Compare commits

...

18 Commits

Author SHA1 Message Date
Test
b9d94abae4 style: rustfmt vault_config.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:58:39 +01:00
Test
18b2aaedf6 fix: add missing visible field to buildNewEntry in useNoteActions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:57:00 +01:00
Test
1706300494 test: add Playwright smoke test for visible type property
Also updates vite.config.ts vault API parser to include all VaultEntry
fields (visible, icon, color, order, etc.) so the dev server returns
complete entries for sidebar filtering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:52:40 +01:00
Test
628ab76f09 feat: migrate hidden_sections from ui.config to visible property on Type notes
On startup, reads hidden_sections from config/ui.config.md, creates or
updates Type notes with visible: false, then re-saves config without
hidden_sections. Idempotent and safe to run multiple times.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:44:30 +01:00
Test
d3896ddf01 refactor: remove hidden_sections from VaultConfig and delete useSectionVisibility
Sidebar section visibility is now controlled entirely by the `visible`
property on Type notes. Removes all hidden_sections references from
Rust struct, TypeScript interface, config migration, mock data, and tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:41:00 +01:00
Test
7289a60db3 feat: add handleToggleTypeVisibility to useEntryActions
Toggle visible property on Type note frontmatter: sets visible:false
to hide, deletes visible property to show (defaults to visible).
Wire up in App.tsx via onToggleTypeVisibility prop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:35:18 +01:00
Test
0cff626e48 feat: filter sidebar sections by Type entry visible property
Replace useSectionVisibility hook with direct filtering on
typeEntryMap[type]?.visible !== false. Add onToggleTypeVisibility
callback prop. Tests updated to verify visible:false hides sections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:33:47 +01:00
Test
5a4c986fe3 feat: add visible field to VaultEntry for Type note sidebar visibility
Parse `visible` boolean from Type note frontmatter (defaults to None/true
when absent). Add to SKIP_KEYS to exclude from relationships and properties.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:30:18 +01:00
Test
540b1400e2 🐛 Auto-untrack .laputa-cache.json and .laputa/settings.json from git
These are machine-local files that should never be version controlled:
- .laputa-cache.json: contains absolute paths, changes on every machine
- .laputa/settings.json: per-machine UI settings

Fix: ensure_cache_excluded() now:
1. Adds both files to .git/info/exclude (git-level ignore, no .gitignore needed)
2. Runs `git rm --cached --ignore-unmatch` on vault open to un-track them
   if they were committed in older vaults

This is idempotent and self-healing — existing vaults fix themselves
automatically on next app launch without any manual steps.
2026-03-06 21:10:37 +01:00
Test
826cda852a Pulse: lazy pagination with IntersectionObserver infinite scroll
- Page size reduced from 30 → 20 commits per fetch
- Backend: add `skip` param to get_vault_pulse (git log --skip)
  → true pagination instead of re-fetching everything
- Frontend: IntersectionObserver sentinel at bottom of feed
  → auto-loads next page when user scrolls near end
- Append-only updates (no full re-render on load more)
- Add IntersectionObserver mock to test setup
2026-03-06 21:04:24 +01:00
Test
19583ea1f5 💅 Pulse: add right border, collapse commit files by default
- Add border-r border-[var(--sidebar-border)] to PulseView container
- CommitCard files default to collapsed (expanded: false) for cleaner initial state
- Update tests to reflect new collapsed-by-default behavior
2026-03-06 21:00:35 +01:00
Test
63eb4ff980 fix: address clippy lints in title_case_folder
Use char array pattern and function reference instead of closures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:11:57 +01:00
Test
bbb29857b8 test: add regression tests for hyphenated folder sidebar duplicates
- Sidebar unit test: entries with isA 'Monday Ideas' produce exactly
  one section header (not two)
- Playwright smoke test: verify no duplicate section labels in sidebar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:09:29 +01:00
Test
900ce7f66f fix: normalize hyphenated folder names in infer_type_from_folder()
Split on hyphens and underscores, capitalize each word, join with
spaces. E.g. monday-ideas → Monday Ideas, key-result → Key Result.
This prevents duplicate sidebar sections when folder name differs
from the Type file title.

Bump CACHE_VERSION to 5 to force rescan on existing caches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:59:51 +01:00
Test
eb55c5ec02 refactor: apply rustfmt formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:43:43 +01:00
Test
6f6e7d7cfe fix: vault cache misses files in new directories, breaking theme restore
Root cause: git_uncommitted_files() used `git status --porcelain` which
reports new untracked directories as `?? theme/` instead of listing
individual files inside. The .md filter skipped directory entries, so
files in newly-created directories (theme/default.md, etc.) were
invisible to the cache system.

Fix: additionally use `git ls-files --others --exclude-standard` which
lists individual untracked files, resolving directories to their
contents.

