Compare commits
18 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9d94abae4 | ||
|
|
18b2aaedf6 | ||
|
|
1706300494 | ||
|
|
628ab76f09 | ||
|
|
d3896ddf01 | ||
|
|
7289a60db3 | ||
|
|
0cff626e48 | ||
|
|
5a4c986fe3 | ||
|
|
540b1400e2 | ||
|
|
826cda852a | ||
|
|
19583ea1f5 | ||
|
|
63eb4ff980 | ||
|
|
bbb29857b8 | ||
|
|
900ce7f66f | ||
|
|
eb55c5ec02 | ||
|
|
6f6e7d7cfe | ||
|
|
edcb306c7f | ||
|
|
97be1d1ca3 |
3
demo-vault-v2/.laputa/settings.json
Normal file
3
demo-vault-v2/.laputa/settings.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"theme": null
|
||||
}
|
||||
33
demo-vault-v2/_themes/dark.json
Normal file
33
demo-vault-v2/_themes/dark.json
Normal 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"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/default.json
Normal file
33
demo-vault-v2/_themes/default.json
Normal 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"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/minimal.json
Normal file
33
demo-vault-v2/_themes/minimal.json
Normal 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"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/untitled-2.json
Normal file
33
demo-vault-v2/_themes/untitled-2.json
Normal 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"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/untitled-3.json
Normal file
33
demo-vault-v2/_themes/untitled-3.json
Normal 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"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/untitled-4.json
Normal file
33
demo-vault-v2/_themes/untitled-4.json
Normal 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"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/untitled.json
Normal file
33
demo-vault-v2/_themes/untitled.json
Normal 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"
|
||||
}
|
||||
}
|
||||
5
demo-vault-v2/config/ui.config.md
Normal file
5
demo-vault-v2/config/ui.config.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: config
|
||||
zoom: 1.3
|
||||
view_mode: all
|
||||
---
|
||||
@@ -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*
|
||||
|
||||
5
demo-vault-v2/note/wikilinks-qa-test.md
Normal file
5
demo-vault-v2/note/wikilinks-qa-test.md
Normal 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.
|
||||
54
demo-vault-v2/theme/dark.md
Normal file
54
demo-vault-v2/theme/dark.md
Normal 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.
|
||||
54
demo-vault-v2/theme/default.md
Normal file
54
demo-vault-v2/theme/default.md
Normal 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.
|
||||
54
demo-vault-v2/theme/minimal.md
Normal file
54
demo-vault-v2/theme/minimal.md
Normal 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.
|
||||
@@ -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]
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
|
||||
@@ -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({})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) }, [])
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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: {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
36
tests/smoke/sidebar-duplicate-sections.spec.ts
Normal file
36
tests/smoke/sidebar-duplicate-sections.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
39
tests/smoke/visible-type-property.spec.ts
Normal file
39
tests/smoke/visible-type-property.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user