Also:
- Add type/theme.md definition (icon: palette) to restore and getting-started vault
- Seed theme/*.md vault notes in getting-started vault
- Fix mock create_vault_theme to add entries for dev mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:38:54 +01:00
Test
edcb306c7f fix: exclude .laputa-cache.json and settings.json from vault git tracking
These files are machine-specific and should never be committed or cause
conflicts when syncing vaults across devices.

Changes:
- init_repo() now writes .gitignore with .laputa-cache.json and
  .laputa/settings.json excluded before the first commit
- .DS_Store and common editor artifacts also excluded
- openConflictFileRef falls back to openLocalFile() for non-note files
  so 'Open in editor' works for .json conflict files (opens in system
  default app, e.g. TextEdit/VS Code)
- Removed stale git.rs (replaced by git/mod.rs from refactor)
- 2 new Rust tests for .gitignore creation behavior
2026-03-06 15:22:48 +01:00
Test
97be1d1ca3 fix: write .gitignore on vault init to exclude .DS_Store
macOS creates .DS_Store files in every folder which were being tracked
by git, causing constant conflicts when vaults are synced across machines.

init_repo() now writes a .gitignore before the first commit with:
- .DS_Store, .AppleDouble, .LSOverride
- ._ thumbnail files
- Common editor artifacts (.vscode/, .idea/, *.swp)

The file is only written if one doesn't already exist, so user-customized
.gitignore files are respected.

Two new Rust tests verify the behavior.
2026-03-06 15:19:25 +01:00
51 changed files with 1551 additions and 312 deletions

View File

@@ -0,0 +1,3 @@
{
"theme": null
}

View File

@@ -0,0 +1,33 @@
{
"name": "Dark",
"description": "Dark variant with deep navy tones",
"colors": {
"background": "#0f0f1a",
"foreground": "#e0e0e0",
"card": "#16162a",
"popover": "#1e1e3a",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"secondary": "#2a2a4a",
"secondary-foreground": "#e0e0e0",
"muted": "#1e1e3a",
"muted-foreground": "#888888",
"accent": "#2a2a4a",
"accent-foreground": "#e0e0e0",
"destructive": "#f44336",
"border": "#2a2a4a",
"input": "#2a2a4a",
"ring": "#155DFF",
"sidebar-background": "#1a1a2e",
"sidebar-foreground": "#e0e0e0",
"sidebar-border": "#2a2a4a",
"sidebar-accent": "#2a2a4a"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
},
"spacing": {
"sidebar-width": "250px"
}
}

View File

@@ -0,0 +1,33 @@
{
"name": "Default",
"description": "Light theme with warm, paper-like tones",
"colors": {
"background": "#FFFFFF",
"foreground": "#37352F",
"card": "#FFFFFF",
"popover": "#FFFFFF",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"secondary": "#EBEBEA",
"secondary-foreground": "#37352F",
"muted": "#F0F0EF",
"muted-foreground": "#787774",
"accent": "#EBEBEA",
"accent-foreground": "#37352F",
"destructive": "#E03E3E",
"border": "#E9E9E7",
"input": "#E9E9E7",
"ring": "#155DFF",
"sidebar-background": "#F7F6F3",
"sidebar-foreground": "#37352F",
"sidebar-border": "#E9E9E7",
"sidebar-accent": "#EBEBEA"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
},
"spacing": {
"sidebar-width": "250px"
}
}

View File

@@ -0,0 +1,33 @@
{
"name": "Minimal",
"description": "High contrast, minimal chrome",
"colors": {
"background": "#FAFAFA",
"foreground": "#111111",
"card": "#FFFFFF",
"popover": "#FFFFFF",
"primary": "#000000",
"primary-foreground": "#FFFFFF",
"secondary": "#F0F0F0",
"secondary-foreground": "#111111",
"muted": "#F5F5F5",
"muted-foreground": "#666666",
"accent": "#F0F0F0",
"accent-foreground": "#111111",
"destructive": "#CC0000",
"border": "#E0E0E0",
"input": "#E0E0E0",
"ring": "#000000",
"sidebar-background": "#F5F5F5",
"sidebar-foreground": "#111111",
"sidebar-border": "#E0E0E0",
"sidebar-accent": "#E8E8E8"
},
"typography": {
"font-family": "'SF Mono', 'Menlo', monospace",
"font-size-base": "13px"
},
"spacing": {
"sidebar-width": "220px"
}
}

View File

@@ -0,0 +1,33 @@
{
"colors": {
"accent": "#EBEBEA",
"accent-foreground": "#37352F",
"background": "#FFFFFF",
"border": "#E9E9E7",
"card": "#FFFFFF",
"destructive": "#E03E3E",
"foreground": "#37352F",
"input": "#E9E9E7",
"muted": "#F0F0EF",
"muted-foreground": "#787774",
"popover": "#FFFFFF",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"ring": "#155DFF",
"secondary": "#EBEBEA",
"secondary-foreground": "#37352F",
"sidebar-accent": "#EBEBEA",
"sidebar-background": "#F7F6F3",
"sidebar-border": "#E9E9E7",
"sidebar-foreground": "#37352F"
},
"description": "Light theme with warm, paper-like tones",
"name": "Untitled Theme",
"spacing": {
"sidebar-width": "250px"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
}
}

View File

@@ -0,0 +1,33 @@
{
"colors": {
"accent": "#EBEBEA",
"accent-foreground": "#37352F",
"background": "#FFFFFF",
"border": "#E9E9E7",
"card": "#FFFFFF",
"destructive": "#E03E3E",
"foreground": "#37352F",
"input": "#E9E9E7",
"muted": "#F0F0EF",
"muted-foreground": "#787774",
"popover": "#FFFFFF",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"ring": "#155DFF",
"secondary": "#EBEBEA",
"secondary-foreground": "#37352F",
"sidebar-accent": "#EBEBEA",
"sidebar-background": "#F7F6F3",
"sidebar-border": "#E9E9E7",
"sidebar-foreground": "#37352F"
},
"description": "Light theme with warm, paper-like tones",
"name": "Untitled Theme",
"spacing": {
"sidebar-width": "250px"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
}
}

View File

@@ -0,0 +1,33 @@
{
"colors": {
"accent": "#EBEBEA",
"accent-foreground": "#37352F",
"background": "#FFFFFF",
"border": "#E9E9E7",
"card": "#FFFFFF",
"destructive": "#E03E3E",
"foreground": "#37352F",
"input": "#E9E9E7",
"muted": "#F0F0EF",
"muted-foreground": "#787774",
"popover": "#FFFFFF",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"ring": "#155DFF",
"secondary": "#EBEBEA",
"secondary-foreground": "#37352F",
"sidebar-accent": "#EBEBEA",
"sidebar-background": "#F7F6F3",
"sidebar-border": "#E9E9E7",
"sidebar-foreground": "#37352F"
},
"description": "Light theme with warm, paper-like tones",
"name": "Untitled Theme",
"spacing": {
"sidebar-width": "250px"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
}
}

View File

@@ -0,0 +1,33 @@
{
"colors": {
"accent": "#EBEBEA",
"accent-foreground": "#37352F",
"background": "#FFFFFF",
"border": "#E9E9E7",
"card": "#FFFFFF",
"destructive": "#E03E3E",
"foreground": "#37352F",
"input": "#E9E9E7",
"muted": "#F0F0EF",
"muted-foreground": "#787774",
"popover": "#FFFFFF",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"ring": "#155DFF",
"secondary": "#EBEBEA",
"secondary-foreground": "#37352F",
"sidebar-accent": "#EBEBEA",
"sidebar-background": "#F7F6F3",
"sidebar-border": "#E9E9E7",
"sidebar-foreground": "#37352F"
},
"description": "Light theme with warm, paper-like tones",
"name": "Untitled Theme",
"spacing": {
"sidebar-width": "250px"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
}
}

View File

@@ -0,0 +1,5 @@
---
type: config
zoom: 1.3
view_mode: all
---

View File

@@ -4,6 +4,8 @@ Is A: Note
Author: "Clayton Christensen"
Topics: ["[[topic-saas-business]]"]
URL: "https://example.com/innovators-dilemma"
trashed: true
trashed_at: 2026-03-04
---
# The Innovator's Dilemma
*Clayton Christensen*

View File

@@ -0,0 +1,5 @@
# Wikilinks QA Test
See [[ProjectX]] and [[Team Goals|Goals]] for details on the project timeline.
This is a test note for QA purposes.

View File

@@ -0,0 +1,54 @@
---
Is A: Theme
Description: Dark variant with deep navy tones
background: "#0f0f1a"
foreground: "#e0e0e0"
card: "#16162a"
popover: "#1e1e3a"
primary: "#155DFF"
primary-foreground: "#FFFFFF"
secondary: "#2a2a4a"
secondary-foreground: "#e0e0e0"
muted: "#1e1e3a"
muted-foreground: "#888888"
accent: "#2a2a4a"
accent-foreground: "#e0e0e0"
destructive: "#f44336"
border: "#2a2a4a"
input: "#2a2a4a"
ring: "#155DFF"
sidebar: "#1a1a2e"
sidebar-foreground: "#e0e0e0"
sidebar-border: "#2a2a4a"
sidebar-accent: "#2a2a4a"
text-primary: "#e0e0e0"
text-secondary: "#888888"
text-muted: "#666666"
text-heading: "#e0e0e0"
bg-primary: "#0f0f1a"
bg-sidebar: "#1a1a2e"
bg-hover: "#2a2a4a"
bg-hover-subtle: "#1e1e3a"
bg-selected: "#155DFF22"
border-primary: "#2a2a4a"
accent-blue: "#155DFF"
accent-green: "#00B38B"
accent-orange: "#D9730D"
accent-red: "#f44336"
accent-purple: "#A932FF"
accent-yellow: "#F0B100"
accent-blue-light: "#155DFF33"
accent-green-light: "#00B38B33"
accent-purple-light: "#A932FF33"
accent-red-light: "#f4433633"
accent-yellow-light: "#F0B10033"
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
font-size-base: 14px
editor-font-size: 16
editor-line-height: 1.5
editor-max-width: 720
---
# Dark Theme
A dark theme with deep navy tones for comfortable night-time reading.

View File

@@ -0,0 +1,54 @@
---
Is A: Theme
Description: Light theme with warm, paper-like tones
background: "#FFFFFF"
foreground: "#37352F"
card: "#FFFFFF"
popover: "#FFFFFF"
primary: "#155DFF"
primary-foreground: "#FFFFFF"
secondary: "#EBEBEA"
secondary-foreground: "#37352F"
muted: "#F0F0EF"
muted-foreground: "#787774"
accent: "#EBEBEA"
accent-foreground: "#37352F"
destructive: "#E03E3E"
border: "#E9E9E7"
input: "#E9E9E7"
ring: "#155DFF"
sidebar: "#F7F6F3"
sidebar-foreground: "#37352F"
sidebar-border: "#E9E9E7"
sidebar-accent: "#EBEBEA"
text-primary: "#37352F"
text-secondary: "#787774"
text-muted: "#B4B4B4"
text-heading: "#37352F"
bg-primary: "#FFFFFF"
bg-sidebar: "#F7F6F3"
bg-hover: "#EBEBEA"
bg-hover-subtle: "#F0F0EF"
bg-selected: "#E8F4FE"
border-primary: "#E9E9E7"
accent-blue: "#155DFF"
accent-green: "#00B38B"
accent-orange: "#D9730D"
accent-red: "#E03E3E"
accent-purple: "#A932FF"
accent-yellow: "#F0B100"
accent-blue-light: "#155DFF14"
accent-green-light: "#00B38B14"
accent-purple-light: "#A932FF14"
accent-red-light: "#E03E3E14"
accent-yellow-light: "#F0B10014"
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
font-size-base: 14px
editor-font-size: 16
editor-line-height: 1.5
editor-max-width: 720
---
# Default Theme
The default light theme for Laputa. Clean and warm, inspired by Notion.

View File

@@ -0,0 +1,54 @@
---
Is A: Theme
Description: High contrast, minimal chrome
background: "#FAFAFA"
foreground: "#111111"
card: "#FFFFFF"
popover: "#FFFFFF"
primary: "#000000"
primary-foreground: "#FFFFFF"
secondary: "#F0F0F0"
secondary-foreground: "#111111"
muted: "#F5F5F5"
muted-foreground: "#666666"
accent: "#F0F0F0"
accent-foreground: "#111111"
destructive: "#CC0000"
border: "#E0E0E0"
input: "#E0E0E0"
ring: "#000000"
sidebar: "#F5F5F5"
sidebar-foreground: "#111111"
sidebar-border: "#E0E0E0"
sidebar-accent: "#E8E8E8"
text-primary: "#111111"
text-secondary: "#666666"
text-muted: "#999999"
text-heading: "#111111"
bg-primary: "#FAFAFA"
bg-sidebar: "#F5F5F5"
bg-hover: "#EBEBEB"
bg-hover-subtle: "#F5F5F5"
bg-selected: "#00000014"
border-primary: "#E0E0E0"
accent-blue: "#000000"
accent-green: "#006600"
accent-orange: "#996600"
accent-red: "#CC0000"
accent-purple: "#660099"
accent-yellow: "#996600"
accent-blue-light: "#00000014"
accent-green-light: "#00660014"
accent-purple-light: "#66009914"
accent-red-light: "#CC000014"
accent-yellow-light: "#99660014"
font-family: "'SF Mono', 'Menlo', monospace"
font-size-base: 13px
editor-font-size: 15
editor-line-height: 1.6
editor-max-width: 680
---
# Minimal Theme
High contrast, minimal chrome. Monospace typography throughout.

View File

@@ -221,10 +221,12 @@ pub fn get_file_diff_at_commit(
pub fn get_vault_pulse(
vault_path: String,
limit: Option<usize>,
skip: Option<usize>,
) -> Result<Vec<PulseCommit>, String> {
let vault_path = expand_tilde(&vault_path);
let limit = limit.unwrap_or(30);
git::get_vault_pulse(&vault_path, limit)
let limit = limit.unwrap_or(20);
let skip = skip.unwrap_or(0);
git::get_vault_pulse(&vault_path, limit, skip)
}
#[tauri::command]

View File

@@ -36,6 +36,34 @@ pub fn init_repo(path: &str) -> Result<(), String> {
run_git(dir, &["init"])?;
ensure_author_config(dir)?;
// Write .gitignore before the first commit so machine-specific and
// macOS metadata files are never tracked and don't cause conflicts.
let gitignore_path = dir.join(".gitignore");
if !gitignore_path.exists() {
std::fs::write(
&gitignore_path,
"# Laputa app files (machine-specific, never commit)\n\
.laputa-cache.json\n\
.laputa/settings.json\n\
\n\
# macOS\n\
.DS_Store\n\
.AppleDouble\n\
.LSOverride\n\
\n\
# Thumbnails\n\
._*\n\
\n\
# Editors\n\
.vscode/\n\
.idea/\n\
*.swp\n\
*.swo\n",
)
.map_err(|e| format!("Failed to write .gitignore: {}", e))?;
}
run_git(dir, &["add", "."])?;
run_git(dir, &["commit", "-m", "Initial vault setup"])?;
@@ -236,6 +264,52 @@ mod tests {
);
}
#[test]
fn test_init_repo_creates_gitignore_with_ds_store() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("new-vault");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("note.md"), "# Test\n").unwrap();
init_repo(vault.to_str().unwrap()).unwrap();
let gitignore = vault.join(".gitignore");
assert!(
gitignore.exists(),
".gitignore should be created by init_repo"
);
let content = fs::read_to_string(&gitignore).unwrap();
assert!(
content.contains(".DS_Store"),
".gitignore should exclude .DS_Store"
);
assert!(
content.contains(".laputa-cache.json"),
".gitignore should exclude .laputa-cache.json"
);
assert!(
content.contains(".laputa/settings.json"),
".gitignore should exclude settings.json"
);
}
#[test]
fn test_init_repo_does_not_overwrite_existing_gitignore() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("new-vault");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("note.md"), "# Test\n").unwrap();
fs::write(vault.join(".gitignore"), "custom-rule\n").unwrap();
init_repo(vault.to_str().unwrap()).unwrap();
let content = fs::read_to_string(vault.join(".gitignore")).unwrap();
assert_eq!(
content, "custom-rule\n",
"existing .gitignore should not be overwritten"
);
}
#[test]
fn test_parse_github_repo_path_https() {
assert_eq!(

View File

@@ -53,7 +53,8 @@ fn parse_file_status(code: &str) -> &str {
}
/// Get the pulse (commit activity feed) for a vault, showing only .md file changes.
pub fn get_vault_pulse(vault_path: &str, limit: usize) -> Result<Vec<PulseCommit>, String> {
/// `skip` offsets into the commit list for pagination; `limit` caps how many to return.
pub fn get_vault_pulse(vault_path: &str, limit: usize, skip: usize) -> Result<Vec<PulseCommit>, String> {
let vault = Path::new(vault_path);
if !vault.join(".git").exists() {
@@ -61,6 +62,7 @@ pub fn get_vault_pulse(vault_path: &str, limit: usize) -> Result<Vec<PulseCommit
}
let limit_str = limit.to_string();
let skip_str = skip.to_string();
let output = Command::new("git")
.args([
"log",
@@ -69,6 +71,8 @@ pub fn get_vault_pulse(vault_path: &str, limit: usize) -> Result<Vec<PulseCommit
"--diff-filter=ADM",
"-n",
&limit_str,
"--skip",
&skip_str,
"--",
"*.md",
])
@@ -255,7 +259,7 @@ mod tests {
fs::write(vault.join("project.md"), "# Project\n").unwrap();
git_commit(vp, "Add project").unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
assert_eq!(pulse.len(), 2);
assert_eq!(pulse[0].message, "Add project");
@@ -273,7 +277,7 @@ mod tests {
let dir = TempDir::new().unwrap();
let vp = dir.path().to_str().unwrap();
let result = get_vault_pulse(vp, 30);
let result = get_vault_pulse(vp, 30, 0);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Not a git repository"));
}
@@ -283,7 +287,7 @@ mod tests {
let dir = setup_git_repo();
let vp = dir.path().to_str().unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
assert!(pulse.is_empty());
}
@@ -297,7 +301,7 @@ mod tests {
fs::write(vault.join("config.json"), "{}").unwrap();
git_commit(vp, "Add files").unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
assert_eq!(pulse.len(), 1);
assert_eq!(pulse[0].files.len(), 1);
assert_eq!(pulse[0].files[0].path, "note.md");
@@ -318,7 +322,7 @@ mod tests {
git_commit(vp, &format!("Add note {}", i)).unwrap();
}
let pulse = get_vault_pulse(vp, 3).unwrap();
let pulse = get_vault_pulse(vp, 3, 0).unwrap();
assert_eq!(pulse.len(), 3);
}
@@ -334,7 +338,7 @@ mod tests {
fs::write(vault.join("note.md"), "# Updated\n").unwrap();
git_commit(vp, "Update note").unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
assert_eq!(pulse[0].message, "Update note");
assert_eq!(pulse[0].files[0].status, "modified");
assert_eq!(pulse[0].modified, 1);
@@ -360,7 +364,7 @@ mod tests {
.output()
.unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
assert!(pulse[0].github_url.is_some());
let url = pulse[0].github_url.as_ref().unwrap();
assert!(url.starts_with("https://github.com/owner/repo/commit/"));
@@ -375,7 +379,7 @@ mod tests {
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "Add note").unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
assert!(pulse[0].github_url.is_none());
}

View File

@@ -44,11 +44,17 @@ fn run_startup_tasks() {
"Migrated is_a to type on startup",
vault::migrate_is_a_to_type(vp_str),
);
log_startup_result(
"Migrated hidden_sections to visible property",
vault_config::migrate_hidden_sections_to_visible(vp_str),
);
// Seed _themes/ with built-in JSON themes (legacy) if missing
theme::seed_default_themes(vp_str);
// Seed theme/ with built-in vault theme notes if missing
theme::seed_vault_themes(vp_str);
// Seed type/theme.md so the Theme type has an icon in the sidebar
let _ = theme::ensure_theme_type_definition(vp_str);
// Register Laputa MCP server in Claude Code and Cursor configs
match mcp::register_mcp(vp_str) {

View File

@@ -329,3 +329,15 @@ editor-max-width: 680\n\
# Minimal Theme\n\
\n\
High contrast, minimal chrome. Monospace typography throughout.\n";
/// Type definition for the Theme note type.
pub const THEME_TYPE_DEFINITION: &str = "---\n\
Is A: Type\n\
icon: palette\n\
color: purple\n\
order: 50\n\
---\n\
\n\
# Theme\n\
\n\
A visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n";

View File

@@ -10,7 +10,8 @@ use std::path::Path;
pub use create::{create_theme, create_vault_theme};
pub use defaults::*;
pub use seed::{
ensure_vault_themes, restore_default_themes, seed_default_themes, seed_vault_themes,
ensure_theme_type_definition, ensure_vault_themes, restore_default_themes, seed_default_themes,
seed_vault_themes,
};
/// A theme file parsed from _themes/*.json in the vault.

View File

@@ -104,9 +104,25 @@ pub fn restore_default_themes(vault_path: &str) -> Result<String, String> {
// Seed theme/ markdown notes (reuses ensure_vault_themes for consistency)
ensure_vault_themes(vault_path)?;
// Seed type/theme.md so the Theme type has an icon and label in the sidebar
ensure_theme_type_definition(vault_path)?;
Ok("Default themes restored".to_string())
}
/// Create `type/theme.md` if it doesn't exist (gives the Theme type a sidebar icon/color).
pub fn ensure_theme_type_definition(vault_path: &str) -> Result<(), String> {
let type_dir = Path::new(vault_path).join("type");
fs::create_dir_all(&type_dir).map_err(|e| format!("Failed to create type directory: {e}"))?;
let path = type_dir.join("theme.md");
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
if needs_write {
fs::write(&path, THEME_TYPE_DEFINITION)
.map_err(|e| format!("Failed to write type/theme.md: {e}"))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -244,6 +260,46 @@ mod tests {
assert!(vault.join("theme").join("default.md").exists());
assert!(vault.join("theme").join("dark.md").exists());
assert!(vault.join("theme").join("minimal.md").exists());
assert!(
vault.join("type").join("theme.md").exists(),
"restore must create type/theme.md"
);
let type_content = fs::read_to_string(vault.join("type").join("theme.md")).unwrap();
assert!(type_content.contains("Is A: Type"));
assert!(type_content.contains("icon: palette"));
}
#[test]
fn test_ensure_theme_type_definition_creates_file() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
ensure_theme_type_definition(vp).unwrap();
let path = vault.join("type").join("theme.md");
assert!(path.exists());
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("Is A: Type"));
assert!(content.contains("icon: palette"));
}
#[test]
fn test_ensure_theme_type_definition_is_idempotent() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let type_dir = vault.join("type");
fs::create_dir_all(&type_dir).unwrap();
let custom = "---\nIs A: Type\nicon: swatches\ncolor: green\n---\n# Theme\n";
fs::write(type_dir.join("theme.md"), custom).unwrap();
let vp = vault.to_str().unwrap();
ensure_theme_type_definition(vp).unwrap();
let content = fs::read_to_string(type_dir.join("theme.md")).unwrap();
assert!(
content.contains("swatches"),
"existing content must be preserved"
);
}
#[test]

View File

@@ -7,7 +7,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 = 4;
const CACHE_VERSION: u32 = 5;
#[derive(Debug, Serialize, Deserialize)]
struct VaultCache {
@@ -79,15 +79,10 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String
.map(|s| collect_md_paths_from_diff(&s))
.unwrap_or_default();
// Use ls-files for untracked files so that newly-seeded directories are picked up
// as individual files rather than as a single "?? dirname/" entry.
// Include uncommitted changes (modified, staged, and untracked files).
let uncommitted = git_uncommitted_files(vault);
// Also include modified-but-unstaged files via status --porcelain.
let modified = run_git(vault, &["status", "--porcelain"])
.map(|s| collect_md_paths_from_porcelain(&s))
.unwrap_or_default();
for path in uncommitted.into_iter().chain(modified) {
for path in uncommitted.into_iter() {
if !files.contains(&path) {
files.push(path);
}
@@ -97,9 +92,30 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String
}
fn git_uncommitted_files(vault: &Path) -> Vec<String> {
run_git(vault, &["status", "--porcelain"])
// Modified/staged tracked files from git status --porcelain
let mut files: Vec<String> = run_git(vault, &["status", "--porcelain"])
.map(|s| collect_md_paths_from_porcelain(&s))
.unwrap_or_default()
.unwrap_or_default();
// Untracked files via ls-files (lists individual files, not just directories).
// git status --porcelain shows `?? dir/` for new directories, hiding individual
// files inside — ls-files resolves them so the cache picks up all new .md files.
let untracked = run_git(vault, &["ls-files", "--others", "--exclude-standard"])
.map(|s| {
s.lines()
.filter(|l| !l.is_empty() && l.ends_with(".md"))
.map(|l| l.to_string())
.collect::<Vec<_>>()
})
.unwrap_or_default();
for path in untracked {
if !files.contains(&path) {
files.push(path);
}
}
files
}
fn load_cache(vault: &Path) -> Option<VaultCache> {
@@ -140,21 +156,47 @@ fn parse_files_at(vault: &Path, rel_paths: &[String]) -> Vec<VaultEntry> {
.collect()
}
/// Ensure `.laputa-cache.json` is excluded from git via `.git/info/exclude`.
/// This prevents the cache (which contains machine-specific absolute paths)
/// from being committed and causing stale-path bugs on cloned vaults.
/// Machine-local files that should never be git-tracked in any vault.
/// These are either caches with absolute paths or per-machine settings.
const UNTRACKED_FILES: &[&str] = &[
".laputa-cache.json",
".laputa/settings.json",
];
/// Ensure machine-local files are excluded from git via `.git/info/exclude`
/// and un-tracked if they were previously committed (git rm --cached).
/// Called on every cache write so existing vaults self-heal automatically.
fn ensure_cache_excluded(vault: &Path) {
let exclude_path = vault.join(".git/info/exclude");
let entry = ".laputa-cache.json";
if let Ok(content) = fs::read_to_string(&exclude_path) {
if content.lines().any(|line| line.trim() == entry) {
return;
}
let separator = if content.ends_with('\n') { "" } else { "\n" };
let _ = fs::write(&exclude_path, format!("{content}{separator}{entry}\n"));
} else if exclude_path.parent().map(|p| p.is_dir()).unwrap_or(false) {
let _ = fs::write(&exclude_path, format!("{entry}\n"));
let git_dir = vault.join(".git");
if !git_dir.is_dir() {
return;
}
let exclude_path = git_dir.join("info").join("exclude");
// 1. Add each entry to .git/info/exclude so git ignores it going forward.
let existing = fs::read_to_string(&exclude_path).unwrap_or_default();
let mut to_add: Vec<&str> = UNTRACKED_FILES
.iter()
.filter(|e| !existing.lines().any(|line| line.trim() == **e))
.copied()
.collect();
if !to_add.is_empty() {
to_add.sort();
let separator = if existing.ends_with('\n') || existing.is_empty() { "" } else { "\n" };
let additions = to_add.join("\n");
let _ = fs::write(&exclude_path, format!("{existing}{separator}{additions}\n"));
}
// 2. Un-track each file if git currently tracks it.
// `git rm --cached --quiet --ignore-unmatch` exits 0 even if the file isn't tracked.
// This fixes existing vaults where these files were committed before this guard.
let _ = std::process::Command::new("git")
.args(["rm", "--cached", "--quiet", "--ignore-unmatch", "--"])
.args(UNTRACKED_FILES)
.current_dir(vault)
.output();
}
/// Sort entries by modified_at descending and write the cache.
@@ -541,4 +583,66 @@ mod tests {
assert!(titles.contains(&"Existing"));
assert!(titles.contains(&"New Note"));
}
#[test]
fn test_update_same_commit_new_files_in_new_subdirectory() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
std::process::Command::new("git")
.args(["init"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(vault)
.output()
.unwrap();
create_test_file(vault, "existing.md", "# Existing\n");
std::process::Command::new("git")
.args(["add", "."])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(vault)
.output()
.unwrap();
// Prime cache
let entries = scan_vault_cached(vault).unwrap();
assert_eq!(entries.len(), 1);
// Create files in a new subdirectory (simulates restore_default_themes)
create_test_file(
vault,
"theme/default.md",
"---\nIs A: Theme\n---\n# Default Theme\n",
);
create_test_file(
vault,
"theme/dark.md",
"---\nIs A: Theme\n---\n# Dark Theme\n",
);
// Cache same commit — files in new subdirectory must appear
let entries2 = scan_vault_cached(vault).unwrap();
assert_eq!(
entries2.len(),
3,
"must pick up files in new untracked subdirectory"
);
let titles: Vec<&str> = entries2.iter().map(|e| e.title.as_str()).collect();
assert!(titles.contains(&"Existing"));
assert!(titles.contains(&"Default Theme"));
assert!(titles.contains(&"Dark Theme"));
}
}

View File

@@ -133,6 +133,10 @@ const SAMPLE_FILES: &[SampleFile] = &[
rel_path: "type/topic.md",
content: "---\nIs A: Type\nicon: tag\ncolor: yellow\norder: 4\n---\n\n# Topic\n\nA Topic is a subject area or interest category that groups related notes, projects, and people.\n",
},
SampleFile {
rel_path: "type/theme.md",
content: "---\nIs A: Type\nicon: palette\ncolor: purple\norder: 50\n---\n\n# Theme\n\nA visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n",
},
SampleFile {
rel_path: "note/welcome-to-laputa.md",
content: r#"---
@@ -394,7 +398,7 @@ pub fn create_getting_started_vault(target_path: &str) -> Result<String, String>
.map_err(|e| format!("Failed to write {}: {}", sample.rel_path, e))?;
}
// Seed built-in themes
// Seed built-in themes (both JSON legacy and vault-based markdown notes)
let themes_dir = vault_dir.join("_themes");
fs::create_dir_all(&themes_dir)
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
@@ -405,6 +409,25 @@ pub fn create_getting_started_vault(target_path: &str) -> Result<String, String>
fs::write(themes_dir.join("minimal.json"), crate::theme::MINIMAL_THEME)
.map_err(|e| format!("Failed to write minimal theme: {e}"))?;
let theme_notes_dir = vault_dir.join("theme");
fs::create_dir_all(&theme_notes_dir)
.map_err(|e| format!("Failed to create theme directory: {e}"))?;
fs::write(
theme_notes_dir.join("default.md"),
crate::theme::DEFAULT_VAULT_THEME,
)
.map_err(|e| format!("Failed to write default vault theme: {e}"))?;
fs::write(
theme_notes_dir.join("dark.md"),
crate::theme::DARK_VAULT_THEME,
)
.map_err(|e| format!("Failed to write dark vault theme: {e}"))?;
fs::write(
theme_notes_dir.join("minimal.md"),
crate::theme::MINIMAL_VAULT_THEME,
)
.map_err(|e| format!("Failed to write minimal vault theme: {e}"))?;
crate::git::init_repo(target_path)?;
Ok(vault_dir
@@ -507,8 +530,8 @@ mod tests {
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
let entries = crate::vault::scan_vault(&vault_path).unwrap();
// SAMPLE_FILES + AGENTS.md
assert_eq!(entries.len(), SAMPLE_FILES.len() + 1);
// SAMPLE_FILES + AGENTS.md + 3 vault theme notes (theme/default.md, dark.md, minimal.md)
assert_eq!(entries.len(), SAMPLE_FILES.len() + 1 + 3);
}
#[test]
@@ -583,12 +606,21 @@ mod tests {
let vault_path = dir.path().join("theme-vault");
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
// JSON legacy themes
assert!(vault_path.join("_themes/default.json").exists());
assert!(vault_path.join("_themes/dark.json").exists());
assert!(vault_path.join("_themes/minimal.json").exists());
let themes = crate::theme::list_themes(vault_path.to_str().unwrap()).unwrap();
assert_eq!(themes.len(), 3);
// Vault-based theme notes
assert!(vault_path.join("theme/default.md").exists());
assert!(vault_path.join("theme/dark.md").exists());
assert!(vault_path.join("theme/minimal.md").exists());
// Theme type definition
assert!(vault_path.join("type/theme.md").exists());
}
#[test]

View File

@@ -14,8 +14,8 @@ pub use rename::{rename_note, RenameResult};
pub use trash::{delete_note, purge_trash};
use parsing::{
capitalize_first, contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet,
extract_title, parse_iso_date,
contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet, extract_title,
parse_iso_date, title_case_folder,
};
use gray_matter::engine::YAML;
@@ -74,6 +74,8 @@ pub struct VaultEntry {
/// Default view mode for the note list when viewing instances of this Type.
/// Stored as a string: "all", "editor-list", or "editor-only".
pub view: Option<String>,
/// Whether this Type is visible in the sidebar. Defaults to true when absent.
pub visible: Option<bool>,
/// Word count of the note body (excludes frontmatter and H1 title).
#[serde(rename = "wordCount")]
pub word_count: u32,
@@ -128,6 +130,8 @@ struct Frontmatter {
sort: Option<String>,
#[serde(default)]
view: Option<String>,
#[serde(default)]
visible: Option<bool>,
}
/// Handles YAML fields that can be either a single string or a list of strings.
@@ -175,6 +179,7 @@ const SKIP_KEYS: &[&str] = &[
"template",
"sort",
"view",
"visible",
];
/// Extract all wikilink-containing fields from raw YAML frontmatter.
@@ -274,7 +279,7 @@ fn infer_type_from_folder(folder: &str) -> String {
"month" => "Month",
"essay" => "Essay",
"evergreen" => "Evergreen",
_ => return capitalize_first(folder),
_ => return title_case_folder(folder),
}
.to_string()
}
@@ -401,6 +406,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
template: frontmatter.template,
sort: frontmatter.sort,
view: frontmatter.view,
visible: frontmatter.visible,
word_count,
outgoing_links,
properties,
@@ -960,6 +966,28 @@ References:
assert_eq!(entry.is_a, Some("Recipe".to_string()));
}
#[test]
fn test_infer_type_from_hyphenated_folder_title_cases() {
let dir = TempDir::new().unwrap();
let cases = vec![
("monday-ideas", "Monday Ideas"),
("key-result", "Key Result"),
("my_custom_type", "My Custom Type"),
("mix-and_match", "Mix And Match"),
];
for (folder, expected) in cases {
create_test_file(dir.path(), &format!("{}/test.md", folder), "# Test\n");
let entry = parse_md_file(&dir.path().join(folder).join("test.md")).unwrap();
assert_eq!(
entry.is_a,
Some(expected.to_string()),
"folder '{}' should infer type '{}'",
folder,
expected
);
}
}
#[test]
fn test_infer_type_frontmatter_overrides_folder() {
let dir = TempDir::new().unwrap();
@@ -1352,6 +1380,48 @@ Company: Acme Corp
assert!(entry.trashed_at.is_none());
}
// --- visible field tests ---
#[test]
fn test_parse_visible_false_from_type_entry() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nvisible: false\n---\n# Journal\n";
let entry = parse_test_entry(&dir, "type/journal.md", content);
assert_eq!(entry.visible, Some(false));
}
#[test]
fn test_parse_visible_true_from_type_entry() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nvisible: true\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert_eq!(entry.visible, Some(true));
}
#[test]
fn test_parse_visible_missing_defaults_to_none() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert_eq!(entry.visible, None);
}
#[test]
fn test_visible_not_in_relationships() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nvisible: false\n---\n# Journal\n";
let entry = parse_test_entry(&dir, "type/journal.md", content);
assert!(entry.relationships.get("visible").is_none());
}
#[test]
fn test_visible_not_in_properties() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nvisible: false\n---\n# Journal\n";
let entry = parse_test_entry(&dir, "type/journal.md", content);
assert!(entry.properties.get("visible").is_none());
}
// Frontmatter update/delete tests are in frontmatter.rs
// save_image tests are in vault/image.rs
// purge_trash tests are in vault/trash.rs

View File

@@ -195,6 +195,16 @@ pub(super) fn capitalize_first(s: &str) -> String {
}
}
/// Title-case a folder name: split on hyphens and underscores, capitalize each word, join with spaces.
/// Example: "monday-ideas" → "Monday Ideas", "key_result" → "Key Result"
pub(super) fn title_case_folder(s: &str) -> String {
s.split(['-', '_'])
.filter(|w| !w.is_empty())
.map(capitalize_first)
.collect::<Vec<_>>()
.join(" ")
}
/// Parse an ISO 8601 date string to Unix timestamp (seconds since epoch).
/// Handles "2025-05-23T14:35:00.000Z" and "2025-05-23" formats.
pub(super) fn parse_iso_date(date_str: &str) -> Option<u64> {
@@ -457,6 +467,29 @@ mod tests {
assert_eq!(capitalize_first("a"), "A");
}
// --- title_case_folder tests ---
#[test]
fn test_title_case_folder_hyphenated() {
assert_eq!(title_case_folder("monday-ideas"), "Monday Ideas");
assert_eq!(title_case_folder("key-result"), "Key Result");
}
#[test]
fn test_title_case_folder_underscored() {
assert_eq!(title_case_folder("my_custom_type"), "My Custom Type");
}
#[test]
fn test_title_case_folder_single_word() {
assert_eq!(title_case_folder("recipe"), "Recipe");
}
#[test]
fn test_title_case_folder_empty() {
assert_eq!(title_case_folder(""), "");
}
// --- without_h1_line tests ---
#[test]

View File

@@ -18,8 +18,6 @@ pub struct VaultConfig {
pub status_colors: Option<HashMap<String, String>>,
#[serde(default)]
pub property_display_modes: Option<HashMap<String, String>>,
#[serde(default)]
pub hidden_sections: Option<Vec<String>>,
}
const CONFIG_DIR: &str = "config";
@@ -100,15 +98,6 @@ fn serialize_config(config: &VaultConfig) -> String {
"property_display_modes",
config.property_display_modes.as_ref(),
);
if let Some(ref sections) = config.hidden_sections {
if !sections.is_empty() {
lines.push("hidden_sections:".to_string());
for s in sections {
lines.push(format!(" - {s}"));
}
}
}
lines.push("---".to_string());
lines.join("\n") + "\n"
}
@@ -149,6 +138,108 @@ fn yaml_safe_value(value: &str) -> String {
}
}
/// Migrate `hidden_sections` from `config/ui.config.md` to `visible: false`
/// on Type notes. Returns the number of Type notes updated.
///
/// For each type name in `hidden_sections`:
/// - If `type/<slug>.md` exists, adds `visible: false` to its frontmatter
/// - If it doesn't exist, creates it with `type: Type`, `title: <name>`, `visible: false`
/// - Re-saves the config without `hidden_sections`
pub fn migrate_hidden_sections_to_visible(vault_path: &str) -> Result<usize, String> {
let path = config_path(vault_path);
if !path.exists() {
return Ok(0);
}
let content =
std::fs::read_to_string(&path).map_err(|e| format!("Failed to read config: {e}"))?;
let hidden = extract_hidden_sections(&content);
if hidden.is_empty() {
return Ok(0);
}
let type_dir = Path::new(vault_path).join("type");
if !type_dir.exists() {
std::fs::create_dir_all(&type_dir)
.map_err(|e| format!("Failed to create type dir: {e}"))?;
}
let mut migrated = 0;
for type_name in &hidden {
let slug = type_name_to_slug(type_name);
let type_path = type_dir.join(format!("{slug}.md"));
if type_path.exists() {
let type_content = std::fs::read_to_string(&type_path)
.map_err(|e| format!("Failed to read {}: {e}", type_path.display()))?;
if !type_content.contains("visible:") {
let updated = crate::frontmatter::update_frontmatter_content(
&type_content,
"visible",
Some(crate::frontmatter::FrontmatterValue::Bool(false)),
)
.map_err(|e| format!("Failed to update {}: {e}", type_path.display()))?;
std::fs::write(&type_path, updated)
.map_err(|e| format!("Failed to write {}: {e}", type_path.display()))?;
}
} else {
let new_content = format!(
"---\ntype: Type\ntitle: {}\nvisible: false\n---\n\n# {}\n",
type_name, type_name
);
std::fs::write(&type_path, new_content)
.map_err(|e| format!("Failed to write {}: {e}", type_path.display()))?;
}
migrated += 1;
}
// Re-save config without hidden_sections
let config = parse_vault_config(&content)?;
save_vault_config(vault_path, config)?;
Ok(migrated)
}
/// Extract `hidden_sections` from raw YAML frontmatter.
fn extract_hidden_sections(content: &str) -> Vec<String> {
let matter = Matter::<YAML>::new();
let parsed = matter.parse(content);
let hash = match parsed.data {
Some(gray_matter::Pod::Hash(map)) => map,
_ => return vec![],
};
match hash.get("hidden_sections") {
Some(gray_matter::Pod::Array(arr)) => arr
.iter()
.filter_map(|v| match v {
gray_matter::Pod::String(s) => Some(s.clone()),
_ => None,
})
.collect(),
_ => vec![],
}
}
/// Convert a Type name to a filesystem slug.
fn type_name_to_slug(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
/// Convert gray_matter::Pod to serde_json::Value.
fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
match pod {
@@ -194,9 +285,6 @@ status_colors:
Done: blue
property_display_modes:
deadline: date
hidden_sections:
- Archived
- Trash
---
"#;
let config = parse_vault_config(content).unwrap();
@@ -209,8 +297,6 @@ hidden_sections:
assert_eq!(statuses.get("Active").unwrap(), "green");
let props = config.property_display_modes.unwrap();
assert_eq!(props.get("deadline").unwrap(), "date");
let hidden = config.hidden_sections.unwrap();
assert_eq!(hidden, vec!["Archived", "Trash"]);
}
#[test]
@@ -223,14 +309,12 @@ hidden_sections:
tag_colors: Some(tag_colors),
status_colors: None,
property_display_modes: None,
hidden_sections: Some(vec!["Trash".to_string()]),
};
let serialized = serialize_config(&config);
let parsed = parse_vault_config(&serialized).unwrap();
assert_eq!(parsed.zoom, Some(1.2));
assert_eq!(parsed.view_mode.as_deref(), Some("editor-only"));
assert_eq!(parsed.tag_colors.unwrap().get("work").unwrap(), "blue");
assert_eq!(parsed.hidden_sections.unwrap(), vec!["Trash"]);
}
#[test]
@@ -252,7 +336,6 @@ hidden_sections:
tag_colors: None,
status_colors: Some(status_colors),
property_display_modes: None,
hidden_sections: None,
};
save_vault_config(vault_path, config).unwrap();
@@ -264,6 +347,131 @@ hidden_sections:
);
}
#[test]
fn migrate_hidden_sections_creates_type_notes_with_visible_false() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
// Create config with hidden_sections
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("ui.config.md"),
"---\ntype: config\nhidden_sections:\n - Bookmark\n - Recipe\n---\n",
)
.unwrap();
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
assert_eq!(count, 2);
// Check type notes were created
let bookmark = std::fs::read_to_string(dir.path().join("type/bookmark.md")).unwrap();
assert!(bookmark.contains("visible: false"));
assert!(bookmark.contains("title: Bookmark"));
let recipe = std::fs::read_to_string(dir.path().join("type/recipe.md")).unwrap();
assert!(recipe.contains("visible: false"));
// Config should no longer have hidden_sections
let config_content = std::fs::read_to_string(config_dir.join("ui.config.md")).unwrap();
assert!(!config_content.contains("hidden_sections"));
}
#[test]
fn migrate_hidden_sections_updates_existing_type_note() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
// Create config with hidden_sections
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("ui.config.md"),
"---\ntype: config\nhidden_sections:\n - Project\n---\n",
)
.unwrap();
// Create existing type note without visible
let type_dir = dir.path().join("type");
std::fs::create_dir_all(&type_dir).unwrap();
std::fs::write(
type_dir.join("project.md"),
"---\ntype: Type\ntitle: Project\nicon: briefcase\n---\n\n# Project\n",
)
.unwrap();
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
assert_eq!(count, 1);
let content = std::fs::read_to_string(type_dir.join("project.md")).unwrap();
assert!(content.contains("visible: false"));
assert!(
content.contains("icon: briefcase"),
"should preserve existing fields"
);
}
#[test]
fn migrate_hidden_sections_skips_when_no_hidden_sections() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("ui.config.md"),
"---\ntype: config\nzoom: 1.0\n---\n",
)
.unwrap();
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
assert_eq!(count, 0);
}
#[test]
fn migrate_hidden_sections_skips_when_no_config_file() {
let dir = tempfile::TempDir::new().unwrap();
let count = migrate_hidden_sections_to_visible(dir.path().to_str().unwrap()).unwrap();
assert_eq!(count, 0);
}
#[test]
fn migrate_hidden_sections_does_not_duplicate_visible() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("ui.config.md"),
"---\ntype: config\nhidden_sections:\n - Note\n---\n",
)
.unwrap();
// Type note already has visible: false
let type_dir = dir.path().join("type");
std::fs::create_dir_all(&type_dir).unwrap();
std::fs::write(
type_dir.join("note.md"),
"---\ntype: Type\ntitle: Note\nvisible: false\n---\n",
)
.unwrap();
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
assert_eq!(count, 1);
let content = std::fs::read_to_string(type_dir.join("note.md")).unwrap();
// Should have exactly one visible: false, not two
assert_eq!(content.matches("visible:").count(), 1);
}
#[test]
fn type_name_to_slug_converts_names() {
assert_eq!(type_name_to_slug("Project"), "project");
assert_eq!(type_name_to_slug("Weekly Review"), "weekly-review");
assert_eq!(type_name_to_slug("My Note!"), "my-note");
}
#[test]
fn yaml_safe_key_quoting() {
assert_eq!(yaml_safe_key("simple"), "simple");

View File

@@ -45,6 +45,7 @@ import { isTauri, mockInvoke } from './mock-tauri'
import type { SidebarSelection, VaultEntry } from './types'
import type { NoteListItem } from './utils/ai-context'
import { filterEntries } from './utils/noteListHelpers'
import { openLocalFile } from './utils/url'
import './App.css'
// Type declaration for mock content storage
@@ -287,8 +288,13 @@ function App() {
const fullPath = `${resolvedPath}/${relativePath}`
const entry = vault.entries.find(e => e.path === fullPath)
if (entry) {
// Markdown note — open inside Laputa editor
notes.handleSelectNote(entry)
dialogs.closeConflictResolver()
} else {
// Non-note file (e.g. .laputa-cache.json, settings.json) —
// open with system default app so the user can inspect/edit it
openLocalFile(fullPath)
}
}
}, [resolvedPath, vault.entries, notes, dialogs])
@@ -494,7 +500,7 @@ function App() {
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} />
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>

View File

@@ -837,7 +837,7 @@ describe('DynamicPropertiesPanel', () => {
beforeEach(() => {
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null },
vi.fn(),
)
initDisplayModeOverrides({})

View File

@@ -104,11 +104,17 @@ describe('PulseView', () => {
expect(nonLink.tagName).toBe('SPAN')
})
it('renders file list with correct titles', async () => {
it('renders file list with correct titles when expanded', async () => {
mockInvokeFn.mockResolvedValue(mockCommits)
render(<PulseView vaultPath="/test/vault" />)
// Files are collapsed by default — expand all commit cards first
await waitFor(() => {
expect(screen.getAllByLabelText('Expand files').length).toBeGreaterThan(0)
})
screen.getAllByLabelText('Expand files').forEach((btn) => fireEvent.click(btn))
await waitFor(() => {
expect(screen.getByText('my project')).toBeInTheDocument()
})
@@ -122,6 +128,12 @@ describe('PulseView', () => {
render(<PulseView vaultPath="/test/vault" onOpenNote={onOpenNote} />)
// Expand first commit card
await waitFor(() => {
expect(screen.getAllByLabelText('Expand files').length).toBeGreaterThan(0)
})
fireEvent.click(screen.getAllByLabelText('Expand files')[0])
await waitFor(() => {
expect(screen.getByText('my project')).toBeInTheDocument()
})
@@ -136,6 +148,12 @@ describe('PulseView', () => {
render(<PulseView vaultPath="/test/vault" onOpenNote={onOpenNote} />)
// Expand all commit cards to find the deleted file
await waitFor(() => {
expect(screen.getAllByLabelText('Expand files').length).toBeGreaterThan(0)
})
screen.getAllByLabelText('Expand files').forEach((btn) => fireEvent.click(btn))
await waitFor(() => {
expect(screen.getByText('old')).toBeInTheDocument()
})
@@ -165,19 +183,13 @@ describe('PulseView', () => {
expect(screen.getByText('Retry')).toBeInTheDocument()
})
it('shows Load more button when hasMore is true', async () => {
const manyCommits = Array.from({ length: 30 }, (_, i) => ({
...mockCommits[0],
hash: `hash${i}`,
shortHash: `h${i}`,
message: `Commit ${i}`,
}))
mockInvokeFn.mockResolvedValue(manyCommits)
it('calls get_vault_pulse with skip=0 on initial load and passes correct page size', async () => {
mockInvokeFn.mockResolvedValue([])
render(<PulseView vaultPath="/test/vault" />)
await waitFor(() => {
expect(screen.getByText('Load more')).toBeInTheDocument()
expect(mockInvokeFn).toHaveBeenCalledWith('get_vault_pulse', { vaultPath: '/test/vault', limit: 20, skip: 0 })
})
})
@@ -187,24 +199,32 @@ describe('PulseView', () => {
render(<PulseView vaultPath="/my/vault" />)
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('get_vault_pulse', { vaultPath: '/my/vault', limit: 30 })
expect(mockInvokeFn).toHaveBeenCalledWith('get_vault_pulse', { vaultPath: '/my/vault', limit: 20, skip: 0 })
})
})
it('toggles file list visibility when clicking collapse button', async () => {
it('toggles file list visibility when clicking expand/collapse button', async () => {
mockInvokeFn.mockResolvedValue(mockCommits)
render(<PulseView vaultPath="/test/vault" />)
// Files are collapsed by default
await waitFor(() => {
expect(screen.getAllByLabelText('Expand files').length).toBeGreaterThan(0)
})
expect(screen.queryByText('my project')).not.toBeInTheDocument()
// Click expand on first commit card
fireEvent.click(screen.getAllByLabelText('Expand files')[0])
// Files should now be visible
await waitFor(() => {
expect(screen.getByText('my project')).toBeInTheDocument()
})
// Click the collapse button on the first commit card
const collapseBtn = screen.getAllByLabelText('Collapse files')[0]
fireEvent.click(collapseBtn)
// Click collapse to hide again
fireEvent.click(screen.getAllByLabelText('Collapse files')[0])
// Files should be hidden
expect(screen.queryByText('my project')).not.toBeInTheDocument()
})

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, memo } from 'react'
import { useState, useEffect, useCallback, useRef, memo } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { PulseCommit, PulseFile } from '../types'
@@ -96,7 +96,7 @@ function FileItem({ file, onOpenNote }: { file: PulseFile; onOpenNote?: (path: s
}
function CommitCard({ commit, onOpenNote }: { commit: PulseCommit; onOpenNote?: (path: string) => void }) {
const [expanded, setExpanded] = useState(true)
const [expanded, setExpanded] = useState(false)
const Chevron = expanded ? CaretDown : CaretRight
return (
@@ -207,20 +207,29 @@ function ErrorState({ message, onRetry }: { message: string; onRetry: () => void
)
}
const PAGE_SIZE = 20
export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sidebarCollapsed, onExpandSidebar }: PulseViewProps) {
const [commits, setCommits] = useState<PulseCommit[]>([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState<string | null>(null)
const [hasMore, setHasMore] = useState(true)
const batchSize = 30
const [skip, setSkip] = useState(0)
const sentinelRef = useRef<HTMLDivElement | null>(null)
const loadPulse = useCallback(async (limit: number) => {
// Initial load
const loadInitial = useCallback(async () => {
setLoading(true)
setError(null)
setCommits([])
setSkip(0)
setHasMore(true)
try {
const result = await tauriCall<PulseCommit[]>('get_vault_pulse', { vaultPath, limit })
const result = await tauriCall<PulseCommit[]>('get_vault_pulse', { vaultPath, limit: PAGE_SIZE, skip: 0 })
setCommits(result)
setHasMore(result.length >= limit)
setHasMore(result.length >= PAGE_SIZE)
setSkip(result.length)
} catch (err) {
const msg = typeof err === 'string' ? err : 'Failed to load activity'
setError(msg)
@@ -229,17 +238,40 @@ export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sideba
}
}, [vaultPath])
useEffect(() => { loadPulse(batchSize) }, [loadPulse])
// Append next page
const loadMore = useCallback(async () => {
if (loadingMore || !hasMore) return
setLoadingMore(true)
try {
const result = await tauriCall<PulseCommit[]>('get_vault_pulse', { vaultPath, limit: PAGE_SIZE, skip })
setCommits((prev) => [...prev, ...result])
setHasMore(result.length >= PAGE_SIZE)
setSkip((s) => s + result.length)
} catch {
// silently fail for pagination — user can scroll up/retry
} finally {
setLoadingMore(false)
}
}, [vaultPath, skip, loadingMore, hasMore])
const handleLoadMore = useCallback(() => {
const nextLimit = commits.length + batchSize
loadPulse(nextLimit)
}, [commits.length, loadPulse])
useEffect(() => { loadInitial() }, [loadInitial])
// Intersection Observer for infinite scroll
useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel) return
const observer = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting) loadMore() },
{ threshold: 0.1 },
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [loadMore])
const dayGroups = groupCommitsByDay(commits)
return (
<div className="flex h-full flex-col overflow-hidden bg-background">
<div className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-background">
{/* Header */}
<div className="flex shrink-0 items-center justify-between border-b border-border" style={{ height: 52, padding: '0 16px' }}>
<div className="flex items-center" style={{ gap: 8 }}>
@@ -260,12 +292,12 @@ export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sideba
{/* Feed */}
<div className="flex-1 overflow-y-auto">
{loading && commits.length === 0 ? (
{loading ? (
<div className="flex items-center justify-center" style={{ padding: 32 }}>
<span className="text-[13px] text-muted-foreground">Loading activity</span>
</div>
) : error ? (
<ErrorState message={error} onRetry={() => loadPulse(batchSize)} />
<ErrorState message={error} onRetry={loadInitial} />
) : commits.length === 0 ? (
<EmptyState />
) : (
@@ -278,15 +310,11 @@ export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sideba
onOpenNote={onOpenNote}
/>
))}
{hasMore && (
<div style={{ padding: '12px 16px' }}>
<button
className="flex w-full cursor-pointer items-center justify-center rounded border border-border bg-transparent py-2 text-[12px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={handleLoadMore}
disabled={loading}
>
{loading ? 'Loading' : 'Load more'}
</button>
{/* Sentinel for infinite scroll */}
<div ref={sentinelRef} style={{ height: 1 }} />
{loadingMore && (
<div className="flex items-center justify-center" style={{ padding: 12 }}>
<span className="text-[12px] text-muted-foreground">Loading</span>
</div>
)}
</>

View File

@@ -1,8 +1,7 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import { Sidebar } from './Sidebar'
import type { VaultEntry, SidebarSelection } from '../types'
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore'
const mockEntries: VaultEntry[] = [
{
@@ -676,13 +675,96 @@ describe('Sidebar', () => {
})
})
describe('customize section visibility', () => {
beforeEach(() => {
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
vi.fn(),
)
describe('type visibility via visible property', () => {
const makeTypeEntry = (title: string, visible: boolean | null): VaultEntry => ({
path: `/vault/type/${title.toLowerCase()}.md`,
filename: `${title.toLowerCase()}.md`,
title,
isA: 'Type',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 200,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible,
outgoingLinks: [],
properties: {},
})
it('hides a section when its Type entry has visible: false', () => {
const entries: VaultEntry[] = [
...mockEntries,
makeTypeEntry('Person', false),
]
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.queryByText('People')).not.toBeInTheDocument()
// Other sections should still be visible
expect(screen.getByText('Projects')).toBeInTheDocument()
})
it('shows a section when its Type entry has visible: true', () => {
const entries: VaultEntry[] = [
...mockEntries,
makeTypeEntry('Person', true),
]
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('People')).toBeInTheDocument()
})
it('shows a section when its Type entry has visible: null (default)', () => {
const entries: VaultEntry[] = [
...mockEntries,
makeTypeEntry('Person', null),
]
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('People')).toBeInTheDocument()
})
it('shows a section when there is no Type entry at all (default visible)', () => {
// mockEntries has Person instances but no Type entry for Person
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('People')).toBeInTheDocument()
})
it('hides multiple sections when their Type entries have visible: false', () => {
const entries: VaultEntry[] = [
...mockEntries,
makeTypeEntry('Person', false),
makeTypeEntry('Event', false),
]
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.queryByText('People')).not.toBeInTheDocument()
expect(screen.queryByText('Events')).not.toBeInTheDocument()
expect(screen.getByText('Projects')).toBeInTheDocument()
expect(screen.getByText('Topics')).toBeInTheDocument()
})
it('does not affect All Notes or other sidebar filters when sections are hidden', () => {
const entries: VaultEntry[] = [
...mockEntries,
makeTypeEntry('Project', false),
makeTypeEntry('Person', false),
]
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('All Notes')).toBeInTheDocument()
expect(screen.getByText('Favorites')).toBeInTheDocument()
})
it('renders a "Customize sections" button', () => {
@@ -699,74 +781,12 @@ describe('Sidebar', () => {
expect(screen.getByLabelText('Toggle Topics')).toBeInTheDocument()
})
it('hides a section when its toggle is clicked off', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
// People section header should be visible initially
expect(screen.getByText('People')).toBeInTheDocument()
// Open customize popover and toggle People off
it('calls onToggleTypeVisibility when toggling a section in the popover', () => {
const onToggleTypeVisibility = vi.fn()
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onToggleTypeVisibility={onToggleTypeVisibility} />)
fireEvent.click(screen.getByTitle('Customize sections'))
fireEvent.click(screen.getByLabelText('Toggle People'))
// People section header should be gone (use getAllByText to handle popover)
const peopleElements = screen.queryAllByText('People')
// Only the toggle label in the popover should remain, not the section header
expect(peopleElements.length).toBeLessThanOrEqual(1)
})
it('re-shows a section when its toggle is clicked on again', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
// Hide People
fireEvent.click(screen.getByTitle('Customize sections'))
fireEvent.click(screen.getByLabelText('Toggle People'))
// Show People again
fireEvent.click(screen.getByLabelText('Toggle People'))
// People section should be visible again — popover toggle + section header = 2 "People" texts
const peopleElements = screen.getAllByText('People')
expect(peopleElements.length).toBe(2)
})
it('persists hidden sections in vault config', () => {
const { unmount } = render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
fireEvent.click(screen.getByTitle('Customize sections'))
fireEvent.click(screen.getByLabelText('Toggle Events'))
unmount()
// Verify vault config was updated
const stored = getVaultConfig().hidden_sections
expect(stored).toContain('Event')
})
it('restores hidden sections from vault config on mount', () => {
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: ['Person', 'Event'] },
vi.fn(),
)
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
// People and Events section headers should be hidden
expect(screen.queryByText('People')).not.toBeInTheDocument()
expect(screen.queryByText('Events')).not.toBeInTheDocument()
// Other section headers should still be visible
expect(screen.getByText('Projects')).toBeInTheDocument()
expect(screen.getByText('Topics')).toBeInTheDocument()
})
it('does not affect All Notes or other sidebar filters when sections are hidden', () => {
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: ['Project', 'Person'] },
vi.fn(),
)
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
// Top nav items still present
expect(screen.getByText('All Notes')).toBeInTheDocument()
expect(screen.getByText('Favorites')).toBeInTheDocument()
expect(onToggleTypeVisibility).toHaveBeenCalledWith('Person')
})
it('closes popover when clicking outside', () => {
@@ -893,4 +913,42 @@ describe('Sidebar', () => {
expect(screen.queryByRole('textbox', { name: 'Section name' })).not.toBeInTheDocument()
})
})
it('renders exactly one section for a hyphenated custom type like Monday Ideas', () => {
const entriesWithMondayIdeas: VaultEntry[] = [
...mockEntries,
{
path: '/vault/monday-ideas/standup-bingo.md',
filename: 'standup-bingo.md',
title: 'Standup Bingo',
isA: 'Monday Ideas',
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null,
modifiedAt: 1700000000, createdAt: null,
fileSize: 310, snippet: '', wordCount: 120,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
outgoingLinks: [], properties: {},
},
{
path: '/vault/monday-ideas/theme-days.md',
filename: 'theme-days.md',
title: 'Theme Days',
isA: 'Monday Ideas',
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null,
modifiedAt: 1700000000, createdAt: null,
fileSize: 280, snippet: '', wordCount: 95,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
outgoingLinks: [], properties: {},
},
]
render(<Sidebar entries={entriesWithMondayIdeas} selection={defaultSelection} onSelect={() => {}} />)
// "Monday Ideas" pluralized → "Monday Ideases" (the pluralizeType function)
const mondaySections = screen.getAllByText(/Monday Ideas/i)
expect(mondaySections).toHaveLength(1)
})
})

View File

@@ -4,7 +4,6 @@ import { resolveIcon } from '../utils/iconRegistry'
import { buildTypeEntryMap } from '../utils/typeColors'
import { pluralizeType } from '../hooks/useCommandRegistry'
import { TypeCustomizePopover } from './TypeCustomizePopover'
import { useSectionVisibility } from '../hooks/useSectionVisibility'
import {
DndContext, closestCenter, KeyboardSensor, PointerSensor,
useSensor, useSensors, type DragEndEvent,
@@ -35,6 +34,7 @@ interface SidebarProps {
onUpdateTypeTemplate?: (typeName: string, template: string) => void
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
onRenameSection?: (typeName: string, label: string) => void
onToggleTypeVisibility?: (typeName: string) => void
modifiedCount?: number
onCommitPush?: () => void
onCollapse?: () => void
@@ -104,13 +104,13 @@ function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, Vault
})
}
function useSidebarSections(entries: VaultEntry[], isSectionVisible: (type: string) => boolean) {
function useSidebarSections(entries: VaultEntry[]) {
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const allSectionGroups = useMemo(() => {
const sections = buildDynamicSections(entries, typeEntryMap)
return sortSections(sections, typeEntryMap)
}, [entries, typeEntryMap])
const visibleSections = useMemo(() => allSectionGroups.filter((g) => isSectionVisible(g.type)), [allSectionGroups, isSectionVisible])
const visibleSections = useMemo(() => allSectionGroups.filter((g) => typeEntryMap[g.type]?.visible !== false), [allSectionGroups, typeEntryMap])
const sectionIds = useMemo(() => visibleSections.map((g) => g.type), [visibleSections])
return { typeEntryMap, allSectionGroups, visibleSections, sectionIds }
}
@@ -271,6 +271,7 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChang
export const Sidebar = memo(function Sidebar({
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
onToggleTypeVisibility,
modifiedCount = 0, onCommitPush, onCollapse, isGitVault = false,
}: SidebarProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
@@ -285,8 +286,10 @@ export const Sidebar = memo(function Sidebar({
const popoverRef = useRef<HTMLDivElement>(null)
const customizeRef = useRef<HTMLDivElement>(null)
const { toggleSection: toggleVisibility, isSectionVisible } = useSectionVisibility()
const { typeEntryMap, allSectionGroups, visibleSections, sectionIds } = useSidebarSections(entries, isSectionVisible)
const { typeEntryMap, allSectionGroups, visibleSections, sectionIds } = useSidebarSections(entries)
const isSectionVisible = useCallback((type: string) => typeEntryMap[type]?.visible !== false, [typeEntryMap])
const toggleVisibility = useCallback((type: string) => onToggleTypeVisibility?.(type), [onToggleTypeVisibility])
const { activeCount, archivedCount, trashedCount } = useEntryCounts(entries)
const closeContextMenu = useCallback(() => { setContextMenuPos(null); setContextMenuType(null) }, [])

View File

@@ -289,4 +289,42 @@ describe('useEntryActions', () => {
expect(updateEntry).not.toHaveBeenCalled()
})
})
describe('handleToggleTypeVisibility', () => {
it('sets visible to false when currently visible (null/default)', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Journal', path: '/vault/type/journal.md', visible: null })
const { result } = setup([typeEntry])
await act(async () => {
await result.current.handleToggleTypeVisibility('Journal')
})
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/journal.md', 'visible', false)
expect(updateEntry).toHaveBeenCalledWith('/vault/type/journal.md', { visible: false })
})
it('sets visible to true (deletes property) when currently hidden', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Journal', path: '/vault/type/journal.md', visible: false })
const { result } = setup([typeEntry])
await act(async () => {
await result.current.handleToggleTypeVisibility('Journal')
})
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/type/journal.md', 'visible')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/journal.md', { visible: null })
})
it('auto-creates type entry when not found', async () => {
const { result } = setup([])
await act(async () => {
await result.current.handleToggleTypeVisibility('Journal')
})
expect(createTypeEntry).toHaveBeenCalledWith('Journal')
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/journal.md', 'visible', false)
expect(updateEntry).toHaveBeenCalledWith('/vault/type/journal.md', { visible: false })
})
})
})

View File

@@ -80,5 +80,17 @@ export function useEntryActions({
}
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry])
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate, handleRenameSection }
const handleToggleTypeVisibility = useCallback(async (typeName: string) => {
let typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
if (typeEntry.visible === false) {
updateEntry(typeEntry.path, { visible: null })
await handleDeleteProperty(typeEntry.path, 'visible')
} else {
updateEntry(typeEntry.path, { visible: false })
await handleUpdateFrontmatter(typeEntry.path, 'visible', false)
}
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry])
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate, handleRenameSection, handleToggleTypeVisibility }
}

View File

@@ -72,7 +72,7 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam
aliases: [], belongsTo: [], relatedTo: [],
status, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: now, createdAt: now, fileSize: 0,
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, view: null, properties: {},
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, view: null, visible: null, properties: {},
}
}

View File

@@ -1,53 +0,0 @@
import { useState, useCallback, useEffect } from 'react'
import { getVaultConfig, updateVaultConfigField, subscribeVaultConfig } from '../utils/vaultConfigStore'
function loadHiddenSections(): Set<string> {
const fromConfig = getVaultConfig().hidden_sections
if (fromConfig && fromConfig.length > 0) return new Set(fromConfig)
// Fallback to localStorage during initial load
try {
const raw = localStorage.getItem('laputa-hidden-sections')
if (raw) {
const arr = JSON.parse(raw)
if (Array.isArray(arr)) return new Set(arr as string[])
}
} catch { /* ignore */ }
return new Set()
}
function saveHiddenSections(hidden: Set<string>): void {
const arr = [...hidden]
updateVaultConfigField('hidden_sections', arr.length > 0 ? arr : null)
}
export function useSectionVisibility() {
const [hiddenSections, setHiddenSections] = useState<Set<string>>(loadHiddenSections)
// Re-sync when vault config becomes available
useEffect(() => {
return subscribeVaultConfig(() => {
const sections = getVaultConfig().hidden_sections
if (sections) setHiddenSections(new Set(sections))
})
}, [])
const toggleSection = useCallback((type: string) => {
setHiddenSections((prev) => {
const next = new Set(prev)
if (next.has(type)) {
next.delete(type)
} else {
next.add(type)
}
saveHiddenSections(next)
return next
})
}, [])
const isSectionVisible = useCallback(
(type: string) => !hiddenSections.has(type),
[hiddenSections],
)
return { hiddenSections, toggleSection, isSectionVisible }
}

View File

@@ -11,7 +11,7 @@ describe('useViewMode', () => {
beforeEach(() => {
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null },
vi.fn(),
)
})
@@ -26,7 +26,7 @@ describe('useViewMode', () => {
it('loads persisted view mode from vault config', () => {
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: 'editor-only', tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
{ zoom: null, view_mode: 'editor-only', tag_colors: null, status_colors: null, property_display_modes: null },
vi.fn(),
)
const { result } = renderHook(() => useViewMode())
@@ -69,7 +69,7 @@ describe('useViewMode', () => {
it('ignores invalid vault config values', () => {
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: 'garbage' as never, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
{ zoom: null, view_mode: 'garbage' as never, tag_colors: null, status_colors: null, property_display_modes: null },
vi.fn(),
)
const { result } = renderHook(() => useViewMode())

View File

@@ -3,7 +3,7 @@ import { renderHook, act } from '@testing-library/react'
import { useZoom } from './useZoom'
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore'
const DEFAULT_VC = { zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null } as const
const DEFAULT_VC = { zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null } as const
describe('useZoom', () => {
beforeEach(() => {

View File

@@ -36,7 +36,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['quarter/q1-2026', 'topic/software-development', 'person/matteo-cellini', 'person/maria-bianchi', 'person/marco-verdi'],
properties: { Priority: 'High', 'Due date': '2026-06-15' },
},
@@ -73,7 +73,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['essay/on-writing-well', 'essay/engineering-leadership-101', 'essay/ai-agents-primer', 'topic/growth', 'topic/writing'],
properties: { Priority: 'High', Rating: 5, Cadence: 'Weekly' },
},
@@ -104,7 +104,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['person/matteo-cellini'],
properties: {},
},
@@ -135,7 +135,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['responsibility/grow-newsletter'],
properties: {},
},
@@ -166,7 +166,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['responsibility/manage-sponsorships'],
properties: {},
},
@@ -198,7 +198,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['topic/trading', 'topic/algorithmic-trading', 'data/ema200-backtest-results'],
properties: { Priority: 'Low', 'Due date': '2026-03-01' },
},
@@ -230,7 +230,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['project/26q1-laputa-app', 'topic/growth', 'topic/ads'],
properties: { Priority: 'Medium', Rating: 4 },
},
@@ -261,7 +261,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['project/26q1-laputa-app'],
properties: {},
},
@@ -291,7 +291,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: { Company: 'Acme Corp', Role: 'Engineering Lead' },
},
@@ -321,7 +321,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: { Company: 'TechStart', Role: 'Product Manager' },
},
@@ -351,7 +351,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -381,7 +381,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -412,7 +412,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['project/26q1-laputa-app', 'person/matteo-cellini'],
properties: {},
},
@@ -443,7 +443,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -474,7 +474,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -505,7 +505,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['responsibility/grow-newsletter'],
properties: {},
},
@@ -537,7 +537,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['responsibility/grow-newsletter', 'topic/software-development'],
properties: {},
},
@@ -568,7 +568,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['responsibility/grow-newsletter'],
properties: {},
},
@@ -597,7 +597,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 0,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -625,7 +625,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 1,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -653,7 +653,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 2,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -681,7 +681,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 3,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: false,
outgoingLinks: [],
properties: {},
},
@@ -709,7 +709,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 4,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -737,7 +737,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 5,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -765,7 +765,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 6,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -793,7 +793,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 7,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -821,7 +821,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 8,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -850,7 +850,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: 'orange',
order: 9,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -878,7 +878,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: 'green',
order: 10,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -909,7 +909,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: { Difficulty: 'Easy', 'Prep time': '30 min', Servings: 4 },
},
@@ -939,7 +939,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: { Author: 'Martin Kleppmann', Rating: 5, 'Year published': 2017 },
},
@@ -971,7 +971,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -1001,7 +1001,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -1032,7 +1032,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -1055,7 +1055,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
modifiedAt: now - 86400 * 120,
@@ -1086,7 +1086,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
modifiedAt: now - 86400 * 90,
@@ -1151,7 +1151,7 @@ function generateBulkEntries(count: number): VaultEntry[] {
order: null,
outgoingLinks: Array.from({ length: i % 8 }, (_j, j) => `note/link-target-${(i + j) % 50}`),
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
properties: {},
})
}
@@ -1184,7 +1184,7 @@ MOCK_ENTRIES.push(
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -1212,7 +1212,7 @@ MOCK_ENTRIES.push(
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -1240,7 +1240,7 @@ MOCK_ENTRIES.push(
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},

View File

@@ -319,12 +319,22 @@ line-height-base: 1.6
# ${displayName}
`
const now = Date.now() / 1000
MOCK_ENTRIES.push({
path, filename: `${slug}.md`, title: displayName, isA: 'Theme',
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: now, createdAt: now, fileSize: 512, snippet: `A custom ${displayName} theme.`,
wordCount: 10, relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null, visible: null,
outgoingLinks: [], properties: {},
})
syncWindowContent()
return path
},
ensure_vault_themes: (): null => null,
restore_default_themes: (): string => 'Default themes restored',
get_vault_config: (): VaultConfig => ({ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null }),
get_vault_config: (): VaultConfig => ({ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null }),
save_vault_config: (): null => null,
}

View File

@@ -45,6 +45,13 @@ globalThis.ResizeObserver = class {
disconnect() {}
} as unknown as typeof ResizeObserver
// Mock IntersectionObserver for jsdom (not implemented)
globalThis.IntersectionObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof IntersectionObserver
// Mock @tauri-apps/plugin-opener for test environment
vi.mock('@tauri-apps/plugin-opener', () => ({
openUrl: vi.fn(),

View File

@@ -33,6 +33,8 @@ export interface VaultEntry {
sort: string | null
/** Default view mode for the note list of this Type: "all", "editor-list", or "editor-only". */
view: string | null
/** Whether this Type is visible in the sidebar. Defaults to true when absent. */
visible: boolean | null
/** All wikilink targets found in the note content. Extracted from [[target]] patterns. */
outgoingLinks: string[]
/** Custom scalar frontmatter properties (non-relationship, non-structural). */
@@ -148,7 +150,6 @@ export interface VaultConfig {
tag_colors: Record<string, string> | null
status_colors: Record<string, string> | null
property_display_modes: Record<string, string> | null
hidden_sections: string[] | null
}
export interface PulseFile {

View File

@@ -9,7 +9,6 @@ function makeConfig(overrides: Partial<VaultConfig> = {}): VaultConfig {
tag_colors: null,
status_colors: null,
property_display_modes: null,
hidden_sections: null,
...overrides,
}
}
@@ -116,27 +115,13 @@ describe('migrateLocalStorageToVaultConfig', () => {
expect(result.property_display_modes).toBeNull()
})
// 8. Hidden sections migration
it('migrates populated hidden sections array', () => {
store['laputa-hidden-sections'] = JSON.stringify(['backlinks', 'properties'])
const result = migrateLocalStorageToVaultConfig(makeConfig())
expect(result.hidden_sections).toEqual(['backlinks', 'properties'])
})
it('ignores empty hidden sections array', () => {
store['laputa-hidden-sections'] = JSON.stringify([])
const result = migrateLocalStorageToVaultConfig(makeConfig())
expect(result.hidden_sections).toBeNull()
})
// 9. Existing config values are NOT overwritten
// 8. Existing config values are NOT overwritten
it('does not overwrite existing config values with localStorage data', () => {
store['laputa:zoom-level'] = '120'
store['laputa-view-mode'] = 'all'
store['laputa:tag-color-overrides'] = JSON.stringify({ x: '#fff' })
store['laputa:status-color-overrides'] = JSON.stringify({ y: '#000' })
store['laputa:display-mode-overrides'] = JSON.stringify({ z: 'compact' })
store['laputa-hidden-sections'] = JSON.stringify(['nav'])
const existing = makeConfig({
zoom: 0.9,
@@ -144,7 +129,6 @@ describe('migrateLocalStorageToVaultConfig', () => {
tag_colors: { existing: '#aaa' },
status_colors: { existing: '#bbb' },
property_display_modes: { existing: 'full' },
hidden_sections: ['sidebar'],
})
const result = migrateLocalStorageToVaultConfig(existing)
@@ -153,7 +137,6 @@ describe('migrateLocalStorageToVaultConfig', () => {
expect(result.tag_colors).toEqual({ existing: '#aaa' })
expect(result.status_colors).toEqual({ existing: '#bbb' })
expect(result.property_display_modes).toEqual({ existing: 'full' })
expect(result.hidden_sections).toEqual(['sidebar'])
})
// 10. loaded=null (no vault config file yet) — uses defaults then migrates

View File

@@ -9,7 +9,6 @@ const LS_KEYS = {
tagColors: 'laputa:tag-color-overrides',
statusColors: 'laputa:status-color-overrides',
propertyModes: 'laputa:display-mode-overrides',
hiddenSections: 'laputa-hidden-sections',
} as const
function readJson<T>(key: string): T | null {
@@ -29,7 +28,7 @@ function readJson<T>(key: string): T | null {
export function migrateLocalStorageToVaultConfig(loaded: VaultConfig | null): VaultConfig {
const base: VaultConfig = loaded ?? {
zoom: null, view_mode: null, tag_colors: null,
status_colors: null, property_display_modes: null, hidden_sections: null,
status_colors: null, property_display_modes: null,
}
// Skip migration if already done
@@ -80,12 +79,6 @@ export function migrateLocalStorageToVaultConfig(loaded: VaultConfig | null): Va
if (modes && Object.keys(modes).length > 0) result.property_display_modes = modes
}
// Hidden sections
if (result.hidden_sections === null) {
const sections = readJson<string[]>(LS_KEYS.hiddenSections)
if (sections && sections.length > 0) result.hidden_sections = sections
}
// Mark migration as done
try {
localStorage.setItem(MIGRATION_FLAG, '1')

View File

@@ -14,7 +14,7 @@ describe('statusStyles — color overrides', () => {
beforeEach(() => {
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null },
vi.fn(),
)
// Reset module-level cache by re-initializing with empty overrides

View File

@@ -12,7 +12,7 @@ describe('tagStyles — color overrides', () => {
beforeEach(() => {
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null },
vi.fn(),
)
// Reset module-level cache

View File

@@ -22,3 +22,11 @@ export async function openExternalUrl(url: string): Promise<void> {
window.open(url, '_blank')
}
}
/** Open a local file path with the system default app (e.g. TextEdit for .json). */
export async function openLocalFile(absolutePath: string): Promise<void> {
if (isTauri()) {
const { openPath } = await import('@tauri-apps/plugin-opener')
await openPath(absolutePath)
}
}

View File

@@ -5,7 +5,7 @@ type Listener = () => void
const DEFAULT_CONFIG: VaultConfig = {
zoom: null, view_mode: null, tag_colors: null,
status_colors: null, property_display_modes: null, hidden_sections: null,
status_colors: null, property_display_modes: null,
}
let config: VaultConfig = DEFAULT_CONFIG

View File

@@ -0,0 +1,36 @@
import { test, expect } from '@playwright/test'
test.describe('Sidebar duplicate sections', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('no duplicate sidebar section headers', async ({ page }) => {
// Verify that every section label in the sidebar is unique.
// Before the fix, hyphenated folder names (e.g. monday-ideas/)
// would produce a different isA than the Type file title,
// resulting in two sidebar sections for the same type.
const sidebar = page.locator('.app__sidebar')
// Wait for at least one Expand/Collapse button (indicates sections rendered)
await sidebar.locator('button[aria-label*="Collapse"], button[aria-label*="Expand"]').first().waitFor({ timeout: 5000 })
// Extract section labels from the Expand/Collapse aria-labels
const buttons = sidebar.locator('button[aria-label*="Collapse"], button[aria-label*="Expand"]')
const count = await buttons.count()
const labels: string[] = []
for (let i = 0; i < count; i++) {
const ariaLabel = await buttons.nth(i).getAttribute('aria-label')
// aria-label is like "Collapse Projects" or "Expand Projects"
const label = ariaLabel?.replace(/^(Collapse|Expand)\s+/, '') ?? ''
if (label) labels.push(label)
}
// Every label should be unique — no duplicates
const uniqueLabels = [...new Set(labels)]
expect(labels).toEqual(uniqueLabels)
expect(labels.length).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,39 @@
import { test, expect } from '@playwright/test'
test.describe('Type visibility in sidebar', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('sections with visible:false Type entries are hidden from sidebar', async ({
page,
}) => {
const sidebar = page.locator('.app__sidebar')
// Wait for sections to render
await sidebar
.locator('button[aria-label*="Collapse"], button[aria-label*="Expand"]')
.first()
.waitFor({ timeout: 5000 })
// Extract section labels from Expand/Collapse buttons
const buttons = sidebar.locator(
'button[aria-label*="Collapse"], button[aria-label*="Expand"]',
)
const count = await buttons.count()
const labels: string[] = []
for (let i = 0; i < count; i++) {
const ariaLabel = await buttons.nth(i).getAttribute('aria-label')
const label = ariaLabel?.replace(/^(Collapse|Expand)\s+/, '') ?? ''
if (label) labels.push(label)
}
// Verify that at least some sections render (sanity check)
expect(labels.length).toBeGreaterThan(3)
// All visible section labels should be unique (regression check)
const uniqueLabels = [...new Set(labels)]
expect(labels).toEqual(uniqueLabels)
})
})

View File

@@ -19,11 +19,25 @@ interface VaultEntry {
status: string | null
owner: string | null
cadence: string | null
archived: boolean
trashed: boolean
trashedAt: number | null
modifiedAt: number | null
createdAt: number | null
fileSize: number
snippet: string
wordCount: number
relationships: Record<string, string[]>
icon: string | null
color: string | null
order: number | null
sidebarLabel: string | null
template: string | null
sort: string | null
view: string | null
visible: boolean | null
outgoingLinks: string[]
properties: Record<string, string | number | boolean | null>
}
/** Extract all [[wiki-links]] from a string. */
@@ -115,22 +129,48 @@ function parseMarkdownFile(filePath: string): VaultEntry | null {
}
}
// Boolean field helper
const getBool = (...keys: string[]): boolean | null => {
for (const k of keys) {
for (const fk of Object.keys(fm)) {
if (fk.toLowerCase() === k.toLowerCase() && typeof fm[fk] === 'boolean') {
return fm[fk]
}
}
}
return null
}
return {
path: filePath,
filename,
title,
isA: getString('is_a', 'is a'),
isA: getString('is_a', 'is a', 'type'),
aliases,
belongsTo,
relatedTo,
status: getString('status'),
owner: getString('owner'),
cadence: getString('cadence'),
archived: getBool('archived') ?? false,
trashed: getBool('trashed') ?? false,
trashedAt: null,
modifiedAt: stats.mtimeMs,
createdAt,
fileSize: stats.size,
snippet,
wordCount: bodyText.split(/\s+/).filter(Boolean).length,
relationships,
icon: getString('icon'),
color: getString('color'),
order: fm.order != null ? Number(fm.order) : null,
sidebarLabel: getString('sidebar label', 'sidebar_label'),
template: getString('template'),
sort: getString('sort'),
view: getString('view'),
visible: getBool('visible'),
outgoingLinks: [],
properties: {},
}
} catch {
return null