Compare commits
16 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c27403f86 | ||
|
|
276b3c1a37 | ||
|
|
cadb3500f1 | ||
|
|
ee8f0d6bcd | ||
|
|
41d43501a0 | ||
|
|
32b8e2ee57 | ||
|
|
b1110ead87 | ||
|
|
b75b917538 | ||
|
|
24bb64841e | ||
|
|
df2452dcaf | ||
|
|
89d0e39ad2 | ||
|
|
e3977a6042 | ||
|
|
4cdc534c01 | ||
|
|
1a93acdce7 | ||
|
|
9c8f7907b8 | ||
|
|
8d9862ffef |
42
.claude-done
42
.claude-done
@@ -1,42 +0,0 @@
|
||||
# Drag & Drop Images — Implementation Summary
|
||||
|
||||
## Problem
|
||||
Dragging image files from Finder into the BlockNote editor didn't work. Tauri v2's
|
||||
`dragDropEnabled` defaults to `true`, which intercepts OS-level file drops before they
|
||||
reach the webview's HTML5 DnD API — BlockNote never received the dropped files.
|
||||
|
||||
## Solution
|
||||
Instead of disabling Tauri's drag interception, we use Tauri's `onDragDropEvent` API
|
||||
to listen for native file drops and handle them directly:
|
||||
|
||||
1. **Rust backend** (`src-tauri/src/vault/image.rs`):
|
||||
- Added `copy_image_to_vault` command that copies a file by OS path into
|
||||
`vault/attachments/` with a timestamp-prefixed filename
|
||||
- Refactored shared path logic into `prepare_attachment_path` helper
|
||||
- Validates file exists and has an image extension before copying
|
||||
|
||||
2. **Frontend** (`src/hooks/useImageDrop.ts`):
|
||||
- Added Tauri `onDragDropEvent` listener that fires on native OS drag-drop
|
||||
- On `drop`: filters for image paths, copies each to vault, calls `onImageUrl`
|
||||
- On `over`: shows drag overlay (paths aren't available until drop)
|
||||
- `copyImageToVault` invokes the new Rust command and returns an asset URL
|
||||
|
||||
3. **Editor integration** (`src/components/SingleEditorView.tsx`):
|
||||
- `useInsertImageCallback` inserts a BlockNote image block at cursor position
|
||||
- Wired into `useImageDrop` via the `onImageUrl` callback
|
||||
- `vaultPath` threaded through Editor → EditorContent → SingleEditorView
|
||||
|
||||
4. **Existing `/image` slash command** continues to work unchanged (uses `uploadFile`
|
||||
on `useCreateBlockNote` → `uploadImageFile` → `save_image` Tauri command).
|
||||
|
||||
## Commits
|
||||
- `d45f2e2` feat: add copy_image_to_vault Rust command for native drag-drop
|
||||
- `b047d0c` feat: handle Tauri native drag-drop for filesystem images
|
||||
- `2e04f5a` fix: remove nonexistent drag-drop permission from capabilities
|
||||
- `23ad982` fix: correct DragDropEvent type handling for 'over' events
|
||||
|
||||
## Tests
|
||||
- 4 new Rust tests for `copy_image_to_vault` (success, missing file, non-image, all extensions)
|
||||
- 1 new frontend test for `useImageDrop` with onImageUrl + vaultPath params
|
||||
- All 300 Rust tests pass, 1167 frontend tests pass
|
||||
- Coverage: Rust 85.47%, Frontend 78.64%
|
||||
1
.claude-pid
Normal file
1
.claude-pid
Normal file
@@ -0,0 +1 @@
|
||||
81859
|
||||
@@ -140,6 +140,9 @@ The pre-push hook runs all checks locally before the push goes through. This rep
|
||||
git push origin main # pre-push hook runs automatically
|
||||
```
|
||||
|
||||
### ⛔ NEVER open a Pull Request
|
||||
PRs on separate branches diverge from main with every merge, requiring continuous rebases and creating unnecessary conflicts. Always push directly to main. If the push fails (disk full, test failure, etc.) — fix the problem, then push again. There is no scenario where opening a PR is the right fallback.
|
||||
|
||||
### ⛔ NEVER use --no-verify
|
||||
```bash
|
||||
# FORBIDDEN — will be caught and rejected:
|
||||
|
||||
1
design/note-templates.pen
Normal file
1
design/note-templates.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
1
design/tab-responsive-width.pen
Normal file
1
design/tab-responsive-width.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
@@ -15,7 +15,7 @@ pub enum FrontmatterValue {
|
||||
|
||||
/// Characters that require a YAML string value to be quoted.
|
||||
fn has_yaml_special_chars(s: &str) -> bool {
|
||||
s.contains(':') || s.contains('#') || s.contains('\n')
|
||||
s.contains(':') || s.contains('#')
|
||||
}
|
||||
|
||||
/// Check if a string starts with a YAML collection indicator (array or map).
|
||||
@@ -41,6 +41,23 @@ fn format_list_item(item: &str) -> String {
|
||||
format!(" - {}", quote_yaml_string(item))
|
||||
}
|
||||
|
||||
/// Format a multi-line string as a YAML block scalar (`|`).
|
||||
/// Each line is indented by 2 spaces; empty lines are preserved as blank.
|
||||
fn format_block_scalar(s: &str) -> String {
|
||||
let indented = s
|
||||
.lines()
|
||||
.map(|l| {
|
||||
if l.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", l)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("|\n{}", indented)
|
||||
}
|
||||
|
||||
/// Format a number for YAML (integers without decimal, floats with).
|
||||
fn format_yaml_number(n: f64) -> String {
|
||||
if n.fract() == 0.0 {
|
||||
@@ -54,7 +71,9 @@ impl FrontmatterValue {
|
||||
pub fn to_yaml_value(&self) -> String {
|
||||
match self {
|
||||
FrontmatterValue::String(s) => {
|
||||
if needs_yaml_quoting(s) {
|
||||
if s.contains('\n') {
|
||||
format_block_scalar(s)
|
||||
} else if needs_yaml_quoting(s) {
|
||||
quote_yaml_string(s)
|
||||
} else {
|
||||
s.clone()
|
||||
@@ -113,16 +132,20 @@ fn line_is_key(line: &str, key: &str) -> bool {
|
||||
fn format_yaml_field(key: &str, value: &FrontmatterValue) -> Vec<String> {
|
||||
let yaml_key = format_yaml_key(key);
|
||||
let yaml_value = value.to_yaml_value();
|
||||
if yaml_value.contains('\n') {
|
||||
if yaml_value.starts_with("|\n") {
|
||||
// Block scalar: key and indicator on the same line, content follows
|
||||
vec![format!("{}: {}", yaml_key, yaml_value)]
|
||||
} else if yaml_value.contains('\n') {
|
||||
vec![format!("{}:", yaml_key), yaml_value]
|
||||
} else {
|
||||
vec![format!("{}: {}", yaml_key, yaml_value)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a line is a YAML list continuation (` - ...`) rather than a new key.
|
||||
fn is_list_continuation(line: &str) -> bool {
|
||||
line.starts_with(" - ") || line.starts_with(" -\t")
|
||||
/// Check if a line continues the previous key's value (indented list item,
|
||||
/// block scalar content, or blank line inside a block scalar).
|
||||
fn is_value_continuation(line: &str) -> bool {
|
||||
line.is_empty() || line.starts_with(" ") || line.starts_with('\t')
|
||||
}
|
||||
|
||||
/// Split content into frontmatter body and the rest after the closing `---`.
|
||||
@@ -163,8 +186,8 @@ fn apply_field_update(lines: &[&str], key: &str, value: Option<&FrontmatterValue
|
||||
|
||||
found_key = true;
|
||||
i += 1;
|
||||
// Skip list continuation lines belonging to this key
|
||||
while i < lines.len() && is_list_continuation(lines[i]) {
|
||||
// Skip continuation lines belonging to this key (lists, block scalars)
|
||||
while i < lines.len() && is_value_continuation(lines[i]) {
|
||||
i += 1;
|
||||
}
|
||||
// Insert replacement value (if any)
|
||||
@@ -699,6 +722,94 @@ mod tests {
|
||||
assert!(updated.contains("title: New Title"));
|
||||
}
|
||||
|
||||
// --- block scalar (multi-line string) tests ---
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_multiline_uses_block_scalar() {
|
||||
let v = FrontmatterValue::String("line 1\nline 2\nline 3".to_string());
|
||||
let yaml = v.to_yaml_value();
|
||||
assert!(yaml.starts_with("|\n"));
|
||||
assert!(yaml.contains(" line 1"));
|
||||
assert!(yaml.contains(" line 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_field_block_scalar() {
|
||||
let v = FrontmatterValue::String("## Objective\n\n## Timeline".to_string());
|
||||
let lines = format_yaml_field("template", &v);
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert!(lines[0].starts_with("template: |\n"));
|
||||
assert!(lines[0].contains(" ## Objective"));
|
||||
assert!(lines[0].contains(" ## Timeline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_block_scalar_add() {
|
||||
let content = "---\ntype: Type\n---\n# Project\n";
|
||||
let template = "## Objective\n\n## Timeline";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("template: |"));
|
||||
assert!(updated.contains(" ## Objective"));
|
||||
assert!(updated.contains(" ## Timeline"));
|
||||
assert!(updated.contains("type: Type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_block_scalar_replace() {
|
||||
let content = "---\ntype: Type\ntemplate: |\n ## Old\n \n ## Stuff\ncolor: green\n---\n# Project\n";
|
||||
let new_template = "## New\n\n## Content";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(new_template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains(" ## New"));
|
||||
assert!(updated.contains(" ## Content"));
|
||||
assert!(!updated.contains("## Old"));
|
||||
assert!(!updated.contains("## Stuff"));
|
||||
assert!(updated.contains("color: green"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_block_scalar() {
|
||||
let content =
|
||||
"---\ntype: Type\ntemplate: |\n ## Heading\n \n ## Body\ncolor: green\n---\n# Project\n";
|
||||
let updated = update_frontmatter_content(content, "template", None).unwrap();
|
||||
assert!(!updated.contains("template"));
|
||||
assert!(!updated.contains("## Heading"));
|
||||
assert!(updated.contains("color: green"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_block_scalar() {
|
||||
let content = "---\ntype: Type\n---\n# Project\n";
|
||||
let template = "## Objective\n\nDescribe the goal.\n\n## Timeline\n\nKey dates.";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
// Parse back with gray_matter
|
||||
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
|
||||
let parsed = matter.parse(&updated);
|
||||
let data = parsed.data.unwrap();
|
||||
if let gray_matter::Pod::Hash(map) = data {
|
||||
let roundtripped = map.get("template").unwrap().as_string().unwrap();
|
||||
assert!(roundtripped.contains("## Objective"));
|
||||
assert!(roundtripped.contains("## Timeline"));
|
||||
assert!(roundtripped.contains("Describe the goal."));
|
||||
} else {
|
||||
panic!("Expected hash");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_no_body_after_closing() {
|
||||
// Frontmatter with title, no body after closing ---
|
||||
|
||||
@@ -128,7 +128,7 @@ pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String>
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.args(["status", "--porcelain", "--untracked-files=all"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git status: {}", e))?;
|
||||
@@ -740,7 +740,42 @@ mod tests {
|
||||
|
||||
assert!(modified.len() >= 2);
|
||||
let statuses: Vec<&str> = modified.iter().map(|f| f.status.as_str()).collect();
|
||||
assert!(statuses.contains(&"modified") || statuses.contains(&"untracked"));
|
||||
assert!(statuses.contains(&"modified"));
|
||||
assert!(statuses.contains(&"untracked"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files_untracked_in_subdirectory() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
|
||||
// Create initial commit so git is initialized
|
||||
fs::write(vault.join("init.md"), "# Init\n").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "init.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "Initial"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Create a new untracked file in a subdirectory (simulates new note creation)
|
||||
fs::create_dir_all(vault.join("note")).unwrap();
|
||||
fs::write(vault.join("note/brand-new.md"), "# Brand New\n").unwrap();
|
||||
|
||||
let modified = get_modified_files(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(modified.len(), 1);
|
||||
assert_eq!(modified[0].status, "untracked");
|
||||
assert_eq!(modified[0].relative_path, "note/brand-new.md");
|
||||
assert!(
|
||||
modified[0].path.ends_with("/note/brand-new.md"),
|
||||
"Full path should end with relative path: {}",
|
||||
modified[0].path
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,6 +6,7 @@ use tauri::{
|
||||
// Custom menu item IDs that emit events to the frontend.
|
||||
const APP_SETTINGS: &str = "app-settings";
|
||||
const FILE_NEW_NOTE: &str = "file-new-note";
|
||||
const FILE_DAILY_NOTE: &str = "file-daily-note";
|
||||
const FILE_QUICK_OPEN: &str = "file-quick-open";
|
||||
const FILE_SAVE: &str = "file-save";
|
||||
const FILE_CLOSE_TAB: &str = "file-close-tab";
|
||||
@@ -26,6 +27,7 @@ const VIEW_GO_FORWARD: &str = "view-go-forward";
|
||||
const CUSTOM_IDS: &[&str] = &[
|
||||
APP_SETTINGS,
|
||||
FILE_NEW_NOTE,
|
||||
FILE_DAILY_NOTE,
|
||||
FILE_QUICK_OPEN,
|
||||
FILE_SAVE,
|
||||
FILE_CLOSE_TAB,
|
||||
@@ -75,6 +77,10 @@ fn build_file_menu(app: &App) -> MenuResult {
|
||||
.id(FILE_NEW_NOTE)
|
||||
.accelerator("CmdOrCtrl+N")
|
||||
.build(app)?;
|
||||
let daily_note = MenuItemBuilder::new("Open Today's Note")
|
||||
.id(FILE_DAILY_NOTE)
|
||||
.accelerator("CmdOrCtrl+J")
|
||||
.build(app)?;
|
||||
let quick_open = MenuItemBuilder::new("Quick Open")
|
||||
.id(FILE_QUICK_OPEN)
|
||||
.accelerator("CmdOrCtrl+P")
|
||||
@@ -98,6 +104,7 @@ fn build_file_menu(app: &App) -> MenuResult {
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "File")
|
||||
.item(&new_note)
|
||||
.item(&daily_note)
|
||||
.item(&quick_open)
|
||||
.separator()
|
||||
.item(&save)
|
||||
@@ -245,6 +252,7 @@ mod tests {
|
||||
let expected = [
|
||||
"app-settings",
|
||||
"file-new-note",
|
||||
"file-daily-note",
|
||||
"file-quick-open",
|
||||
"file-save",
|
||||
"file-close-tab",
|
||||
|
||||
@@ -62,6 +62,12 @@ pub struct VaultEntry {
|
||||
pub color: Option<String>,
|
||||
/// Display order for Type entries in sidebar (lower = higher). None = use default order.
|
||||
pub order: Option<i64>,
|
||||
/// Custom sidebar section label for Type entries, overriding auto-pluralization.
|
||||
#[serde(rename = "sidebarLabel")]
|
||||
pub sidebar_label: Option<String>,
|
||||
/// Markdown template for notes of this Type. When a new note is created
|
||||
/// with this type, the template body is pre-filled after the frontmatter.
|
||||
pub template: Option<String>,
|
||||
/// Word count of the note body (excludes frontmatter and H1 title).
|
||||
#[serde(rename = "wordCount")]
|
||||
pub word_count: u32,
|
||||
@@ -104,6 +110,10 @@ struct Frontmatter {
|
||||
color: Option<String>,
|
||||
#[serde(default)]
|
||||
order: Option<i64>,
|
||||
#[serde(rename = "sidebar label", default)]
|
||||
sidebar_label: Option<String>,
|
||||
#[serde(default)]
|
||||
template: Option<String>,
|
||||
}
|
||||
|
||||
/// Handles YAML fields that can be either a single string or a list of strings.
|
||||
@@ -147,6 +157,8 @@ const SKIP_KEYS: &[&str] = &[
|
||||
"icon",
|
||||
"color",
|
||||
"order",
|
||||
"sidebar label",
|
||||
"template",
|
||||
];
|
||||
|
||||
/// Extract all wikilink-containing fields from raw YAML frontmatter.
|
||||
@@ -325,6 +337,8 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
icon: frontmatter.icon,
|
||||
color: frontmatter.color,
|
||||
order: frontmatter.order,
|
||||
sidebar_label: frontmatter.sidebar_label,
|
||||
template: frontmatter.template,
|
||||
word_count,
|
||||
outgoing_links,
|
||||
})
|
||||
@@ -1055,6 +1069,71 @@ References:
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
||||
}
|
||||
|
||||
// --- sidebar_label tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_sidebar_label_from_type_entry() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\nsidebar label: News\n---\n# News\n";
|
||||
let entry = parse_test_entry(&dir, "type/news.md", content);
|
||||
assert_eq!(entry.sidebar_label, Some("News".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sidebar_label_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.sidebar_label, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sidebar_label_not_in_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\nsidebar label: My Series\n---\n# Series\n";
|
||||
let entry = parse_test_entry(&dir, "type/series.md", content);
|
||||
assert!(entry.relationships.get("sidebar label").is_none());
|
||||
}
|
||||
|
||||
// --- template field tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_template_from_type_entry() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content =
|
||||
"---\ntype: Type\ntemplate: \"## Objective\\n\\n## Timeline\"\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "type/project.md", content);
|
||||
assert!(entry.template.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_template_block_scalar() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content =
|
||||
"---\ntype: Type\ntemplate: |\n ## Objective\n \n ## Timeline\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "type/project.md", content);
|
||||
assert!(entry.template.is_some());
|
||||
let tmpl = entry.template.unwrap();
|
||||
assert!(tmpl.contains("## Objective"));
|
||||
assert!(tmpl.contains("## Timeline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_template_missing_defaults_to_none() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\n---\n# Note\n";
|
||||
let entry = parse_test_entry(&dir, "type/note.md", content);
|
||||
assert_eq!(entry.template, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_not_in_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\ntemplate: \"## Heading\"\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "type/project.md", content);
|
||||
assert!(entry.relationships.get("template").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
|
||||
|
||||
@@ -34,6 +34,7 @@ const mockEntries = [
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -50,6 +51,7 @@ const mockEntries = [
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
|
||||
36
src/App.tsx
36
src/App.tsx
@@ -142,7 +142,7 @@ function App() {
|
||||
// Read at callback time, so it's always current when user presses Cmd+N.
|
||||
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
|
||||
|
||||
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content) })
|
||||
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles })
|
||||
|
||||
const navHistory = useNavigationHistory()
|
||||
|
||||
@@ -155,23 +155,33 @@ function App() {
|
||||
navFromHistoryRef.current = false
|
||||
}, [notes.activeTabPath]) // eslint-disable-line react-hooks/exhaustive-deps -- navHistory.push is stable
|
||||
|
||||
const isTabOpen = useCallback((path: string) => notes.tabs.some(t => t.entry.path === path), [notes.tabs])
|
||||
const isEntryExists = useCallback((path: string) => vault.entries.some(e => e.path === path), [vault.entries])
|
||||
|
||||
const handleGoBack = useCallback(() => {
|
||||
const target = navHistory.goBack(isTabOpen)
|
||||
const target = navHistory.goBack(isEntryExists)
|
||||
if (target) {
|
||||
navFromHistoryRef.current = true
|
||||
notes.handleSwitchTab(target)
|
||||
if (notes.tabs.some(t => t.entry.path === target)) {
|
||||
notes.handleSwitchTab(target)
|
||||
} else {
|
||||
const entry = vault.entries.find(e => e.path === target)
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
}
|
||||
}
|
||||
}, [navHistory, isTabOpen, notes])
|
||||
}, [navHistory, isEntryExists, vault.entries, notes])
|
||||
|
||||
const handleGoForward = useCallback(() => {
|
||||
const target = navHistory.goForward(isTabOpen)
|
||||
const target = navHistory.goForward(isEntryExists)
|
||||
if (target) {
|
||||
navFromHistoryRef.current = true
|
||||
notes.handleSwitchTab(target)
|
||||
if (notes.tabs.some(t => t.entry.path === target)) {
|
||||
notes.handleSwitchTab(target)
|
||||
} else {
|
||||
const entry = vault.entries.find(e => e.path === target)
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
}
|
||||
}
|
||||
}, [navHistory, isTabOpen, notes])
|
||||
}, [navHistory, isEntryExists, vault.entries, notes])
|
||||
|
||||
// Mouse button 3/4 (back/forward) and macOS trackpad two-finger swipe
|
||||
useEffect(() => {
|
||||
@@ -235,6 +245,7 @@ function App() {
|
||||
entries: vault.entries, updateEntry: vault.updateEntry,
|
||||
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
|
||||
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
|
||||
createTypeEntry: notes.createTypeEntrySilent,
|
||||
})
|
||||
|
||||
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory)
|
||||
@@ -271,11 +282,12 @@ function App() {
|
||||
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
|
||||
onSearch: dialogs.openSearch,
|
||||
onCreateNote: notes.handleCreateNoteImmediate,
|
||||
onOpenDailyNote: notes.handleOpenDailyNote,
|
||||
onCreateNoteOfType: notes.handleCreateNoteImmediate,
|
||||
onSave: handleSave,
|
||||
onOpenSettings: dialogs.openSettings,
|
||||
onTrashNote: entryActions.handleTrashNote, onArchiveNote: entryActions.handleArchiveNote,
|
||||
onUnarchiveNote: entryActions.handleUnarchiveNote,
|
||||
onTrashNote: entryActions.handleTrashNote, onRestoreNote: entryActions.handleRestoreNote,
|
||||
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
|
||||
onCommitPush: commitFlow.openCommitDialog, onSetViewMode: setViewMode,
|
||||
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
|
||||
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
|
||||
@@ -289,6 +301,7 @@ function App() {
|
||||
onSwitchTheme: themeManager.switchTheme,
|
||||
onCreateTheme: async () => { await themeManager.createTheme() },
|
||||
onOpenVault: vaultSwitcher.handleOpenLocalFolder,
|
||||
onToggleAIChat: dialogs.toggleAIChat,
|
||||
})
|
||||
|
||||
const { status: updateStatus, actions: updateActions } = useUpdater()
|
||||
@@ -330,7 +343,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} onReorderSections={entryActions.handleReorderSections} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} />
|
||||
<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} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
@@ -382,6 +395,7 @@ function App() {
|
||||
onGoBack={handleGoBack}
|
||||
onGoForward={handleGoForward}
|
||||
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
|
||||
isDarkTheme={themeManager.isDark}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { AiPanel } from './AiPanel'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
// Mock the hooks and utils to isolate component tests
|
||||
vi.mock('../hooks/useAiAgent', () => ({
|
||||
@@ -16,10 +17,37 @@ vi.mock('../utils/ai-chat', () => ({
|
||||
nextMessageId: () => `msg-${Date.now()}`,
|
||||
}))
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/vault/note/test.md',
|
||||
filename: 'test.md',
|
||||
title: 'Test Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('AiPanel', () => {
|
||||
it('renders panel with AI Agent header', () => {
|
||||
it('renders panel with AI Chat header', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
expect(screen.getByText('AI Agent')).toBeTruthy()
|
||||
expect(screen.getByText('AI Chat')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders data-testid ai-panel', () => {
|
||||
@@ -38,9 +66,44 @@ describe('AiPanel', () => {
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders empty state when no messages', () => {
|
||||
it('renders empty state without context', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
expect(screen.getByText('Ask the AI agent to work with your vault')).toBeTruthy()
|
||||
expect(screen.getByText('Open a note, then ask the AI about it')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders contextual empty state when active entry is provided', () => {
|
||||
const entry = makeEntry({ title: 'My Note' })
|
||||
render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
|
||||
)
|
||||
expect(screen.getByText('Ask about this note and its linked context')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows context bar with active entry title', () => {
|
||||
const entry = makeEntry({ title: 'My Note' })
|
||||
render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
|
||||
)
|
||||
expect(screen.getByTestId('context-bar')).toBeTruthy()
|
||||
expect(screen.getByText('My Note')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows linked count in context bar when entry has outgoing links', () => {
|
||||
const linked = makeEntry({ path: '/vault/linked.md', title: 'Linked Note' })
|
||||
const entry = makeEntry({ title: 'My Note', outgoingLinks: ['Linked Note'] })
|
||||
render(
|
||||
<AiPanel
|
||||
onClose={vi.fn()} vaultPath="/tmp/vault"
|
||||
activeEntry={entry} entries={[entry, linked]}
|
||||
allContent={{}}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('+ 1 linked')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not show context bar when no active entry', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
expect(screen.queryByTestId('context-bar')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders input field enabled', () => {
|
||||
@@ -55,4 +118,19 @@ describe('AiPanel', () => {
|
||||
const sendBtn = screen.getByTestId('agent-send')
|
||||
expect((sendBtn as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('shows contextual placeholder when active entry exists', () => {
|
||||
const entry = makeEntry({ title: 'My Note' })
|
||||
render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
|
||||
)
|
||||
const input = screen.getByTestId('agent-input') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Ask about this note...')
|
||||
})
|
||||
|
||||
it('shows generic placeholder when no active entry', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
const input = screen.getByTestId('agent-input') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Ask the AI agent...')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Robot, X, PaperPlaneRight, Plus } from '@phosphor-icons/react'
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
|
||||
import { AiMessage } from './AiMessage'
|
||||
import { useAiAgent, type AiAgentMessage } from '../hooks/useAiAgent'
|
||||
import { collectLinkedEntries, buildContextualPrompt } from '../utils/ai-context'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
export type { AiAgentMessage } from '../hooks/useAiAgent'
|
||||
|
||||
@@ -9,6 +11,9 @@ interface AiPanelProps {
|
||||
onClose: () => void
|
||||
onOpenNote?: (path: string) => void
|
||||
vaultPath: string
|
||||
activeEntry?: VaultEntry | null
|
||||
entries?: VaultEntry[]
|
||||
allContent?: Record<string, string>
|
||||
}
|
||||
|
||||
function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () => void }) {
|
||||
@@ -19,7 +24,7 @@ function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () =>
|
||||
>
|
||||
<Robot size={16} className="shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
|
||||
AI Agent
|
||||
AI Chat
|
||||
</span>
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
@@ -39,7 +44,23 @@ function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () =>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
function ContextBar({ activeEntry, linkedCount }: { activeEntry: VaultEntry; linkedCount: number }) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center border-b border-border text-muted-foreground"
|
||||
style={{ padding: '6px 12px', gap: 6, fontSize: 11 }}
|
||||
data-testid="context-bar"
|
||||
>
|
||||
<Link size={12} className="shrink-0" />
|
||||
<span className="truncate" style={{ fontWeight: 500 }}>{activeEntry.title}</span>
|
||||
{linkedCount > 0 && (
|
||||
<span style={{ opacity: 0.6 }}>+ {linkedCount} linked</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ hasContext }: { hasContext: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center text-center text-muted-foreground"
|
||||
@@ -47,17 +68,23 @@ function EmptyState() {
|
||||
>
|
||||
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
|
||||
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
|
||||
Ask the AI agent to work with your vault
|
||||
{hasContext
|
||||
? 'Ask about this note and its linked context'
|
||||
: 'Open a note, then ask the AI about it'
|
||||
}
|
||||
</p>
|
||||
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
|
||||
Creates notes, searches, edits frontmatter, and more
|
||||
{hasContext
|
||||
? 'Summarize, find connections, expand ideas'
|
||||
: 'The AI will use the active note as context'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageHistory({ messages, isActive, onOpenNote }: {
|
||||
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void
|
||||
function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
|
||||
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void; hasContext: boolean
|
||||
}) {
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -67,7 +94,7 @@ function MessageHistory({ messages, isActive, onOpenNote }: {
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
|
||||
{messages.length === 0 && !isActive && <EmptyState />}
|
||||
{messages.length === 0 && !isActive && <EmptyState hasContext={hasContext} />}
|
||||
{messages.map((msg, i) => (
|
||||
<AiMessage key={msg.id ?? i} {...msg} onOpenNote={onOpenNote} />
|
||||
))}
|
||||
@@ -76,10 +103,10 @@ function MessageHistory({ messages, isActive, onOpenNote }: {
|
||||
)
|
||||
}
|
||||
|
||||
function InputBar({ input, onInputChange, onSend, onKeyDown, isActive }: {
|
||||
function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, hasContext }: {
|
||||
input: string; onInputChange: (v: string) => void
|
||||
onSend: () => void; onKeyDown: (e: React.KeyboardEvent) => void
|
||||
isActive: boolean
|
||||
isActive: boolean; hasContext: boolean
|
||||
}) {
|
||||
const sendDisabled = isActive || !input.trim()
|
||||
return (
|
||||
@@ -97,7 +124,7 @@ function InputBar({ input, onInputChange, onSend, onKeyDown, isActive }: {
|
||||
fontSize: 13, borderRadius: 8, padding: '8px 10px',
|
||||
outline: 'none', fontFamily: 'inherit',
|
||||
}}
|
||||
placeholder="Ask the AI agent..."
|
||||
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
|
||||
disabled={isActive}
|
||||
data-testid="agent-input"
|
||||
/>
|
||||
@@ -121,9 +148,21 @@ function InputBar({ input, onInputChange, onSend, onKeyDown, isActive }: {
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanel({ onClose, onOpenNote, vaultPath }: AiPanelProps) {
|
||||
export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries, allContent }: AiPanelProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const agent = useAiAgent(vaultPath)
|
||||
|
||||
const linkedEntries = useMemo(() => {
|
||||
if (!activeEntry || !entries) return []
|
||||
return collectLinkedEntries(activeEntry, entries)
|
||||
}, [activeEntry, entries])
|
||||
|
||||
const contextPrompt = useMemo(() => {
|
||||
if (!activeEntry || !allContent) return undefined
|
||||
return buildContextualPrompt(activeEntry, linkedEntries, allContent)
|
||||
}, [activeEntry, linkedEntries, allContent])
|
||||
|
||||
const agent = useAiAgent(vaultPath, contextPrompt)
|
||||
const hasContext = !!activeEntry
|
||||
|
||||
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
|
||||
|
||||
@@ -146,10 +185,14 @@ export function AiPanel({ onClose, onOpenNote, vaultPath }: AiPanelProps) {
|
||||
data-testid="ai-panel"
|
||||
>
|
||||
<PanelHeader onClose={onClose} onClear={agent.clearConversation} />
|
||||
{activeEntry && (
|
||||
<ContextBar activeEntry={activeEntry} linkedCount={linkedEntries.length} />
|
||||
)}
|
||||
<MessageHistory
|
||||
messages={agent.messages}
|
||||
isActive={isActive}
|
||||
onOpenNote={onOpenNote}
|
||||
hasContext={hasContext}
|
||||
/>
|
||||
<InputBar
|
||||
input={input}
|
||||
@@ -157,6 +200,7 @@ export function AiPanel({ onClose, onOpenNote, vaultPath }: AiPanelProps) {
|
||||
onSend={handleSend}
|
||||
onKeyDown={handleKeyDown}
|
||||
isActive={isActive}
|
||||
hasContext={hasContext}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -51,6 +51,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -75,6 +75,7 @@ const mockEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ interface EditorProps {
|
||||
onGoBack?: () => void
|
||||
onGoForward?: () => void
|
||||
leftPanelsCollapsed?: boolean
|
||||
isDarkTheme?: boolean
|
||||
}
|
||||
|
||||
function EditorEmptyState() {
|
||||
@@ -82,6 +83,7 @@ export const Editor = memo(function Editor({
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onRenameTab, onContentChange, onTitleSync,
|
||||
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
|
||||
isDarkTheme,
|
||||
}: EditorProps) {
|
||||
const vaultPathRef = useRef(vaultPath)
|
||||
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
|
||||
@@ -161,6 +163,7 @@ export const Editor = memo(function Editor({
|
||||
onArchiveNote={onArchiveNote}
|
||||
onUnarchiveNote={onUnarchiveNote}
|
||||
vaultPath={vaultPath}
|
||||
isDarkTheme={isDarkTheme}
|
||||
/>
|
||||
}
|
||||
{showRightPanel && <ResizeHandle onResize={onInspectorResize} />}
|
||||
|
||||
@@ -32,6 +32,7 @@ interface EditorContentProps {
|
||||
onArchiveNote?: (path: string) => void
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
vaultPath?: string
|
||||
isDarkTheme?: boolean
|
||||
}
|
||||
|
||||
function EditorLoadingSkeleton() {
|
||||
@@ -97,7 +98,7 @@ function ActiveTabBreadcrumb({ activeTab, props }: {
|
||||
export function EditorContent({
|
||||
activeTab, isLoadingNewTab, entries, editor,
|
||||
diffMode, diffContent, onToggleDiff,
|
||||
onNavigateWikilink, onEditorChange, vaultPath,
|
||||
onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme,
|
||||
...breadcrumbProps
|
||||
}: EditorContentProps) {
|
||||
return (
|
||||
@@ -106,7 +107,7 @@ export function EditorContent({
|
||||
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
|
||||
{!diffMode && activeTab && (
|
||||
<div style={{ display: 'flex', flex: 1, flexDirection: 'column', minHeight: 0 }}>
|
||||
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} />
|
||||
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} />
|
||||
</div>
|
||||
)}
|
||||
{isLoadingNewTab && !diffMode && <EditorLoadingSkeleton />}
|
||||
|
||||
@@ -38,6 +38,9 @@ export function EditorRightPanel({
|
||||
onClose={() => onToggleAIChat?.()}
|
||||
onOpenNote={onOpenNote}
|
||||
vaultPath={vaultPath}
|
||||
activeEntry={inspectorEntry}
|
||||
entries={entries}
|
||||
allContent={allContent}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ const mockEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -70,6 +71,7 @@ const referrerEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: ['Test Project'],
|
||||
}
|
||||
|
||||
@@ -186,8 +188,11 @@ This is a test note with some words to count.
|
||||
entries={[mockEntry, referrerEntry]}
|
||||
/>
|
||||
)
|
||||
// Backlinks section is collapsed by default, but header with count is visible
|
||||
expect(screen.getByText('Backlinks (1)')).toBeInTheDocument()
|
||||
// Expand to see the backlink entry
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
expect(screen.getByText('Referrer Note')).toBeInTheDocument()
|
||||
expect(screen.getByText('1')).toBeInTheDocument() // count badge
|
||||
})
|
||||
|
||||
it('updates backlinks reactively when outgoingLinks changes', () => {
|
||||
@@ -200,7 +205,7 @@ This is a test note with some words to count.
|
||||
/>
|
||||
)
|
||||
// Initially no backlinks — section is hidden entirely
|
||||
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('backlinks-toggle')).not.toBeInTheDocument()
|
||||
|
||||
// Rerender with updated outgoingLinks (simulates adding [[Test Project]] to referrer)
|
||||
rerender(
|
||||
@@ -211,6 +216,8 @@ This is a test note with some words to count.
|
||||
entries={[mockEntry, { ...referrerEntry, outgoingLinks: ['Test Project'] }]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Backlinks (1)')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
expect(screen.getByText('Referrer Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -223,8 +230,7 @@ This is a test note with some words to count.
|
||||
entries={[mockEntry]}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByText('No backlinks')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('backlinks-toggle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates when a backlink is clicked', () => {
|
||||
@@ -235,10 +241,10 @@ This is a test note with some words to count.
|
||||
entry={mockEntry}
|
||||
content={mockContent}
|
||||
entries={[mockEntry, referrerEntry]}
|
||||
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
fireEvent.click(screen.getByText('Referrer Note'))
|
||||
expect(onNavigate).toHaveBeenCalledWith('Referrer Note')
|
||||
})
|
||||
@@ -370,6 +376,7 @@ This is a test note with some words to count.
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -396,6 +403,7 @@ This is a test note with some words to count.
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -422,6 +430,7 @@ This is a test note with some words to count.
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -448,6 +457,7 @@ This is a test note with some words to count.
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -590,6 +600,7 @@ Status: Active
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
// Body text also links to grow-newsletter
|
||||
outgoingLinks: ['responsibility/grow-newsletter'],
|
||||
}
|
||||
@@ -605,7 +616,7 @@ Status: Active
|
||||
expect(screen.getByText(/← Belongs to/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
|
||||
// But NOT in Backlinks (even though outgoingLinks matches) — section hidden
|
||||
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('backlinks-toggle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show self-references', () => {
|
||||
|
||||
@@ -7,7 +7,8 @@ import { parseFrontmatter } from '../utils/frontmatter'
|
||||
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
|
||||
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel } from './InspectorPanels'
|
||||
import { wikilinkTarget } from '../utils/wikilink'
|
||||
import type { ReferencedByItem } from './InspectorPanels'
|
||||
import { extractBacklinkContext } from '../utils/wikilinks'
|
||||
import type { ReferencedByItem, BacklinkItem } from './InspectorPanels'
|
||||
|
||||
export type FrontmatterValue = string | number | boolean | string[] | null
|
||||
|
||||
@@ -26,7 +27,12 @@ interface InspectorProps {
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
}
|
||||
|
||||
function useBacklinks(entry: VaultEntry | null, entries: VaultEntry[], referencedBy: ReferencedByItem[]): VaultEntry[] {
|
||||
function useBacklinks(
|
||||
entry: VaultEntry | null,
|
||||
entries: VaultEntry[],
|
||||
referencedBy: ReferencedByItem[],
|
||||
allContent?: Record<string, string>,
|
||||
): BacklinkItem[] {
|
||||
return useMemo(() => {
|
||||
if (!entry) return []
|
||||
const matchTargets = new Set([
|
||||
@@ -37,14 +43,21 @@ function useBacklinks(entry: VaultEntry | null, entries: VaultEntry[], reference
|
||||
|
||||
const referencedByPaths = new Set(referencedBy.map((item) => item.entry.path))
|
||||
|
||||
return entries.filter((e) => {
|
||||
if (e.path === entry.path) return false
|
||||
if (referencedByPaths.has(e.path)) return false
|
||||
return e.outgoingLinks.some((target) =>
|
||||
matchTargets.has(target) || matchTargets.has(target.split('/').pop() ?? '')
|
||||
)
|
||||
})
|
||||
}, [entry, entries, referencedBy])
|
||||
return entries
|
||||
.filter((e) => {
|
||||
if (e.path === entry.path) return false
|
||||
if (referencedByPaths.has(e.path)) return false
|
||||
return e.outgoingLinks.some((target) =>
|
||||
matchTargets.has(target) || matchTargets.has(target.split('/').pop() ?? '')
|
||||
)
|
||||
})
|
||||
.map((e) => ({
|
||||
entry: e,
|
||||
context: allContent?.[e.path]
|
||||
? extractBacklinkContext(allContent[e.path], matchTargets)
|
||||
: null,
|
||||
}))
|
||||
}, [entry, entries, referencedBy, allContent])
|
||||
}
|
||||
|
||||
function refsMatchTargets(refs: string[], targets: Set<string>): boolean {
|
||||
@@ -109,7 +122,7 @@ export function Inspector({
|
||||
onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty,
|
||||
}: InspectorProps) {
|
||||
const referencedBy = useReferencedBy(entry, entries)
|
||||
const backlinks = useBacklinks(entry, entries, referencedBy)
|
||||
const backlinks = useBacklinks(entry, entries, referencedBy, allContent)
|
||||
const frontmatter = useMemo(() => parseFrontmatter(content), [content])
|
||||
const typeEntryMap = useMemo(() => {
|
||||
const map: Record<string, VaultEntry> = {}
|
||||
|
||||
@@ -30,6 +30,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
@@ -420,27 +421,48 @@ describe('BacklinksPanel', () => {
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders backlink entries', () => {
|
||||
const backlinks = [
|
||||
makeEntry({ title: 'Referencing Note', isA: 'Note' }),
|
||||
makeEntry({ title: 'Another Note', isA: 'Project', path: '/vault/project/another.md' }),
|
||||
]
|
||||
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
|
||||
const twoBacklinks = [
|
||||
{ entry: makeEntry({ title: 'Referencing Note', isA: 'Note' }), context: null },
|
||||
{ entry: makeEntry({ title: 'Another Note', isA: 'Project', path: '/vault/project/another.md' }), context: null },
|
||||
]
|
||||
|
||||
it('renders collapsed by default with count badge', () => {
|
||||
render(<BacklinksPanel typeEntryMap={{}} backlinks={twoBacklinks} onNavigate={onNavigate} />)
|
||||
expect(screen.getByText('Backlinks (2)')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Referencing Note')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('expands to show backlink entries when toggle clicked', () => {
|
||||
render(<BacklinksPanel typeEntryMap={{}} backlinks={twoBacklinks} onNavigate={onNavigate} />)
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
expect(screen.getByText('Referencing Note')).toBeInTheDocument()
|
||||
expect(screen.getByText('Another Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates when clicking backlink', () => {
|
||||
const backlinks = [makeEntry({ title: 'Reference' })]
|
||||
const backlinks = [{ entry: makeEntry({ title: 'Reference' }), context: null }]
|
||||
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
fireEvent.click(screen.getByText('Reference'))
|
||||
expect(onNavigate).toHaveBeenCalledWith('Reference')
|
||||
})
|
||||
|
||||
it('shows count when backlinks exist', () => {
|
||||
const backlinks = [makeEntry(), makeEntry({ path: '/vault/b.md', title: 'B' })]
|
||||
it('shows paragraph context preview when available', () => {
|
||||
const backlinks = [
|
||||
{ entry: makeEntry({ title: 'Referencing Note' }), context: 'This references [[My Note]] in context.' },
|
||||
]
|
||||
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
expect(screen.getByText('This references [[My Note]] in context.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses when toggle clicked twice', () => {
|
||||
const backlinks = [{ entry: makeEntry({ title: 'Note A' }), context: null }]
|
||||
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
expect(screen.getByText('Note A')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
expect(screen.queryByText('Note A')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useCallback, useState, useRef } from 'react'
|
||||
import type { ComponentType, SVGAttributes } from 'react'
|
||||
import { wikilinkTarget, wikilinkDisplay } from '../utils/wikilink'
|
||||
import type { VaultEntry, GitCommit } from '../types'
|
||||
import { Trash, X } from '@phosphor-icons/react'
|
||||
import { CaretRight, Trash, X } from '@phosphor-icons/react'
|
||||
import type { ParsedFrontmatter } from '../utils/frontmatter'
|
||||
import { RELATIONSHIP_KEYS, containsWikilinks } from './DynamicPropertiesPanel'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
@@ -372,21 +372,78 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
|
||||
)
|
||||
}
|
||||
|
||||
export function BacklinksPanel({ backlinks, typeEntryMap, onNavigate }: { backlinks: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; onNavigate: (target: string) => void }) {
|
||||
export interface BacklinkItem {
|
||||
entry: VaultEntry
|
||||
context: string | null
|
||||
}
|
||||
|
||||
function BacklinkEntry({ entry, context, typeEntryMap, onNavigate }: {
|
||||
entry: VaultEntry
|
||||
context: string | null
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
onNavigate: (target: string) => void
|
||||
}) {
|
||||
const te = typeEntryMap[entry.isA ?? '']
|
||||
const isDimmed = entry.archived || entry.trashed
|
||||
return (
|
||||
<button
|
||||
className="flex w-full cursor-pointer flex-col items-start gap-0.5 border-none bg-transparent p-0 text-left hover:opacity-80"
|
||||
onClick={() => onNavigate(entry.title)}
|
||||
title={entryStatusTitle(entry)}
|
||||
>
|
||||
<span
|
||||
className="flex items-center gap-1 text-xs font-medium"
|
||||
style={{ color: isDimmed ? 'var(--muted-foreground)' : getTypeColor(entry.isA, te?.color) }}
|
||||
>
|
||||
{entry.trashed && <Trash size={12} className="shrink-0" />}
|
||||
{entry.title}
|
||||
<StatusSuffix isArchived={entry.archived} isTrashed={entry.trashed} />
|
||||
</span>
|
||||
{context && (
|
||||
<span className="line-clamp-2 text-[11px] leading-snug text-muted-foreground">
|
||||
{context}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function BacklinksPanel({ backlinks, typeEntryMap, onNavigate }: {
|
||||
backlinks: BacklinkItem[]
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
onNavigate: (target: string) => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
if (backlinks.length === 0) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="font-mono-overline mb-2 text-muted-foreground">
|
||||
Backlinks <span className="ml-1" style={{ fontWeight: 400 }}>{backlinks.length}</span>
|
||||
</h4>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{backlinks.map((e) => {
|
||||
const te = typeEntryMap[e.isA ?? '']
|
||||
return (
|
||||
<LinkButton key={e.path} label={e.title} typeColor={getTypeColor(e.isA, te?.color)} isArchived={e.archived} isTrashed={e.trashed} onClick={() => onNavigate(e.title)} title={e.trashed ? 'Trashed' : e.archived ? 'Archived' : undefined} TypeIcon={getTypeIcon(e.isA, te?.icon)} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
className="font-mono-overline mb-2 flex w-full cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
data-testid="backlinks-toggle"
|
||||
>
|
||||
<CaretRight
|
||||
size={12}
|
||||
className="shrink-0 transition-transform"
|
||||
style={{ transform: expanded ? 'rotate(90deg)' : undefined }}
|
||||
/>
|
||||
Backlinks ({backlinks.length})
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="flex flex-col gap-1.5" data-testid="backlinks-list">
|
||||
{backlinks.map(({ entry, context }) => (
|
||||
<BacklinkEntry
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
context={context}
|
||||
typeEntryMap={typeEntryMap}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -62,6 +63,7 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -87,6 +89,7 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -112,6 +115,7 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -137,6 +141,7 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
@@ -357,6 +362,7 @@ describe('getSortComparator', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
@@ -469,6 +475,7 @@ describe('NoteList sort controls', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
@@ -663,6 +670,7 @@ const trashedEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -689,6 +697,7 @@ const expiredTrashedEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -844,6 +853,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
@@ -985,6 +995,19 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows untracked (new) notes alongside modified notes in changes view', () => {
|
||||
const mixedFiles = [
|
||||
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
|
||||
{ path: mockEntries[2].path, relativePath: 'person/matteo-cellini.md', status: 'untracked' as const },
|
||||
]
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={mixedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1132,6 +1155,7 @@ const typeEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -38,6 +38,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: ['topic/ai', 'topic/api-design', 'person/luca'],
|
||||
},
|
||||
{
|
||||
@@ -63,6 +64,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: ['person/bob'],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -40,6 +40,7 @@ const mockThemeManager: ThemeManager = {
|
||||
themes: [],
|
||||
activeThemeId: null,
|
||||
activeTheme: null,
|
||||
isDark: false,
|
||||
switchTheme: vi.fn(),
|
||||
createTheme: vi.fn().mockResolvedValue('untitled'),
|
||||
reloadThemes: vi.fn(),
|
||||
|
||||
@@ -39,6 +39,8 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -64,6 +66,8 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -89,6 +93,8 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -114,6 +120,8 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -139,6 +147,8 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -164,6 +174,8 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -189,6 +201,8 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -214,6 +228,8 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
@@ -432,6 +448,8 @@ describe('Sidebar', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -457,6 +475,8 @@ describe('Sidebar', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -482,6 +502,7 @@ describe('Sidebar', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -507,6 +528,7 @@ describe('Sidebar', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
@@ -553,7 +575,7 @@ describe('Sidebar', () => {
|
||||
isA: 'Event', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, trashed: true, trashedAt: 1700000000,
|
||||
modifiedAt: 1700000000, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: null, outgoingLinks: [],
|
||||
relationships: {}, icon: null, color: null, order: null, sidebarLabel: null, outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
render(<Sidebar entries={entriesWithTrashedOnly} selection={defaultSelection} onSelect={() => {}} />)
|
||||
@@ -591,6 +613,8 @@ describe('Sidebar', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
render(<Sidebar entries={[...mockEntries, projectTypeEntry]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
@@ -598,6 +622,52 @@ describe('Sidebar', () => {
|
||||
const projectLabels = screen.getAllByText('Projects')
|
||||
expect(projectLabels.length).toBe(1)
|
||||
})
|
||||
|
||||
it('uses sidebarLabel from Type entry instead of auto-pluralization', () => {
|
||||
const entriesWithLabel: VaultEntry[] = [
|
||||
...mockEntries,
|
||||
{
|
||||
path: '/vault/type/news.md', filename: 'news.md', title: 'News', 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: 'News', outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
path: '/vault/news/breaking.md', filename: 'breaking.md', title: 'Breaking Story', isA: 'News',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 300, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
render(<Sidebar entries={entriesWithLabel} selection={defaultSelection} onSelect={() => {}} />)
|
||||
// Should show "News" (custom label), not "Newses" (auto-pluralized)
|
||||
expect(screen.getByText('News')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Newses')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses sidebarLabel to override built-in type label', () => {
|
||||
const entriesWithBuiltInOverride: VaultEntry[] = [
|
||||
...mockEntries,
|
||||
{
|
||||
path: '/vault/type/person.md', filename: 'person.md', title: 'Person', 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: 'Contacts', outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
render(<Sidebar entries={entriesWithBuiltInOverride} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('Contacts')).toBeInTheDocument()
|
||||
expect(screen.queryByText('People')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to auto-pluralization when sidebarLabel is null', () => {
|
||||
render(<Sidebar entries={entriesWithCustomTypes} selection={defaultSelection} onSelect={() => {}} />)
|
||||
// Recipe has no sidebarLabel → should auto-pluralize to "Recipes"
|
||||
expect(screen.getByText('Recipes')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('customize section visibility', () => {
|
||||
@@ -705,21 +775,21 @@ describe('Sidebar', () => {
|
||||
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: 5, outgoingLinks: [],
|
||||
relationships: {}, icon: null, color: null, order: 5, sidebarLabel: null, outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
path: '/vault/type/topic.md', filename: 'topic.md', title: 'Topic', 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: 0, outgoingLinks: [],
|
||||
relationships: {}, icon: null, color: null, order: 0, sidebarLabel: null, outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
path: '/vault/type/person.md', filename: 'person.md', title: 'Person', 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: 1, outgoingLinks: [],
|
||||
relationships: {}, icon: null, color: null, order: 1, sidebarLabel: null, outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ interface SidebarProps {
|
||||
onCreateType?: (type: string) => void
|
||||
onCreateNewType?: () => void
|
||||
onCustomizeType?: (typeName: string, icon: string, color: string) => void
|
||||
onUpdateTypeTemplate?: (typeName: string, template: string) => void
|
||||
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
|
||||
modifiedCount?: number
|
||||
onCommitPush?: () => void
|
||||
@@ -79,11 +80,12 @@ function buildSectionGroup(type: string, typeEntryMap: Record<string, VaultEntry
|
||||
const builtIn = BUILT_IN_TYPE_MAP.get(type)
|
||||
const typeEntry = typeEntryMap[type]
|
||||
const customColor = typeEntry?.color ?? null
|
||||
const label = typeEntry?.sidebarLabel || (builtIn?.label ?? pluralizeType(type))
|
||||
if (builtIn) {
|
||||
const Icon = typeEntry?.icon ? resolveIcon(typeEntry.icon) : builtIn.Icon
|
||||
return { ...builtIn, Icon, customColor }
|
||||
return { ...builtIn, label, Icon, customColor }
|
||||
}
|
||||
return { label: pluralizeType(type), type, Icon: resolveIcon(typeEntry?.icon ?? null), customColor }
|
||||
return { label, type, Icon: resolveIcon(typeEntry?.icon ?? null), customColor }
|
||||
}
|
||||
|
||||
/** Build sections dynamically from actual vault entries — only types with ≥1 active note appear */
|
||||
@@ -149,8 +151,9 @@ function applyCustomization(
|
||||
): void {
|
||||
if (!target || !onCustomizeType) return
|
||||
const te = typeEntryMap[target]
|
||||
if (!te) return
|
||||
const [icon, color] = buildCustomizeArgs(te, prop, value)
|
||||
const [icon, color] = te
|
||||
? buildCustomizeArgs(te, prop, value)
|
||||
: [prop === 'icon' ? value : 'file-text', prop === 'color' ? value : 'blue']
|
||||
onCustomizeType(target, icon, color)
|
||||
}
|
||||
|
||||
@@ -228,10 +231,11 @@ function ContextMenuOverlay({ pos, type, innerRef, onOpenCustomize }: {
|
||||
)
|
||||
}
|
||||
|
||||
function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onClose }: {
|
||||
function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChangeTemplate, onClose }: {
|
||||
target: string | null; typeEntryMap: Record<string, VaultEntry>
|
||||
innerRef: React.Ref<HTMLDivElement>
|
||||
onCustomize: (prop: 'icon' | 'color', value: string) => void
|
||||
onChangeTemplate: (template: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
if (!target) return null
|
||||
@@ -240,8 +244,10 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onClose
|
||||
<TypeCustomizePopover
|
||||
currentIcon={typeEntryMap[target]?.icon ?? null}
|
||||
currentColor={typeEntryMap[target]?.color ?? null}
|
||||
currentTemplate={typeEntryMap[target]?.template ?? null}
|
||||
onChangeIcon={(icon) => onCustomize('icon', icon)}
|
||||
onChangeColor={(color) => onCustomize('color', color)}
|
||||
onChangeTemplate={onChangeTemplate}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
@@ -252,7 +258,7 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onClose
|
||||
|
||||
export const Sidebar = memo(function Sidebar({
|
||||
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
onCustomizeType, onReorderSections, modifiedCount = 0, onCommitPush, onCollapse,
|
||||
onCustomizeType, onUpdateTypeTemplate, onReorderSections, modifiedCount = 0, onCommitPush, onCollapse,
|
||||
}: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
|
||||
@@ -301,6 +307,10 @@ export const Sidebar = memo(function Sidebar({
|
||||
applyCustomization(customizeTarget, typeEntryMap, onCustomizeType, prop, value)
|
||||
}, [customizeTarget, typeEntryMap, onCustomizeType])
|
||||
|
||||
const handleChangeTemplate = useCallback((template: string) => {
|
||||
if (customizeTarget) onUpdateTypeTemplate?.(customizeTarget, template)
|
||||
}, [customizeTarget, onUpdateTypeTemplate])
|
||||
|
||||
const sectionProps = {
|
||||
entries, collapsed, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
onContextMenu: handleContextMenu, onToggle: toggleSection,
|
||||
@@ -345,7 +355,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
|
||||
<CommitButton modifiedCount={modifiedCount} onClick={onCommitPush} />
|
||||
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} />
|
||||
<CustomizeOverlay target={customizeTarget} typeEntryMap={typeEntryMap} innerRef={popoverRef} onCustomize={handleCustomize} onClose={closeCustomizeTarget} />
|
||||
<CustomizeOverlay target={customizeTarget} typeEntryMap={typeEntryMap} innerRef={popoverRef} onCustomize={handleCustomize} onChangeTemplate={handleChangeTemplate} onClose={closeCustomizeTarget} />
|
||||
</aside>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -23,12 +23,13 @@ function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
}
|
||||
|
||||
/** Single BlockNote editor view — content is swapped via replaceBlocks */
|
||||
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath }: {
|
||||
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, isDarkTheme }: {
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
entries: VaultEntry[]
|
||||
onNavigateWikilink: (target: string) => void
|
||||
onChange?: () => void
|
||||
vaultPath?: string
|
||||
isDarkTheme?: boolean
|
||||
}) {
|
||||
const navigateRef = useRef(onNavigateWikilink)
|
||||
useEffect(() => { navigateRef.current = onNavigateWikilink }, [onNavigateWikilink])
|
||||
@@ -100,7 +101,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
)}
|
||||
<BlockNoteView
|
||||
editor={editor}
|
||||
theme="light"
|
||||
theme={isDarkTheme ? 'dark' : 'light'}
|
||||
onChange={onChange}
|
||||
>
|
||||
<SuggestionMenuController
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { TabBar } from './TabBar'
|
||||
import { computeTabMaxWidth } from '../utils/tabLayout'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
function makeEntry(path: string, title: string): VaultEntry {
|
||||
@@ -10,7 +11,7 @@ function makeEntry(path: string, title: string): VaultEntry {
|
||||
status: null, owner: null, cadence: null, archived: false,
|
||||
trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0,
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [],
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, outgoingLinks: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +216,33 @@ describe('TabBar', () => {
|
||||
expect(screen.queryByTestId('tab-new-indicator')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nav back/forward buttons', () => {
|
||||
const tabs = makeTabs(['Alpha'])
|
||||
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} {...defaultProps} />)
|
||||
expect(screen.getByTestId('nav-back')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('nav-forward')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables nav buttons when canGoBack/canGoForward are false', () => {
|
||||
const tabs = makeTabs(['Alpha'])
|
||||
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} {...defaultProps} canGoBack={false} canGoForward={false} />)
|
||||
expect(screen.getByTestId('nav-back')).toBeDisabled()
|
||||
expect(screen.getByTestId('nav-forward')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('enables nav buttons and fires handlers on click', () => {
|
||||
const onGoBack = vi.fn()
|
||||
const onGoForward = vi.fn()
|
||||
const tabs = makeTabs(['Alpha'])
|
||||
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} {...defaultProps} canGoBack canGoForward onGoBack={onGoBack} onGoForward={onGoForward} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('nav-back'))
|
||||
expect(onGoBack).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByTestId('nav-forward'))
|
||||
expect(onGoForward).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('switches tab on click', () => {
|
||||
const onSwitchTab = vi.fn()
|
||||
const tabs = makeTabs(['Alpha', 'Beta'])
|
||||
@@ -230,4 +258,59 @@ describe('TabBar', () => {
|
||||
fireEvent.click(screen.getByText('Beta'))
|
||||
expect(onSwitchTab).toHaveBeenCalledWith(tabs[1].entry.path)
|
||||
})
|
||||
|
||||
describe('responsive tab width', () => {
|
||||
it('wraps tabs in an overflow-hidden flex container', () => {
|
||||
const tabs = makeTabs(['Alpha'])
|
||||
const { container } = render(
|
||||
<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} {...defaultProps} />
|
||||
)
|
||||
const tabArea = container.querySelector('.overflow-hidden')
|
||||
expect(tabArea).toBeInTheDocument()
|
||||
expect(tabArea?.classList.contains('flex')).toBe(true)
|
||||
expect(tabArea?.classList.contains('min-w-0')).toBe(true)
|
||||
expect(tabArea?.classList.contains('flex-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('tab elements are shrinkable with min-w-0', () => {
|
||||
const tabs = makeTabs(['Alpha', 'Beta'])
|
||||
const { container } = render(
|
||||
<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} {...defaultProps} />
|
||||
)
|
||||
const tabEls = container.querySelectorAll('[draggable="true"]')
|
||||
expect(tabEls).toHaveLength(2)
|
||||
for (const el of tabEls) {
|
||||
expect(el.classList.contains('shrink-0')).toBe(false)
|
||||
expect(el.classList.contains('min-w-0')).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeTabMaxWidth', () => {
|
||||
it('caps at 360px when container is wide', () => {
|
||||
expect(computeTabMaxWidth(1200, 2)).toBe(360)
|
||||
})
|
||||
|
||||
it('divides space equally among tabs', () => {
|
||||
expect(computeTabMaxWidth(500, 5)).toBe(100)
|
||||
})
|
||||
|
||||
it('enforces minimum of 60px', () => {
|
||||
expect(computeTabMaxWidth(300, 10)).toBe(60)
|
||||
})
|
||||
|
||||
it('returns 360 for zero tabs', () => {
|
||||
expect(computeTabMaxWidth(800, 0)).toBe(360)
|
||||
})
|
||||
|
||||
it('floors the result to integer pixels', () => {
|
||||
// 1000 / 3 = 333.33 → 333
|
||||
expect(computeTabMaxWidth(1000, 3)).toBe(333)
|
||||
})
|
||||
|
||||
it('handles single tab', () => {
|
||||
expect(computeTabMaxWidth(200, 1)).toBe(200)
|
||||
expect(computeTabMaxWidth(500, 1)).toBe(360)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { VaultEntry, NoteStatus } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { X } from 'lucide-react'
|
||||
import { Plus, Columns, ArrowsOutSimple, ArrowLeft, ArrowRight } from '@phosphor-icons/react'
|
||||
import { computeTabMaxWidth } from '@/utils/tabLayout'
|
||||
|
||||
interface Tab {
|
||||
entry: VaultEntry
|
||||
@@ -199,7 +200,7 @@ function StatusDot({ status }: { status: NoteStatus }) {
|
||||
)
|
||||
}
|
||||
|
||||
function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBefore, showDropAfter, onSwitch, onClose, onDoubleClick, onRenameSave, onRenameCancel, dragProps }: {
|
||||
function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBefore, showDropAfter, tabMaxWidth, onSwitch, onClose, onDoubleClick, onRenameSave, onRenameCancel, dragProps }: {
|
||||
tab: Tab
|
||||
isActive: boolean
|
||||
isEditing: boolean
|
||||
@@ -207,6 +208,7 @@ function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBef
|
||||
isDragging: boolean
|
||||
showDropBefore: boolean
|
||||
showDropAfter: boolean
|
||||
tabMaxWidth: number
|
||||
onSwitch: () => void
|
||||
onClose: () => void
|
||||
onDoubleClick: () => void
|
||||
@@ -219,10 +221,11 @@ function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBef
|
||||
draggable={!isEditing}
|
||||
{...dragProps}
|
||||
className={cn(
|
||||
"group flex shrink-0 items-center gap-1.5 whitespace-nowrap max-w-[360px] transition-all relative",
|
||||
"group flex min-w-0 items-center gap-1.5 whitespace-nowrap transition-all relative",
|
||||
isActive ? "text-foreground" : "text-muted-foreground hover:text-secondary-foreground"
|
||||
)}
|
||||
style={{
|
||||
maxWidth: tabMaxWidth,
|
||||
background: isActive ? 'var(--background)' : 'transparent',
|
||||
borderRight: `1px solid ${isActive ? 'var(--border)' : 'var(--sidebar-border)'}`,
|
||||
borderBottom: isActive ? 'none' : '1px solid var(--sidebar-border)',
|
||||
@@ -331,8 +334,20 @@ export const TabBar = memo(function TabBar({
|
||||
}: TabBarProps) {
|
||||
const { dragIndex, dropIndex, handleDragStart, handleDragEnd, handleDragOver, handleDrop, handleBarDragLeave } = useTabDrag(onReorderTabs)
|
||||
const [editingPath, setEditingPath] = useState<string | null>(null)
|
||||
const tabAreaRef = useRef<HTMLDivElement>(null)
|
||||
const [tabMaxWidth, setTabMaxWidth] = useState(360)
|
||||
const { onMouseDown: onDragMouseDown } = useDragRegion()
|
||||
|
||||
useEffect(() => {
|
||||
const el = tabAreaRef.current
|
||||
if (!el) return
|
||||
const recalc = () => setTabMaxWidth(computeTabMaxWidth(el.clientWidth, tabs.length))
|
||||
recalc()
|
||||
const observer = new ResizeObserver(recalc)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [tabs.length])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-stretch"
|
||||
@@ -340,30 +355,33 @@ export const TabBar = memo(function TabBar({
|
||||
onDragLeave={handleBarDragLeave}
|
||||
>
|
||||
<NavButtons canGoBack={canGoBack} canGoForward={canGoForward} onGoBack={onGoBack} onGoForward={onGoForward} />
|
||||
{tabs.map((tab, index) => (
|
||||
<TabItem
|
||||
key={tab.entry.path}
|
||||
tab={tab}
|
||||
isActive={tab.entry.path === activeTabPath}
|
||||
isEditing={editingPath === tab.entry.path}
|
||||
noteStatus={getNoteStatus?.(tab.entry.path) ?? 'clean'}
|
||||
isDragging={dragIndex !== null}
|
||||
showDropBefore={dropIndex === index}
|
||||
showDropAfter={dropIndex === index + 1 && index === tabs.length - 1}
|
||||
onSwitch={() => onSwitchTab(tab.entry.path)}
|
||||
onClose={() => onCloseTab(tab.entry.path)}
|
||||
onDoubleClick={() => onRenameTab && setEditingPath(tab.entry.path)}
|
||||
onRenameSave={(newTitle) => { setEditingPath(null); onRenameTab?.(tab.entry.path, newTitle) }}
|
||||
onRenameCancel={() => setEditingPath(null)}
|
||||
dragProps={{
|
||||
onDragStart: (e) => handleDragStart(e, index),
|
||||
onDragEnd: handleDragEnd,
|
||||
onDragOver: (e) => handleDragOver(e, index),
|
||||
onDrop: handleDrop,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div className="flex-1" style={{ borderBottom: '1px solid var(--border)', cursor: 'default' }} onMouseDown={onDragMouseDown} />
|
||||
<div ref={tabAreaRef} className="flex flex-1 min-w-0 items-stretch overflow-hidden">
|
||||
{tabs.map((tab, index) => (
|
||||
<TabItem
|
||||
key={tab.entry.path}
|
||||
tab={tab}
|
||||
isActive={tab.entry.path === activeTabPath}
|
||||
isEditing={editingPath === tab.entry.path}
|
||||
noteStatus={getNoteStatus?.(tab.entry.path) ?? 'clean'}
|
||||
isDragging={dragIndex !== null}
|
||||
showDropBefore={dropIndex === index}
|
||||
showDropAfter={dropIndex === index + 1 && index === tabs.length - 1}
|
||||
tabMaxWidth={tabMaxWidth}
|
||||
onSwitch={() => onSwitchTab(tab.entry.path)}
|
||||
onClose={() => onCloseTab(tab.entry.path)}
|
||||
onDoubleClick={() => onRenameTab && setEditingPath(tab.entry.path)}
|
||||
onRenameSave={(newTitle) => { setEditingPath(null); onRenameTab?.(tab.entry.path, newTitle) }}
|
||||
onRenameCancel={() => setEditingPath(null)}
|
||||
dragProps={{
|
||||
onDragStart: (e) => handleDragStart(e, index),
|
||||
onDragEnd: handleDragEnd,
|
||||
onDragOver: (e) => handleDragOver(e, index),
|
||||
onDrop: handleDrop,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div className="flex-1 shrink-0" style={{ borderBottom: '1px solid var(--border)', cursor: 'default' }} onMouseDown={onDragMouseDown} />
|
||||
</div>
|
||||
<TabBarActions onCreateNote={onCreateNote} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -43,50 +43,42 @@ describe('ICON_OPTIONS', () => {
|
||||
describe('TypeCustomizePopover', () => {
|
||||
const onChangeIcon = vi.fn()
|
||||
const onChangeColor = vi.fn()
|
||||
const onChangeTemplate = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
|
||||
const renderPopover = (overrides: Partial<Parameters<typeof TypeCustomizePopover>[0]> = {}) =>
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
currentTemplate={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onChangeTemplate={onChangeTemplate}
|
||||
onClose={onClose}
|
||||
{...overrides}
|
||||
/>
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders color section and icon section', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon="wrench"
|
||||
currentColor="blue"
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
it('renders color, icon, and template sections', () => {
|
||||
renderPopover()
|
||||
expect(screen.getByText('COLOR')).toBeInTheDocument()
|
||||
expect(screen.getByText('ICON')).toBeInTheDocument()
|
||||
expect(screen.getByText('TEMPLATE')).toBeInTheDocument()
|
||||
expect(screen.getByText('Done')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders search input', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
renderPopover()
|
||||
expect(screen.getByPlaceholderText('Search icons…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters icons by search query', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
renderPopover()
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search icons…')
|
||||
fireEvent.change(searchInput, { target: { value: 'book' } })
|
||||
@@ -99,15 +91,7 @@ describe('TypeCustomizePopover', () => {
|
||||
})
|
||||
|
||||
it('shows empty state when no icons match search', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
renderPopover()
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search icons…')
|
||||
fireEvent.change(searchInput, { target: { value: 'zzzznonexistent' } })
|
||||
@@ -116,15 +100,7 @@ describe('TypeCustomizePopover', () => {
|
||||
})
|
||||
|
||||
it('calls onChangeColor when a color is clicked', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
renderPopover()
|
||||
|
||||
const colorButtons = screen.getAllByTitle(/red|blue|green|purple|yellow|orange|teal|pink/i)
|
||||
fireEvent.click(colorButtons[0])
|
||||
@@ -133,47 +109,70 @@ describe('TypeCustomizePopover', () => {
|
||||
})
|
||||
|
||||
it('calls onChangeIcon when an icon is clicked', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
renderPopover()
|
||||
|
||||
fireEvent.click(screen.getByTitle('wrench'))
|
||||
expect(onChangeIcon).toHaveBeenCalledWith('wrench')
|
||||
})
|
||||
|
||||
it('calls onClose when Done is clicked', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
renderPopover()
|
||||
|
||||
fireEvent.click(screen.getByText('Done'))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders all color options including teal and pink', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
renderPopover()
|
||||
|
||||
expect(screen.getByTitle('Teal')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('Pink')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// --- Template tests ---
|
||||
|
||||
it('renders template textarea', () => {
|
||||
renderPopover()
|
||||
expect(screen.getByTestId('template-textarea')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows placeholder when template is empty', () => {
|
||||
renderPopover()
|
||||
expect(screen.getByPlaceholderText('Markdown template for new notes of this type…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays current template value', () => {
|
||||
renderPopover({ currentTemplate: '## Objective\n\n## Notes' })
|
||||
const textarea = screen.getByTestId('template-textarea') as HTMLTextAreaElement
|
||||
expect(textarea.value).toBe('## Objective\n\n## Notes')
|
||||
})
|
||||
|
||||
it('updates template text on user input', () => {
|
||||
renderPopover()
|
||||
const textarea = screen.getByTestId('template-textarea')
|
||||
fireEvent.change(textarea, { target: { value: '## New Template' } })
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe('## New Template')
|
||||
})
|
||||
|
||||
it('calls onChangeTemplate after debounce', async () => {
|
||||
vi.useFakeTimers()
|
||||
renderPopover()
|
||||
const textarea = screen.getByTestId('template-textarea')
|
||||
fireEvent.change(textarea, { target: { value: '## Debounced' } })
|
||||
|
||||
// Should not be called immediately
|
||||
expect(onChangeTemplate).not.toHaveBeenCalled()
|
||||
|
||||
// Fast-forward past debounce
|
||||
vi.advanceTimersByTime(600)
|
||||
expect(onChangeTemplate).toHaveBeenCalledWith('## Debounced')
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('treats null template as empty string', () => {
|
||||
renderPopover({ currentTemplate: null })
|
||||
const textarea = screen.getByTestId('template-textarea') as HTMLTextAreaElement
|
||||
expect(textarea.value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
|
||||
import { MagnifyingGlass } from '@phosphor-icons/react'
|
||||
import { ICON_OPTIONS, type IconEntry } from '../utils/iconRegistry'
|
||||
import { ACCENT_COLORS } from '../utils/typeColors'
|
||||
@@ -13,21 +13,40 @@ function filterIcons(icons: IconEntry[], query: string): IconEntry[] {
|
||||
interface TypeCustomizePopoverProps {
|
||||
currentIcon: string | null
|
||||
currentColor: string | null
|
||||
currentTemplate: string | null
|
||||
onChangeIcon: (icon: string) => void
|
||||
onChangeColor: (color: string) => void
|
||||
onChangeTemplate: (template: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** Debounce a callback by `delay` ms. Returns a stable ref-based wrapper. */
|
||||
function useDebouncedCallback(fn: (v: string) => void, delay: number): (v: string) => void {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
const fnRef = useRef(fn)
|
||||
useEffect(() => { fnRef.current = fn })
|
||||
|
||||
useEffect(() => () => { clearTimeout(timerRef.current) }, [])
|
||||
|
||||
return useCallback((v: string) => {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => fnRef.current(v), delay)
|
||||
}, [delay])
|
||||
}
|
||||
|
||||
export function TypeCustomizePopover({
|
||||
currentIcon,
|
||||
currentColor,
|
||||
currentTemplate,
|
||||
onChangeIcon,
|
||||
onChangeColor,
|
||||
onChangeTemplate,
|
||||
onClose,
|
||||
}: TypeCustomizePopoverProps) {
|
||||
const [selectedColor, setSelectedColor] = useState(currentColor)
|
||||
const [selectedIcon, setSelectedIcon] = useState(currentIcon)
|
||||
const [search, setSearch] = useState('')
|
||||
const [templateText, setTemplateText] = useState(currentTemplate ?? '')
|
||||
|
||||
const filteredIcons = useMemo(() => filterIcons(ICON_OPTIONS, search), [search])
|
||||
|
||||
@@ -41,10 +60,17 @@ export function TypeCustomizePopover({
|
||||
onChangeIcon(name)
|
||||
}
|
||||
|
||||
const debouncedSaveTemplate = useDebouncedCallback(onChangeTemplate, 500)
|
||||
|
||||
const handleTemplateChange = (value: string) => {
|
||||
setTemplateText(value)
|
||||
debouncedSaveTemplate(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-popover text-popover-foreground z-50 rounded-lg border shadow-md"
|
||||
style={{ width: 280, padding: 12 }}
|
||||
style={{ width: 320, padding: 12 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -84,7 +110,7 @@ export function TypeCustomizePopover({
|
||||
</div>
|
||||
|
||||
{/* Icon grid */}
|
||||
<div className="flex flex-wrap gap-1 overflow-y-auto" style={{ maxHeight: 240 }}>
|
||||
<div className="flex flex-wrap gap-1 overflow-y-auto" style={{ maxHeight: 160 }}>
|
||||
{filteredIcons.length === 0 ? (
|
||||
<div className="w-full py-6 text-center text-[12px] text-muted-foreground">
|
||||
No icons found
|
||||
@@ -109,6 +135,17 @@ export function TypeCustomizePopover({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Template section */}
|
||||
<div className="font-mono-overline mb-2 mt-3 text-muted-foreground">TEMPLATE</div>
|
||||
<textarea
|
||||
value={templateText}
|
||||
onChange={(e) => handleTemplateChange(e.target.value)}
|
||||
placeholder="Markdown template for new notes of this type…"
|
||||
className="w-full rounded border border-border bg-background px-2 py-1.5 text-[12px] font-mono text-foreground placeholder:text-muted-foreground outline-none focus:border-primary resize-y"
|
||||
style={{ minHeight: 80, maxHeight: 200 }}
|
||||
data-testid="template-textarea"
|
||||
/>
|
||||
|
||||
{/* Done button */}
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* States: idle -> thinking -> tool-executing -> done/error
|
||||
*/
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import type { AiAction } from '../components/AiMessage'
|
||||
import { streamClaudeAgent, buildAgentSystemPrompt } from '../utils/ai-agent'
|
||||
import { nextMessageId } from '../utils/ai-chat'
|
||||
@@ -20,10 +20,14 @@ export interface AiAgentMessage {
|
||||
id?: string
|
||||
}
|
||||
|
||||
export function useAiAgent(vaultPath: string) {
|
||||
export function useAiAgent(vaultPath: string, contextPrompt?: string) {
|
||||
const [messages, setMessages] = useState<AiAgentMessage[]>([])
|
||||
const [status, setStatus] = useState<AgentStatus>('idle')
|
||||
const abortRef = useRef({ aborted: false })
|
||||
const contextRef = useRef(contextPrompt)
|
||||
useEffect(() => {
|
||||
contextRef.current = contextPrompt
|
||||
}, [contextPrompt])
|
||||
|
||||
const sendMessage = useCallback(async (text: string) => {
|
||||
if (!text.trim() || status === 'thinking' || status === 'tool-executing') return
|
||||
@@ -49,7 +53,11 @@ export function useAiAgent(vaultPath: string) {
|
||||
setMessages(prev => prev.map(m => m.id === messageId ? fn(m) : m))
|
||||
}
|
||||
|
||||
await streamClaudeAgent(text.trim(), buildAgentSystemPrompt(), vaultPath, {
|
||||
// When a contextual prompt is provided (from buildContextualPrompt),
|
||||
// use it directly — it already includes the system preamble.
|
||||
const systemPrompt = contextRef.current ?? buildAgentSystemPrompt()
|
||||
|
||||
await streamClaudeAgent(text.trim(), systemPrompt, vaultPath, {
|
||||
onText: (text) => {
|
||||
if (abortRef.current.aborted) return
|
||||
update(m => ({ ...m, response: (m.response ?? '') + text }))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useAppKeyboard } from './useAppKeyboard'
|
||||
import { useCommandRegistry } from './useCommandRegistry'
|
||||
import type { CommandAction } from './useCommandRegistry'
|
||||
@@ -21,10 +22,12 @@ interface AppCommandsConfig {
|
||||
onCommandPalette: () => void
|
||||
onSearch: () => void
|
||||
onCreateNote: () => void
|
||||
onOpenDailyNote: () => void
|
||||
onCreateNoteOfType: (type: string) => void
|
||||
onSave: () => void
|
||||
onOpenSettings: () => void
|
||||
onTrashNote: (path: string) => void
|
||||
onRestoreNote: (path: string) => void
|
||||
onArchiveNote: (path: string) => void
|
||||
onUnarchiveNote: (path: string) => void
|
||||
onCommitPush: () => void
|
||||
@@ -48,25 +51,42 @@ interface AppCommandsConfig {
|
||||
onSwitchTheme?: (themeId: string) => void
|
||||
onCreateTheme?: () => void
|
||||
onOpenVault?: () => void
|
||||
onToggleAIChat?: () => void
|
||||
}
|
||||
|
||||
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
|
||||
export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
const entriesRef = useRef(config.entries)
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
entriesRef.current = config.entries
|
||||
|
||||
const toggleArchive = useCallback((path: string) => {
|
||||
const entry = entriesRef.current.find(e => e.path === path)
|
||||
;(entry?.archived ? config.onUnarchiveNote : config.onArchiveNote)(path)
|
||||
}, [config.onArchiveNote, config.onUnarchiveNote])
|
||||
|
||||
const toggleTrash = useCallback((path: string) => {
|
||||
const entry = entriesRef.current.find(e => e.path === path)
|
||||
;(entry?.trashed ? config.onRestoreNote : config.onTrashNote)(path)
|
||||
}, [config.onTrashNote, config.onRestoreNote])
|
||||
|
||||
useAppKeyboard({
|
||||
onQuickOpen: config.onQuickOpen,
|
||||
onCommandPalette: config.onCommandPalette,
|
||||
onSearch: config.onSearch,
|
||||
onCreateNote: config.onCreateNote,
|
||||
onOpenDailyNote: config.onOpenDailyNote,
|
||||
onSave: config.onSave,
|
||||
onOpenSettings: config.onOpenSettings,
|
||||
onTrashNote: config.onTrashNote,
|
||||
onArchiveNote: config.onArchiveNote,
|
||||
onTrashNote: toggleTrash,
|
||||
onArchiveNote: toggleArchive,
|
||||
onSetViewMode: config.onSetViewMode,
|
||||
onZoomIn: config.onZoomIn,
|
||||
onZoomOut: config.onZoomOut,
|
||||
onZoomReset: config.onZoomReset,
|
||||
onGoBack: config.onGoBack,
|
||||
onGoForward: config.onGoForward,
|
||||
onToggleAIChat: config.onToggleAIChat,
|
||||
activeTabPathRef: config.activeTabPathRef,
|
||||
handleCloseTabRef: config.handleCloseTabRef,
|
||||
})
|
||||
@@ -74,6 +94,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
useMenuEvents({
|
||||
onSetViewMode: config.onSetViewMode,
|
||||
onCreateNote: config.onCreateNote,
|
||||
onOpenDailyNote: config.onOpenDailyNote,
|
||||
onQuickOpen: config.onQuickOpen,
|
||||
onSave: config.onSave,
|
||||
onOpenSettings: config.onOpenSettings,
|
||||
@@ -82,8 +103,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onZoomIn: config.onZoomIn,
|
||||
onZoomOut: config.onZoomOut,
|
||||
onZoomReset: config.onZoomReset,
|
||||
onArchiveNote: config.onArchiveNote,
|
||||
onTrashNote: config.onTrashNote,
|
||||
onArchiveNote: toggleArchive,
|
||||
onTrashNote: toggleTrash,
|
||||
onSearch: config.onSearch,
|
||||
onGoBack: config.onGoBack,
|
||||
onGoForward: config.onGoForward,
|
||||
@@ -102,6 +123,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onSave: config.onSave,
|
||||
onOpenSettings: config.onOpenSettings,
|
||||
onTrashNote: config.onTrashNote,
|
||||
onRestoreNote: config.onRestoreNote,
|
||||
onArchiveNote: config.onArchiveNote,
|
||||
onUnarchiveNote: config.onUnarchiveNote,
|
||||
onCommitPush: config.onCommitPush,
|
||||
@@ -112,6 +134,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onZoomReset: config.onZoomReset,
|
||||
zoomLevel: config.zoomLevel,
|
||||
onSelect: config.onSelect,
|
||||
onOpenDailyNote: config.onOpenDailyNote,
|
||||
onCloseTab: config.onCloseTab,
|
||||
onGoBack: config.onGoBack,
|
||||
onGoForward: config.onGoForward,
|
||||
@@ -122,6 +145,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onSwitchTheme: config.onSwitchTheme,
|
||||
onCreateTheme: config.onCreateTheme,
|
||||
onOpenVault: config.onOpenVault,
|
||||
onToggleAIChat: config.onToggleAIChat,
|
||||
})
|
||||
|
||||
useKeyboardNavigation({
|
||||
|
||||
@@ -21,6 +21,7 @@ function makeActions() {
|
||||
onCommandPalette: vi.fn(),
|
||||
onSearch: vi.fn(),
|
||||
onCreateNote: vi.fn(),
|
||||
onOpenDailyNote: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onTrashNote: vi.fn(),
|
||||
@@ -79,6 +80,13 @@ describe('useAppKeyboard', () => {
|
||||
expect(actions.onCreateNote).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+J triggers open daily note', () => {
|
||||
const actions = makeActions()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
fireKey('j', { metaKey: true })
|
||||
expect(actions.onOpenDailyNote).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+W closes the active tab', () => {
|
||||
const actions = makeActions()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
@@ -174,4 +182,22 @@ describe('useAppKeyboard', () => {
|
||||
fireKey('0', { metaKey: true })
|
||||
expect(actions.onZoomReset).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+I triggers toggle AI chat', () => {
|
||||
const actions = makeActions()
|
||||
const onToggleAIChat = vi.fn()
|
||||
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
|
||||
fireKey('i', { metaKey: true })
|
||||
expect(onToggleAIChat).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+I works when text input is focused', () => {
|
||||
const actions = makeActions()
|
||||
const onToggleAIChat = vi.fn()
|
||||
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
|
||||
withFocusedInput(() => {
|
||||
fireKey('i', { metaKey: true })
|
||||
expect(onToggleAIChat).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ interface KeyboardActions {
|
||||
onCommandPalette: () => void
|
||||
onSearch: () => void
|
||||
onCreateNote: () => void
|
||||
onOpenDailyNote: () => void
|
||||
onSave: () => void
|
||||
onOpenSettings: () => void
|
||||
onTrashNote: (path: string) => void
|
||||
@@ -16,6 +17,7 @@ interface KeyboardActions {
|
||||
onZoomReset: () => void
|
||||
onGoBack?: () => void
|
||||
onGoForward?: () => void
|
||||
onToggleAIChat?: () => void
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
|
||||
}
|
||||
@@ -60,8 +62,8 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record<string, ShortcutHandler>)
|
||||
}
|
||||
|
||||
export function useAppKeyboard({
|
||||
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
|
||||
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, activeTabPathRef, handleCloseTabRef,
|
||||
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
|
||||
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, activeTabPathRef, handleCloseTabRef,
|
||||
}: KeyboardActions) {
|
||||
useEffect(() => {
|
||||
const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => {
|
||||
@@ -73,6 +75,7 @@ export function useAppKeyboard({
|
||||
k: onCommandPalette,
|
||||
p: onQuickOpen,
|
||||
n: onCreateNote,
|
||||
j: onOpenDailyNote,
|
||||
s: onSave,
|
||||
',': onOpenSettings,
|
||||
e: withActiveTab(onArchiveNote),
|
||||
@@ -85,6 +88,7 @@ export function useAppKeyboard({
|
||||
'+': onZoomIn,
|
||||
'-': onZoomOut,
|
||||
'0': onZoomReset,
|
||||
i: () => onToggleAIChat?.(),
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -100,5 +104,5 @@ export function useAppKeyboard({
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward])
|
||||
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat])
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
@@ -41,6 +42,7 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
|
||||
onSave: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onTrashNote: vi.fn(),
|
||||
onRestoreNote: vi.fn(),
|
||||
onArchiveNote: vi.fn(),
|
||||
onUnarchiveNote: vi.fn(),
|
||||
onCommitPush: vi.fn(),
|
||||
@@ -52,6 +54,7 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
|
||||
zoomLevel: 100,
|
||||
onSelect: vi.fn(),
|
||||
onCloseTab: vi.fn(),
|
||||
onOpenDailyNote: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -115,6 +118,44 @@ describe('useCommandRegistry', () => {
|
||||
expect(archiveCmd!.label).toBe('Archive Note')
|
||||
})
|
||||
|
||||
it('shows "Restore Note" when active note is trashed', () => {
|
||||
const entries = [makeEntry({ path: '/vault/note/test.md', trashed: true })]
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries })),
|
||||
)
|
||||
const trashCmd = result.current.find(c => c.id === 'trash-note')
|
||||
expect(trashCmd!.label).toBe('Restore Note')
|
||||
})
|
||||
|
||||
it('shows "Trash Note" when active note is not trashed', () => {
|
||||
const entries = [makeEntry({ path: '/vault/note/test.md', trashed: false })]
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries })),
|
||||
)
|
||||
const trashCmd = result.current.find(c => c.id === 'trash-note')
|
||||
expect(trashCmd!.label).toBe('Trash Note')
|
||||
})
|
||||
|
||||
it('calls onRestoreNote when trash command executes on trashed note', () => {
|
||||
const onRestoreNote = vi.fn()
|
||||
const entries = [makeEntry({ path: '/vault/note/test.md', trashed: true })]
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries, onRestoreNote })),
|
||||
)
|
||||
result.current.find(c => c.id === 'trash-note')!.execute()
|
||||
expect(onRestoreNote).toHaveBeenCalledWith('/vault/note/test.md')
|
||||
})
|
||||
|
||||
it('calls onTrashNote when trash command executes on non-trashed note', () => {
|
||||
const onTrashNote = vi.fn()
|
||||
const entries = [makeEntry({ path: '/vault/note/test.md', trashed: false })]
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries, onTrashNote })),
|
||||
)
|
||||
result.current.find(c => c.id === 'trash-note')!.execute()
|
||||
expect(onTrashNote).toHaveBeenCalledWith('/vault/note/test.md')
|
||||
})
|
||||
|
||||
it('disables commit when no modified files', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ modifiedCount: 0 })))
|
||||
expect(result.current.find(c => c.id === 'commit-push')!.enabled).toBe(false)
|
||||
@@ -176,6 +217,40 @@ describe('useCommandRegistry', () => {
|
||||
expect(result.current.find(c => c.id === 'zoom-in')!.label).toContain('120%')
|
||||
})
|
||||
|
||||
it('has toggle-ai-chat command with shortcut', () => {
|
||||
const onToggleAIChat = vi.fn()
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onToggleAIChat })))
|
||||
const cmd = result.current.find(c => c.id === 'toggle-ai-chat')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.shortcut).toBe('⌘I')
|
||||
expect(cmd!.group).toBe('View')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('calls onToggleAIChat when toggle-ai-chat executes', () => {
|
||||
const onToggleAIChat = vi.fn()
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onToggleAIChat })))
|
||||
result.current.find(c => c.id === 'toggle-ai-chat')!.execute()
|
||||
expect(onToggleAIChat).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('has open-daily-note command with shortcut', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
|
||||
const cmd = result.current.find(c => c.id === 'open-daily-note')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.label).toBe("Open Today's Note")
|
||||
expect(cmd!.shortcut).toBe('⌘J')
|
||||
expect(cmd!.group).toBe('Note')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('calls onOpenDailyNote when open-daily-note executes', () => {
|
||||
const onOpenDailyNote = vi.fn()
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onOpenDailyNote })))
|
||||
result.current.find(c => c.id === 'open-daily-note')!.execute()
|
||||
expect(onOpenDailyNote).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('type-aware commands', () => {
|
||||
it('generates "New [Type]" commands from vault entries', () => {
|
||||
const entries = [
|
||||
|
||||
@@ -26,16 +26,19 @@ interface CommandRegistryConfig {
|
||||
onOpenSettings: () => void
|
||||
onOpenVault?: () => void
|
||||
onTrashNote: (path: string) => void
|
||||
onRestoreNote: (path: string) => void
|
||||
onArchiveNote: (path: string) => void
|
||||
onUnarchiveNote: (path: string) => void
|
||||
onCommitPush: () => void
|
||||
onSetViewMode: (mode: ViewMode) => void
|
||||
onToggleInspector: () => void
|
||||
onToggleAIChat?: () => void
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onZoomReset: () => void
|
||||
zoomLevel: number
|
||||
onSelect: (sel: SidebarSelection) => void
|
||||
onOpenDailyNote: () => void
|
||||
onCloseTab: (path: string) => void
|
||||
onGoBack?: () => void
|
||||
onGoForward?: () => void
|
||||
@@ -126,10 +129,10 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
const {
|
||||
activeTabPath, entries, modifiedCount,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
|
||||
onTrashNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onOpenVault,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onToggleAIChat, onOpenVault,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect, onCloseTab,
|
||||
onSelect, onOpenDailyNote, onCloseTab,
|
||||
onGoBack, onGoForward, canGoBack, canGoForward,
|
||||
themes, activeThemeId, onSwitchTheme, onCreateTheme,
|
||||
} = config
|
||||
@@ -141,6 +144,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
[entries, activeTabPath, hasActiveNote],
|
||||
)
|
||||
const isArchived = activeEntry?.archived ?? false
|
||||
const isTrashed = activeEntry?.trashed ?? false
|
||||
|
||||
const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries])
|
||||
|
||||
@@ -158,9 +162,14 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
|
||||
// Note actions (contextual)
|
||||
{ id: 'create-note', label: 'Create New Note', group: 'Note', shortcut: '⌘N', keywords: ['new', 'add'], enabled: true, execute: onCreateNote },
|
||||
{ id: 'open-daily-note', label: "Open Today's Note", group: 'Note', shortcut: '⌘J', keywords: ['daily', 'journal', 'today'], enabled: true, execute: onOpenDailyNote },
|
||||
{ id: 'save-note', label: 'Save Note', group: 'Note', shortcut: '⌘S', keywords: ['write'], enabled: hasActiveNote, execute: onSave },
|
||||
{ id: 'close-tab', label: 'Close Tab', group: 'Note', shortcut: '⌘W', keywords: [], enabled: hasActiveNote, execute: () => { if (activeTabPath) onCloseTab(activeTabPath) } },
|
||||
{ id: 'trash-note', label: 'Trash Note', group: 'Note', shortcut: '⌘⌫', keywords: ['delete', 'remove'], enabled: hasActiveNote, execute: () => { if (activeTabPath) onTrashNote(activeTabPath) } },
|
||||
{
|
||||
id: 'trash-note', label: isTrashed ? 'Restore Note' : 'Trash Note', group: 'Note', shortcut: '⌘⌫',
|
||||
keywords: ['delete', 'remove', 'restore', 'trash'], enabled: hasActiveNote,
|
||||
execute: () => { if (activeTabPath) (isTrashed ? onRestoreNote : onTrashNote)(activeTabPath) },
|
||||
},
|
||||
{
|
||||
id: 'archive-note', label: isArchived ? 'Unarchive Note' : 'Archive Note', group: 'Note', shortcut: '⌘E',
|
||||
keywords: ['archive'], enabled: hasActiveNote,
|
||||
@@ -176,6 +185,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
|
||||
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
|
||||
{ id: 'toggle-inspector', label: 'Toggle Inspector', group: 'View', keywords: ['properties', 'panel', 'right'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'toggle-ai-chat', label: 'Toggle AI Chat', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
|
||||
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
|
||||
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
|
||||
@@ -193,12 +203,12 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
|
||||
return cmds
|
||||
}, [
|
||||
hasActiveNote, activeTabPath, isArchived, modifiedCount,
|
||||
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
|
||||
onTrashNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onOpenVault,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onToggleAIChat, onOpenVault,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect, onCloseTab,
|
||||
onSelect, onOpenDailyNote, onCloseTab,
|
||||
onGoBack, onGoForward, canGoBack, canGoForward,
|
||||
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenVault,
|
||||
])
|
||||
|
||||
@@ -26,6 +26,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
@@ -35,6 +36,9 @@ describe('useEntryActions', () => {
|
||||
const handleUpdateFrontmatter = vi.fn().mockResolvedValue(undefined)
|
||||
const handleDeleteProperty = vi.fn().mockResolvedValue(undefined)
|
||||
const setToastMessage = vi.fn()
|
||||
const createTypeEntry = vi.fn().mockImplementation((typeName: string) =>
|
||||
Promise.resolve(makeEntry({ isA: 'Type', title: typeName, path: `/vault/type/${typeName.toLowerCase()}.md` })),
|
||||
)
|
||||
|
||||
function setup(entries: VaultEntry[] = []) {
|
||||
return renderHook(() =>
|
||||
@@ -44,6 +48,7 @@ describe('useEntryActions', () => {
|
||||
handleUpdateFrontmatter,
|
||||
handleDeleteProperty,
|
||||
setToastMessage,
|
||||
createTypeEntry,
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -117,12 +122,12 @@ describe('useEntryActions', () => {
|
||||
})
|
||||
|
||||
describe('handleCustomizeType', () => {
|
||||
it('updates icon and color on the type entry', () => {
|
||||
it('updates icon and color on the type entry', async () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md' })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
act(() => {
|
||||
result.current.handleCustomizeType('Recipe', 'cooking-pot', 'green')
|
||||
await act(async () => {
|
||||
await result.current.handleCustomizeType('Recipe', 'cooking-pot', 'green')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'icon', 'cooking-pot')
|
||||
@@ -130,11 +135,66 @@ describe('useEntryActions', () => {
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { icon: 'cooking-pot', color: 'green' })
|
||||
})
|
||||
|
||||
it('auto-creates type entry when not found and applies customization', async () => {
|
||||
const { result } = setup([])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCustomizeType('Recipe', 'star', 'red')
|
||||
})
|
||||
|
||||
expect(createTypeEntry).toHaveBeenCalledWith('Recipe')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { icon: 'star', color: 'red' })
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'icon', 'star')
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'color', 'red')
|
||||
})
|
||||
|
||||
it('serializes frontmatter writes (icon before color)', async () => {
|
||||
const callOrder: string[] = []
|
||||
handleUpdateFrontmatter.mockImplementation((_path: string, key: string) => {
|
||||
callOrder.push(key)
|
||||
return Promise.resolve()
|
||||
})
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCustomizeType('Project', 'wrench', 'blue')
|
||||
})
|
||||
|
||||
expect(callOrder).toEqual(['icon', 'color'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleUpdateTypeTemplate', () => {
|
||||
it('updates template on the type entry', () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
act(() => {
|
||||
result.current.handleUpdateTypeTemplate('Project', '## Objective\n\n## Notes')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'template', '## Objective\n\n## Notes')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { template: '## Objective\n\n## Notes' })
|
||||
})
|
||||
|
||||
it('sets template to null when empty string', () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
act(() => {
|
||||
result.current.handleUpdateTypeTemplate('Project', '')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'template', '')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { template: null })
|
||||
})
|
||||
|
||||
it('does nothing when type entry not found', () => {
|
||||
const { result } = setup([])
|
||||
|
||||
act(() => {
|
||||
result.current.handleCustomizeType('NonExistent', 'star', 'red')
|
||||
result.current.handleUpdateTypeTemplate('NonExistent', '## Template')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
|
||||
|
||||
@@ -7,6 +7,7 @@ interface EntryActionsConfig {
|
||||
handleUpdateFrontmatter: (path: string, key: string, value: string | number | boolean | string[]) => Promise<void>
|
||||
handleDeleteProperty: (path: string, key: string) => Promise<void>
|
||||
setToastMessage: (msg: string | null) => void
|
||||
createTypeEntry: (typeName: string) => Promise<VaultEntry>
|
||||
}
|
||||
|
||||
function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | undefined {
|
||||
@@ -14,7 +15,7 @@ function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | un
|
||||
}
|
||||
|
||||
export function useEntryActions({
|
||||
entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage,
|
||||
entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, createTypeEntry,
|
||||
}: EntryActionsConfig) {
|
||||
const handleTrashNote = useCallback(async (path: string) => {
|
||||
const now = new Date().toISOString().slice(0, 10)
|
||||
@@ -43,13 +44,13 @@ export function useEntryActions({
|
||||
setToastMessage('Note unarchived')
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage])
|
||||
|
||||
const handleCustomizeType = useCallback((typeName: string, icon: string, color: string) => {
|
||||
const typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) return
|
||||
handleUpdateFrontmatter(typeEntry.path, 'icon', icon)
|
||||
handleUpdateFrontmatter(typeEntry.path, 'color', color)
|
||||
const handleCustomizeType = useCallback(async (typeName: string, icon: string, color: string) => {
|
||||
let typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
|
||||
updateEntry(typeEntry.path, { icon, color })
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry])
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'icon', icon)
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'color', color)
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry])
|
||||
|
||||
const handleReorderSections = useCallback((orderedTypes: { typeName: string; order: number }[]) => {
|
||||
for (const { typeName, order } of orderedTypes) {
|
||||
@@ -60,5 +61,12 @@ export function useEntryActions({
|
||||
}
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry])
|
||||
|
||||
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections }
|
||||
const handleUpdateTypeTemplate = useCallback((typeName: string, template: string) => {
|
||||
const typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) return
|
||||
handleUpdateFrontmatter(typeEntry.path, 'template', template)
|
||||
updateEntry(typeEntry.path, { template: template || null })
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry])
|
||||
|
||||
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate }
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ function makeHandlers(): MenuEventHandlers {
|
||||
return {
|
||||
onSetViewMode: vi.fn(),
|
||||
onCreateNote: vi.fn(),
|
||||
onOpenDailyNote: vi.fn(),
|
||||
onQuickOpen: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
@@ -49,6 +50,12 @@ describe('dispatchMenuEvent', () => {
|
||||
expect(h.onCreateNote).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('file-daily-note triggers open daily note', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('file-daily-note', h)
|
||||
expect(h.onOpenDailyNote).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('file-quick-open triggers quick open', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('file-quick-open', h)
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ViewMode } from './useViewMode'
|
||||
export interface MenuEventHandlers {
|
||||
onSetViewMode: (mode: ViewMode) => void
|
||||
onCreateNote: () => void
|
||||
onOpenDailyNote: () => void
|
||||
onQuickOpen: () => void
|
||||
onSave: () => void
|
||||
onOpenSettings: () => void
|
||||
@@ -29,10 +30,11 @@ const VIEW_MODE_MAP: Record<string, ViewMode> = {
|
||||
'view-all': 'all',
|
||||
}
|
||||
|
||||
type SimpleHandler = 'onCreateNote' | 'onQuickOpen' | 'onSave' | 'onOpenSettings' | 'onToggleInspector' | 'onCommandPalette' | 'onZoomIn' | 'onZoomOut' | 'onZoomReset' | 'onSearch'
|
||||
type SimpleHandler = 'onCreateNote' | 'onOpenDailyNote' | 'onQuickOpen' | 'onSave' | 'onOpenSettings' | 'onToggleInspector' | 'onCommandPalette' | 'onZoomIn' | 'onZoomOut' | 'onZoomReset' | 'onSearch'
|
||||
|
||||
const SIMPLE_EVENT_MAP: Record<string, SimpleHandler> = {
|
||||
'file-new-note': 'onCreateNote',
|
||||
'file-daily-note': 'onOpenDailyNote',
|
||||
'file-quick-open': 'onQuickOpen',
|
||||
'file-save': 'onSave',
|
||||
'app-settings': 'onOpenSettings',
|
||||
|
||||
@@ -115,6 +115,48 @@ describe('useNavigationHistory', () => {
|
||||
expect(result.current.canGoForward).toBe(false)
|
||||
})
|
||||
|
||||
it('goBack without predicate returns closed-tab paths (replace scenario)', () => {
|
||||
const { result } = renderHook(() => useNavigationHistory())
|
||||
// Simulate: open A, then B replaces A, then C replaces B
|
||||
act(() => { result.current.push('/a'); result.current.push('/b'); result.current.push('/c') })
|
||||
|
||||
// Without a predicate, goBack returns /b even though its tab was replaced
|
||||
let target: string | null = null
|
||||
act(() => { target = result.current.goBack() })
|
||||
expect(target).toBe('/b')
|
||||
|
||||
act(() => { target = result.current.goBack() })
|
||||
expect(target).toBe('/a')
|
||||
})
|
||||
|
||||
it('goBack with entry-exists predicate skips deleted notes but returns replaced tabs', () => {
|
||||
const { result } = renderHook(() => useNavigationHistory())
|
||||
act(() => { result.current.push('/a'); result.current.push('/deleted'); result.current.push('/c') })
|
||||
|
||||
// Simulate: /deleted was removed from vault, but /a still exists
|
||||
const vaultPaths = new Set(['/a', '/c'])
|
||||
const isEntryExists = (p: string) => vaultPaths.has(p)
|
||||
|
||||
let target: string | null = null
|
||||
act(() => { target = result.current.goBack(isEntryExists) })
|
||||
// Should skip /deleted and return /a
|
||||
expect(target).toBe('/a')
|
||||
})
|
||||
|
||||
it('goForward with entry-exists predicate skips deleted notes', () => {
|
||||
const { result } = renderHook(() => useNavigationHistory())
|
||||
act(() => { result.current.push('/a'); result.current.push('/deleted'); result.current.push('/c') })
|
||||
act(() => { result.current.goBack(); result.current.goBack() })
|
||||
|
||||
const vaultPaths = new Set(['/a', '/c'])
|
||||
const isEntryExists = (p: string) => vaultPaths.has(p)
|
||||
|
||||
let target: string | null = null
|
||||
act(() => { target = result.current.goForward(isEntryExists) })
|
||||
// Should skip /deleted and return /c
|
||||
expect(target).toBe('/c')
|
||||
})
|
||||
|
||||
it('handles long navigation chain', () => {
|
||||
const { result } = renderHook(() => useNavigationHistory())
|
||||
act(() => {
|
||||
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
resolveNewNote,
|
||||
resolveNewType,
|
||||
frontmatterToEntryPatch,
|
||||
todayDateString,
|
||||
buildDailyNoteContent,
|
||||
resolveDailyNote,
|
||||
findDailyNote,
|
||||
useNoteActions,
|
||||
DEFAULT_TEMPLATES,
|
||||
resolveTemplate,
|
||||
} from './useNoteActions'
|
||||
import type { NoteActionsConfig } from './useNoteActions'
|
||||
|
||||
@@ -53,6 +59,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
template: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -188,6 +195,47 @@ describe('buildNoteContent', () => {
|
||||
const content = buildNoteContent('AI', 'Topic', null)
|
||||
expect(content).toBe('---\ntitle: AI\ntype: Topic\n---\n\n# AI\n\n')
|
||||
})
|
||||
|
||||
it('includes template body when provided', () => {
|
||||
const content = buildNoteContent('My Project', 'Project', 'Active', '## Objective\n\n## Notes\n\n')
|
||||
expect(content).toContain('# My Project')
|
||||
expect(content).toContain('## Objective')
|
||||
expect(content).toContain('## Notes')
|
||||
})
|
||||
|
||||
it('ignores null template', () => {
|
||||
const content = buildNoteContent('My Note', 'Note', 'Active', null)
|
||||
expect(content).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n\n# My Note\n\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTemplate', () => {
|
||||
it('returns template from type entry when set', () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', template: '## Ingredients\n\n## Steps\n\n' })
|
||||
expect(resolveTemplate([typeEntry], 'Recipe')).toBe('## Ingredients\n\n## Steps\n\n')
|
||||
})
|
||||
|
||||
it('falls back to DEFAULT_TEMPLATES for built-in types', () => {
|
||||
expect(resolveTemplate([], 'Project')).toBe(DEFAULT_TEMPLATES.Project)
|
||||
})
|
||||
|
||||
it('returns null when no template and no default', () => {
|
||||
expect(resolveTemplate([], 'CustomType')).toBeNull()
|
||||
})
|
||||
|
||||
it('type entry template overrides default', () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', template: '## Custom\n\n' })
|
||||
expect(resolveTemplate([typeEntry], 'Project')).toBe('## Custom\n\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DEFAULT_TEMPLATES', () => {
|
||||
it('has templates for Project, Person, Responsibility, Experiment', () => {
|
||||
expect(DEFAULT_TEMPLATES.Project).toBeDefined()
|
||||
expect(DEFAULT_TEMPLATES.Person).toBeDefined()
|
||||
expect(DEFAULT_TEMPLATES.Responsibility).toBeDefined()
|
||||
expect(DEFAULT_TEMPLATES.Experiment).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewNote', () => {
|
||||
@@ -240,6 +288,7 @@ describe('frontmatterToEntryPatch', () => {
|
||||
['archived', true, { archived: true }],
|
||||
['trashed', true, { trashed: true }],
|
||||
['order', 5, { order: 5 }],
|
||||
['template', '## Heading\n\n', { template: '## Heading\n\n' }],
|
||||
] as [string, unknown, Partial<VaultEntry>][])(
|
||||
'maps %s update to correct entry field',
|
||||
(key, value, expected) => {
|
||||
@@ -270,6 +319,7 @@ describe('frontmatterToEntryPatch', () => {
|
||||
['aliases', { aliases: [] }],
|
||||
['archived', { archived: false }],
|
||||
['order', { order: null }],
|
||||
['template', { template: null }],
|
||||
] as [string, Partial<VaultEntry>][])(
|
||||
'maps delete of %s to null/default',
|
||||
(key, expected) => {
|
||||
@@ -282,6 +332,75 @@ describe('frontmatterToEntryPatch', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('todayDateString', () => {
|
||||
it('returns date in YYYY-MM-DD format', () => {
|
||||
const result = todayDateString()
|
||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildDailyNoteContent', () => {
|
||||
it('generates frontmatter with Journal type and date', () => {
|
||||
const content = buildDailyNoteContent('2026-03-02')
|
||||
expect(content).toContain('type: Journal')
|
||||
expect(content).toContain('date: 2026-03-02')
|
||||
expect(content).toContain('title: 2026-03-02')
|
||||
})
|
||||
|
||||
it('includes Intentions and Reflections sections', () => {
|
||||
const content = buildDailyNoteContent('2026-03-02')
|
||||
expect(content).toContain('## Intentions')
|
||||
expect(content).toContain('## Reflections')
|
||||
})
|
||||
|
||||
it('includes H1 heading with the date', () => {
|
||||
const content = buildDailyNoteContent('2026-03-02')
|
||||
expect(content).toContain('# 2026-03-02')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveDailyNote', () => {
|
||||
it('creates entry in journal folder with date as filename', () => {
|
||||
const { entry } = resolveDailyNote('2026-03-02')
|
||||
expect(entry.path).toBe('/Users/luca/Laputa/journal/2026-03-02.md')
|
||||
expect(entry.filename).toBe('2026-03-02.md')
|
||||
expect(entry.title).toBe('2026-03-02')
|
||||
expect(entry.isA).toBe('Journal')
|
||||
expect(entry.status).toBeNull()
|
||||
})
|
||||
|
||||
it('returns content with daily note template', () => {
|
||||
const { content } = resolveDailyNote('2026-03-02')
|
||||
expect(content).toContain('type: Journal')
|
||||
expect(content).toContain('## Intentions')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findDailyNote', () => {
|
||||
it('finds entry by journal path suffix', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/Users/luca/Laputa/journal/2026-03-02.md' }),
|
||||
makeEntry({ path: '/Users/luca/Laputa/note/other.md' }),
|
||||
]
|
||||
const found = findDailyNote(entries, '2026-03-02')
|
||||
expect(found).toBeDefined()
|
||||
expect(found!.path).toBe('/Users/luca/Laputa/journal/2026-03-02.md')
|
||||
})
|
||||
|
||||
it('returns undefined when no matching entry exists', () => {
|
||||
const entries = [makeEntry({ path: '/Users/luca/Laputa/note/other.md' })]
|
||||
expect(findDailyNote(entries, '2026-03-02')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('works with different vault paths', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/other/vault/journal/2026-03-02.md' }),
|
||||
]
|
||||
const found = findDailyNote(entries, '2026-03-02')
|
||||
expect(found).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useNoteActions hook', () => {
|
||||
const addEntry = vi.fn()
|
||||
const removeEntry = vi.fn()
|
||||
@@ -450,6 +569,43 @@ describe('useNoteActions hook', () => {
|
||||
expect(createdEntry.isA).toBe('Project')
|
||||
})
|
||||
|
||||
it('handleCreateNote uses default template for Project type', () => {
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCreateNote('My Project', 'Project')
|
||||
})
|
||||
|
||||
const [, createdContent] = addEntry.mock.calls[0]
|
||||
expect(createdContent).toContain('## Objective')
|
||||
expect(createdContent).toContain('## Key Results')
|
||||
})
|
||||
|
||||
it('handleCreateNote uses custom template from type entry', () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', template: '## Ingredients\n\n## Steps\n\n' })
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig([typeEntry])))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCreateNote('Pasta', 'Recipe')
|
||||
})
|
||||
|
||||
const [, createdContent] = addEntry.mock.calls[0]
|
||||
expect(createdContent).toContain('## Ingredients')
|
||||
expect(createdContent).toContain('## Steps')
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate uses template for typed notes', () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', template: '## Custom Template\n\n' })
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig([typeEntry])))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCreateNoteImmediate('Project')
|
||||
})
|
||||
|
||||
const [, createdContent] = addEntry.mock.calls[0]
|
||||
expect(createdContent).toContain('## Custom Template')
|
||||
})
|
||||
|
||||
it('handleUpdateFrontmatter does not call updateEntry for unknown keys', async () => {
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
@@ -461,6 +617,35 @@ describe('useNoteActions hook', () => {
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Property updated')
|
||||
})
|
||||
|
||||
it('handleOpenDailyNote creates a new daily note when none exists', () => {
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpenDailyNote()
|
||||
})
|
||||
|
||||
expect(addEntry).toHaveBeenCalledTimes(1)
|
||||
const [createdEntry] = addEntry.mock.calls[0]
|
||||
expect(createdEntry.isA).toBe('Journal')
|
||||
expect(createdEntry.path).toContain('journal/')
|
||||
expect(createdEntry.path).toMatch(/journal\/\d{4}-\d{2}-\d{2}\.md$/)
|
||||
})
|
||||
|
||||
it('handleOpenDailyNote opens existing daily note instead of creating', async () => {
|
||||
const today = todayDateString()
|
||||
const existing = makeEntry({ path: `/Users/luca/Laputa/journal/${today}.md`, title: today })
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig([existing])))
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleOpenDailyNote()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
})
|
||||
|
||||
// Should open existing note, not create a new one
|
||||
expect(addEntry).not.toHaveBeenCalled()
|
||||
expect(result.current.activeTabPath).toBe(`/Users/luca/Laputa/journal/${today}.md`)
|
||||
})
|
||||
|
||||
describe('pending save lifecycle', () => {
|
||||
it('createAndPersist calls addPendingSave on start (non-Tauri)', async () => {
|
||||
const addPendingSave = vi.fn()
|
||||
@@ -535,6 +720,38 @@ describe('useNoteActions hook', () => {
|
||||
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md'))
|
||||
expect(markContentPending).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md'), expect.stringContaining('Untitled note'))
|
||||
})
|
||||
|
||||
it('calls onNewNotePersisted after successful disk write (non-Tauri)', async () => {
|
||||
const onNewNotePersisted = vi.fn()
|
||||
const config = makeConfig()
|
||||
config.onNewNotePersisted = onNewNotePersisted
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleCreateNote('Persist Callback', 'Note')
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
})
|
||||
|
||||
expect(onNewNotePersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not call onNewNotePersisted when disk write fails (Tauri)', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
vi.mocked(invoke).mockRejectedValueOnce(new Error('disk full'))
|
||||
const onNewNotePersisted = vi.fn()
|
||||
const config = makeConfig()
|
||||
config.onNewNotePersisted = onNewNotePersisted
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleCreateNote('Fail Persist', 'Note')
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
})
|
||||
|
||||
expect(onNewNotePersisted).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('optimistic error recovery (Tauri mode)', () => {
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface NoteActionsConfig {
|
||||
unsavedPaths?: Set<string>
|
||||
/** Called when an unsaved note is created so the save system can buffer its initial content. */
|
||||
markContentPending?: (path: string, content: string) => void
|
||||
/** Called after a new note is persisted to disk (e.g. to refresh git status for Changes view). */
|
||||
onNewNotePersisted?: () => void
|
||||
}
|
||||
|
||||
async function performRename(
|
||||
@@ -70,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: [],
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,15 +124,31 @@ const TYPE_FOLDER_MAP: Record<string, string> = {
|
||||
Note: 'note', Project: 'project', Experiment: 'experiment',
|
||||
Responsibility: 'responsibility', Procedure: 'procedure',
|
||||
Person: 'person', Event: 'event', Topic: 'topic',
|
||||
Journal: 'journal',
|
||||
}
|
||||
|
||||
const NO_STATUS_TYPES = new Set(['Topic', 'Person'])
|
||||
const NO_STATUS_TYPES = new Set(['Topic', 'Person', 'Journal'])
|
||||
|
||||
/** Default templates for built-in types. Used when the type entry has no custom template. */
|
||||
export const DEFAULT_TEMPLATES: Record<string, string> = {
|
||||
Project: '## Objective\n\n\n\n## Key Results\n\n\n\n## Notes\n\n',
|
||||
Person: '## Role\n\n\n\n## Contact\n\n\n\n## Notes\n\n',
|
||||
Responsibility: '## Description\n\n\n\n## Key Activities\n\n\n\n## Notes\n\n',
|
||||
Experiment: '## Hypothesis\n\n\n\n## Method\n\n\n\n## Results\n\n\n\n## Conclusion\n\n',
|
||||
}
|
||||
|
||||
/** Look up the template for a given type from the type entry or defaults. */
|
||||
export function resolveTemplate(entries: VaultEntry[], typeName: string): string | null {
|
||||
const typeEntry = entries.find(e => e.isA === 'Type' && e.title === typeName)
|
||||
return typeEntry?.template ?? DEFAULT_TEMPLATES[typeName] ?? null
|
||||
}
|
||||
|
||||
const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
|
||||
type: { isA: null }, is_a: { isA: null }, status: { status: null }, color: { color: null },
|
||||
icon: { icon: null }, owner: { owner: null }, cadence: { cadence: null },
|
||||
aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] },
|
||||
archived: { archived: false }, trashed: { trashed: false }, order: { order: null },
|
||||
template: { template: null },
|
||||
}
|
||||
|
||||
/** Map a frontmatter key+value to the corresponding VaultEntry field(s). */
|
||||
@@ -147,6 +165,7 @@ export function frontmatterToEntryPatch(
|
||||
aliases: { aliases: arr }, belongs_to: { belongsTo: arr }, related_to: { relatedTo: arr },
|
||||
archived: { archived: Boolean(value) }, trashed: { trashed: Boolean(value) },
|
||||
order: { order: typeof value === 'number' ? value : null },
|
||||
template: { template: str },
|
||||
}
|
||||
return updates[k] ?? {}
|
||||
}
|
||||
@@ -156,19 +175,20 @@ function addEntryWithMock(entry: VaultEntry, content: string, addEntry: (e: Vaul
|
||||
addEntry(entry, content)
|
||||
}
|
||||
|
||||
export function buildNoteContent(title: string, type: string, status: string | null): string {
|
||||
export function buildNoteContent(title: string, type: string, status: string | null, template?: string | null): string {
|
||||
const lines = ['---', `title: ${title}`, `type: ${type}`]
|
||||
if (status) lines.push(`status: ${status}`)
|
||||
lines.push('---')
|
||||
return `${lines.join('\n')}\n\n# ${title}\n\n`
|
||||
const body = template ? `\n${template}` : '\n'
|
||||
return `${lines.join('\n')}\n\n# ${title}\n${body}`
|
||||
}
|
||||
|
||||
export function resolveNewNote(title: string, type: string): { entry: VaultEntry; content: string } {
|
||||
export function resolveNewNote(title: string, type: string, template?: string | null): { entry: VaultEntry; content: string } {
|
||||
const folder = TYPE_FOLDER_MAP[type] || slugify(type)
|
||||
const slug = slugify(title)
|
||||
const status = NO_STATUS_TYPES.has(type) ? null : 'Active'
|
||||
const entry = buildNewEntry({ path: `/Users/luca/Laputa/${folder}/${slug}.md`, slug, title, type, status })
|
||||
return { entry, content: buildNoteContent(title, type, status) }
|
||||
return { entry, content: buildNoteContent(title, type, status, template) }
|
||||
}
|
||||
|
||||
export function resolveNewType(typeName: string): { entry: VaultEntry; content: string } {
|
||||
@@ -177,6 +197,36 @@ export function resolveNewType(typeName: string): { entry: VaultEntry; content:
|
||||
return { entry, content: `---\ntype: Type\n---\n\n# ${typeName}\n\n` }
|
||||
}
|
||||
|
||||
export function todayDateString(): string {
|
||||
return new Date().toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
export function buildDailyNoteContent(date: string): string {
|
||||
const lines = ['---', `title: ${date}`, 'type: Journal', `date: ${date}`, '---']
|
||||
return `${lines.join('\n')}\n\n# ${date}\n\n## Intentions\n\n\n\n## Reflections\n\n`
|
||||
}
|
||||
|
||||
export function resolveDailyNote(date: string): { entry: VaultEntry; content: string } {
|
||||
const entry = buildNewEntry({ path: `/Users/luca/Laputa/journal/${date}.md`, slug: date, title: date, type: 'Journal', status: null })
|
||||
return { entry, content: buildDailyNoteContent(date) }
|
||||
}
|
||||
|
||||
export function findDailyNote(entries: VaultEntry[], date: string): VaultEntry | undefined {
|
||||
const suffix = `journal/${date}.md`
|
||||
return entries.find(e => e.path.endsWith(suffix))
|
||||
}
|
||||
|
||||
type PersistFn = (resolved: { entry: VaultEntry; content: string }) => void
|
||||
|
||||
/** Open today's daily note: navigate to it if it exists, or create + persist a new one. */
|
||||
function openDailyNote(entries: VaultEntry[], selectNote: (e: VaultEntry) => void, persist: PersistFn): void {
|
||||
const date = todayDateString()
|
||||
const existing = findDailyNote(entries, date)
|
||||
if (existing) selectNote(existing)
|
||||
else persist(resolveDailyNote(date))
|
||||
signalFocusEditor()
|
||||
}
|
||||
|
||||
function findWikilinkTarget(entries: VaultEntry[], target: string): VaultEntry | undefined {
|
||||
const targetLower = target.toLowerCase()
|
||||
const targetAsWords = target.split('/').pop()?.replace(/-/g, ' ').toLowerCase() ?? targetLower
|
||||
@@ -199,13 +249,14 @@ interface PersistCallbacks {
|
||||
onFail: (p: string) => void
|
||||
onStart?: (p: string) => void
|
||||
onEnd?: (p: string) => void
|
||||
onPersisted?: () => void
|
||||
}
|
||||
|
||||
/** Persist to disk; track pending state via onStart/onEnd; revert on failure. */
|
||||
function persistOptimistic(path: string, content: string, cbs: PersistCallbacks): void {
|
||||
cbs.onStart?.(path)
|
||||
persistNewNote(path, content)
|
||||
.then(() => cbs.onEnd?.(path))
|
||||
.then(() => { cbs.onEnd?.(path); cbs.onPersisted?.() })
|
||||
.catch(() => { cbs.onEnd?.(path); cbs.onFail(path) })
|
||||
}
|
||||
|
||||
@@ -296,19 +347,27 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
onFail: revertOptimisticNote,
|
||||
onStart: addPendingSave,
|
||||
onEnd: removePendingSave,
|
||||
onPersisted: config.onNewNotePersisted,
|
||||
}
|
||||
|
||||
const pendingNamesRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const persistNew: PersistFn = useCallback(
|
||||
(resolved) => createAndPersist(resolved, addEntry, openTabWithContent, persistCbs),
|
||||
[openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave], // eslint-disable-line react-hooks/exhaustive-deps -- persistCbs is stable when deps are
|
||||
)
|
||||
|
||||
const handleCreateNote = useCallback((title: string, type: string) => {
|
||||
createAndPersist(resolveNewNote(title, type), addEntry, openTabWithContent, persistCbs)
|
||||
}, [openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave]) // eslint-disable-line react-hooks/exhaustive-deps -- persistCbs is stable when deps are
|
||||
const template = resolveTemplate(entries, type)
|
||||
persistNew(resolveNewNote(title, type, template))
|
||||
}, [entries, persistNew])
|
||||
|
||||
const handleCreateNoteImmediate = useCallback((type?: string) => {
|
||||
const noteType = type || 'Note'
|
||||
const title = generateUntitledName(entries, noteType, pendingNamesRef.current)
|
||||
pendingNamesRef.current.add(title)
|
||||
const resolved = resolveNewNote(title, noteType)
|
||||
const template = resolveTemplate(entries, noteType)
|
||||
const resolved = resolveNewNote(title, noteType, template)
|
||||
openTabWithContent(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
config.trackUnsaved?.(resolved.entry.path)
|
||||
@@ -319,19 +378,24 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
|
||||
/** Close tab and discard entry+unsaved state if the note was never persisted. */
|
||||
const handleCloseTabWithCleanup = useCallback((path: string) => {
|
||||
if (unsavedPathsRef.current?.has(path)) {
|
||||
removeEntry(path)
|
||||
config.clearUnsaved?.(path)
|
||||
}
|
||||
if (unsavedPathsRef.current?.has(path)) { removeEntry(path); config.clearUnsaved?.(path) }
|
||||
handleCloseTab(path)
|
||||
}, [handleCloseTab, removeEntry, config.clearUnsaved]) // eslint-disable-line react-hooks/exhaustive-deps -- ref access is stable
|
||||
|
||||
// Keep handleCloseTabRef in sync so Cmd+W and menu events also clean up unsaved notes.
|
||||
useEffect(() => { handleCloseTabRef.current = handleCloseTabWithCleanup })
|
||||
|
||||
const handleCreateType = useCallback((typeName: string) => {
|
||||
createAndPersist(resolveNewType(typeName), addEntry, openTabWithContent, persistCbs)
|
||||
}, [openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave]) // eslint-disable-line react-hooks/exhaustive-deps -- persistCbs is stable when deps are
|
||||
const handleOpenDailyNote = useCallback(() => openDailyNote(entries, handleSelectNote, persistNew), [entries, handleSelectNote, persistNew])
|
||||
|
||||
const handleCreateType = useCallback((typeName: string) => persistNew(resolveNewType(typeName)), [persistNew])
|
||||
|
||||
/** Create a Type entry file silently (no tab opened). Adds to state and persists to disk. */
|
||||
const createTypeEntrySilent = useCallback(async (typeName: string): Promise<VaultEntry> => {
|
||||
const resolved = resolveNewType(typeName)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
await persistNewNote(resolved.entry.path, resolved.content)
|
||||
return resolved.entry
|
||||
}, [addEntry])
|
||||
|
||||
const fmCallbacks = { updateTab: updateTabContent, updateEntry, toast: setToastMessage }
|
||||
|
||||
@@ -368,7 +432,9 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
handleNavigateWikilink,
|
||||
handleCreateNote,
|
||||
handleCreateNoteImmediate,
|
||||
handleOpenDailyNote,
|
||||
handleCreateType,
|
||||
createTypeEntrySilent,
|
||||
handleUpdateFrontmatter: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
|
||||
handleDeleteProperty: useCallback((path: string, key: string) => runFrontmatterOp('delete', path, key), [runFrontmatterOp]),
|
||||
handleAddProperty: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
|
||||
|
||||
@@ -22,6 +22,7 @@ function makeEntry(path: string, title: string): VaultEntry {
|
||||
fileSize: 100,
|
||||
color: null,
|
||||
icon: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
relationships: {},
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -37,7 +37,22 @@ vi.mock('../mock-tauri', () => ({
|
||||
}))
|
||||
|
||||
// Must import after mocks
|
||||
const { useThemeManager } = await import('./useThemeManager')
|
||||
const { useThemeManager, isColorDark } = await import('./useThemeManager')
|
||||
|
||||
describe('isColorDark', () => {
|
||||
it('identifies dark colors', () => {
|
||||
expect(isColorDark('#000000')).toBe(true)
|
||||
expect(isColorDark('#0F0F23')).toBe(true)
|
||||
expect(isColorDark('#1a1a2e')).toBe(true)
|
||||
expect(isColorDark('#0f0f1a')).toBe(true)
|
||||
})
|
||||
|
||||
it('identifies light colors', () => {
|
||||
expect(isColorDark('#FFFFFF')).toBe(false)
|
||||
expect(isColorDark('#F7F6F3')).toBe(false)
|
||||
expect(isColorDark('#E0E0E0')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useThemeManager', () => {
|
||||
beforeEach(() => {
|
||||
@@ -228,6 +243,101 @@ describe('useThemeManager', () => {
|
||||
expect(newId).toBe('')
|
||||
})
|
||||
|
||||
it('isDark is false for light theme', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeTheme?.id).toBe('default')
|
||||
})
|
||||
expect(result.current.isDark).toBe(false)
|
||||
})
|
||||
|
||||
it('isDark is true for dark theme', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
expect(result.current.themes).toHaveLength(2)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchTheme('dark')
|
||||
})
|
||||
|
||||
expect(result.current.isDark).toBe(true)
|
||||
})
|
||||
|
||||
it('isDark is false when no active theme', async () => {
|
||||
const { result } = renderHook(() => useThemeManager(null))
|
||||
expect(result.current.isDark).toBe(false)
|
||||
})
|
||||
|
||||
it('derives app-specific CSS variables from theme colors', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeTheme).not.toBeNull()
|
||||
})
|
||||
|
||||
const root = document.documentElement
|
||||
expect(root.style.getPropertyValue('--bg-primary')).toBe('#FFFFFF')
|
||||
expect(root.style.getPropertyValue('--text-primary')).toBe('#1A1A2E')
|
||||
expect(root.style.getPropertyValue('--text-heading')).toBe('#1A1A2E')
|
||||
expect(root.style.getPropertyValue('--border-primary')).toBe('#E2E8F0')
|
||||
})
|
||||
|
||||
it('sets color-scheme and data-theme-mode for dark theme', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
expect(result.current.themes).toHaveLength(2)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchTheme('dark')
|
||||
})
|
||||
|
||||
const root = document.documentElement
|
||||
await waitFor(() => {
|
||||
expect(root.style.getPropertyValue('color-scheme')).toBe('dark')
|
||||
})
|
||||
expect(root.dataset.themeMode).toBe('dark')
|
||||
expect(root.style.getPropertyValue('--bg-primary')).toBe('#0F0F23')
|
||||
expect(root.style.getPropertyValue('--text-primary')).toBe('#E2E8F0')
|
||||
})
|
||||
|
||||
it('sets color-scheme to light for light theme', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeTheme).not.toBeNull()
|
||||
})
|
||||
|
||||
const root = document.documentElement
|
||||
expect(root.style.getPropertyValue('color-scheme')).toBe('light')
|
||||
expect(root.dataset.themeMode).toBe('light')
|
||||
})
|
||||
|
||||
it('updates derived variables when switching between themes', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
expect(result.current.themes).toHaveLength(2)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchTheme('dark')
|
||||
})
|
||||
|
||||
const root = document.documentElement
|
||||
await waitFor(() => {
|
||||
expect(root.style.getPropertyValue('--bg-primary')).toBe('#0F0F23')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchTheme('default')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(root.style.getPropertyValue('--bg-primary')).toBe('#FFFFFF')
|
||||
})
|
||||
expect(root.style.getPropertyValue('color-scheme')).toBe('light')
|
||||
expect(root.dataset.themeMode).toBe('light')
|
||||
})
|
||||
|
||||
it('reloadThemes re-fetches theme list', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -7,6 +7,118 @@ function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T
|
||||
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
|
||||
}
|
||||
|
||||
// --- Color utilities for theme variable derivation ---
|
||||
|
||||
function parseHex(hex: string): [number, number, number] {
|
||||
const h = hex.replace('#', '')
|
||||
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]
|
||||
}
|
||||
|
||||
function toHex(r: number, g: number, b: number): string {
|
||||
return '#' + [r, g, b].map(c => Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
/** Blend two hex colors. ratio=0 → color1, ratio=1 → color2. */
|
||||
function mixColors(hex1: string, hex2: string, ratio: number): string {
|
||||
const [r1, g1, b1] = parseHex(hex1)
|
||||
const [r2, g2, b2] = parseHex(hex2)
|
||||
return toHex(r1 + (r2 - r1) * ratio, g1 + (g2 - g1) * ratio, b1 + (b2 - b1) * ratio)
|
||||
}
|
||||
|
||||
/** Check if a hex color is perceptually dark (luminance < 0.5). */
|
||||
export function isColorDark(hex: string): boolean {
|
||||
const [r, g, b] = parseHex(hex)
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5
|
||||
}
|
||||
|
||||
// Variables derived from theme core colors (not present in theme.colors directly)
|
||||
const DERIVED_VAR_NAMES = [
|
||||
'bg-primary', 'bg-sidebar', 'bg-card', 'bg-hover', 'bg-hover-subtle', 'bg-selected',
|
||||
'bg-input', 'bg-button', 'bg-dialog',
|
||||
'text-primary', 'text-heading', 'text-secondary', 'text-tertiary', 'text-muted', 'text-faint',
|
||||
'border-primary', 'border-subtle', 'border-input', 'border-dialog',
|
||||
'link-color', 'link-hover',
|
||||
// shadcn variables that may not be in the theme
|
||||
'card', 'card-foreground', 'popover', 'popover-foreground',
|
||||
'secondary', 'secondary-foreground', 'muted-foreground',
|
||||
'accent', 'accent-foreground', 'input', 'ring',
|
||||
'sidebar-foreground', 'sidebar-primary', 'sidebar-primary-foreground',
|
||||
'sidebar-accent', 'sidebar-accent-foreground', 'sidebar-border', 'sidebar-ring',
|
||||
]
|
||||
|
||||
/** Derive app-specific and missing shadcn CSS variables from core theme colors. */
|
||||
function deriveThemeVariables(root: HTMLElement, colors: Record<string, string>): void {
|
||||
const bg = colors.background
|
||||
const fg = colors.foreground
|
||||
if (!bg || !fg) return
|
||||
|
||||
const isDark = isColorDark(bg)
|
||||
root.style.setProperty('color-scheme', isDark ? 'dark' : 'light')
|
||||
root.dataset.themeMode = isDark ? 'dark' : 'light'
|
||||
|
||||
const primary = colors.primary ?? (isDark ? '#5C9CFF' : '#155DFF')
|
||||
const border = colors.border ?? mixColors(bg, fg, isDark ? 0.15 : 0.08)
|
||||
const muted = colors.muted ?? mixColors(bg, fg, isDark ? 0.08 : 0.05)
|
||||
const sidebarBg = colors['sidebar-background'] ?? mixColors(bg, fg, 0.04)
|
||||
|
||||
// App-specific variables
|
||||
root.style.setProperty('--bg-primary', bg)
|
||||
root.style.setProperty('--bg-sidebar', sidebarBg)
|
||||
root.style.setProperty('--bg-card', mixColors(bg, fg, 0.03))
|
||||
root.style.setProperty('--bg-hover', mixColors(bg, fg, 0.1))
|
||||
root.style.setProperty('--bg-hover-subtle', muted)
|
||||
root.style.setProperty('--bg-selected', `${primary}25`)
|
||||
root.style.setProperty('--bg-input', bg)
|
||||
root.style.setProperty('--bg-button', mixColors(bg, fg, 0.1))
|
||||
root.style.setProperty('--bg-dialog', mixColors(bg, fg, 0.02))
|
||||
|
||||
root.style.setProperty('--text-primary', fg)
|
||||
root.style.setProperty('--text-heading', fg)
|
||||
root.style.setProperty('--text-secondary', mixColors(fg, bg, 0.25))
|
||||
root.style.setProperty('--text-tertiary', mixColors(fg, bg, 0.35))
|
||||
root.style.setProperty('--text-muted', mixColors(fg, bg, 0.5))
|
||||
root.style.setProperty('--text-faint', mixColors(fg, bg, 0.6))
|
||||
|
||||
root.style.setProperty('--border-primary', border)
|
||||
root.style.setProperty('--border-subtle', border)
|
||||
root.style.setProperty('--border-input', border)
|
||||
root.style.setProperty('--border-dialog', border)
|
||||
|
||||
root.style.setProperty('--link-color', primary)
|
||||
root.style.setProperty('--link-hover', mixColors(primary, fg, 0.2))
|
||||
|
||||
// Shadcn variables — only set if not already provided by the theme
|
||||
const setIfMissing = (name: string, value: string) => {
|
||||
if (!(name in colors)) root.style.setProperty(`--${name}`, value)
|
||||
}
|
||||
setIfMissing('card', mixColors(bg, fg, 0.03))
|
||||
setIfMissing('card-foreground', fg)
|
||||
setIfMissing('popover', mixColors(bg, fg, 0.04))
|
||||
setIfMissing('popover-foreground', fg)
|
||||
setIfMissing('secondary', mixColors(bg, fg, 0.08))
|
||||
setIfMissing('secondary-foreground', fg)
|
||||
setIfMissing('muted-foreground', mixColors(fg, bg, 0.3))
|
||||
setIfMissing('accent', mixColors(bg, fg, 0.08))
|
||||
setIfMissing('accent-foreground', fg)
|
||||
setIfMissing('input', border)
|
||||
setIfMissing('ring', primary)
|
||||
setIfMissing('sidebar-foreground', fg)
|
||||
setIfMissing('sidebar-accent', mixColors(sidebarBg, fg, 0.1))
|
||||
setIfMissing('sidebar-accent-foreground', fg)
|
||||
setIfMissing('sidebar-border', border)
|
||||
setIfMissing('sidebar-primary', primary)
|
||||
setIfMissing('sidebar-primary-foreground', '#FFFFFF')
|
||||
setIfMissing('sidebar-ring', primary)
|
||||
}
|
||||
|
||||
function clearDerivedVariables(root: HTMLElement): void {
|
||||
for (const name of DERIVED_VAR_NAMES) {
|
||||
root.style.removeProperty(`--${name}`)
|
||||
}
|
||||
root.style.removeProperty('color-scheme')
|
||||
delete root.dataset.themeMode
|
||||
}
|
||||
|
||||
/** Map theme colors/typography/spacing to CSS custom properties on :root. */
|
||||
function applyThemeToDom(theme: ThemeFile): void {
|
||||
const root = document.documentElement
|
||||
@@ -23,6 +135,7 @@ function applyThemeToDom(theme: ThemeFile): void {
|
||||
if (theme.colors['sidebar-background']) {
|
||||
root.style.setProperty('--sidebar', theme.colors['sidebar-background'])
|
||||
}
|
||||
deriveThemeVariables(root, theme.colors)
|
||||
}
|
||||
|
||||
function clearThemeFromDom(theme: ThemeFile): void {
|
||||
@@ -38,12 +151,14 @@ function clearThemeFromDom(theme: ThemeFile): void {
|
||||
root.style.removeProperty(`--theme-${key}`)
|
||||
}
|
||||
root.style.removeProperty('--sidebar')
|
||||
clearDerivedVariables(root)
|
||||
}
|
||||
|
||||
export interface ThemeManager {
|
||||
themes: ThemeFile[]
|
||||
activeThemeId: string | null
|
||||
activeTheme: ThemeFile | null
|
||||
isDark: boolean
|
||||
switchTheme: (themeId: string) => Promise<void>
|
||||
createTheme: (sourceId?: string) => Promise<string>
|
||||
reloadThemes: () => Promise<void>
|
||||
@@ -69,6 +184,7 @@ export function useThemeManager(vaultPath: string | null): ThemeManager {
|
||||
const prevThemeRef = useRef<ThemeFile | null>(null)
|
||||
|
||||
const activeTheme = themes.find(t => t.id === activeThemeId) ?? null
|
||||
const isDark = activeTheme?.colors.background ? isColorDark(activeTheme.colors.background) : false
|
||||
|
||||
const loadThemes = useCallback(async () => {
|
||||
if (!vaultPath) return
|
||||
@@ -120,5 +236,5 @@ export function useThemeManager(vaultPath: string | null): ThemeManager {
|
||||
}
|
||||
}, [vaultPath, loadThemes, switchTheme])
|
||||
|
||||
return { themes, activeThemeId, activeTheme, switchTheme, createTheme, reloadThemes: loadThemes }
|
||||
return { themes, activeThemeId, activeTheme, isDark, switchTheme, createTheme, reloadThemes: loadThemes }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@ vi.mock('../mock-tauri', () => ({
|
||||
isTauri: vi.fn(() => false),
|
||||
}))
|
||||
|
||||
// Mock openExternalUrl
|
||||
const mockOpenExternalUrl = vi.fn()
|
||||
vi.mock('../utils/url', () => ({
|
||||
openExternalUrl: (...args: unknown[]) => mockOpenExternalUrl(...args),
|
||||
}))
|
||||
|
||||
// Mock the dynamic imports
|
||||
const mockCheck = vi.fn()
|
||||
const mockRelaunch = vi.fn()
|
||||
@@ -149,9 +155,8 @@ describe('useUpdater', () => {
|
||||
expect(result.current.status).toEqual({ state: 'idle' })
|
||||
})
|
||||
|
||||
it('openReleaseNotes opens the release notes URL', async () => {
|
||||
it('openReleaseNotes opens the release notes URL via openExternalUrl', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(false)
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
|
||||
@@ -159,9 +164,8 @@ describe('useUpdater', () => {
|
||||
result.current.actions.openReleaseNotes()
|
||||
})
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://refactoringhq.github.io/laputa-app/',
|
||||
'_blank'
|
||||
expect(mockOpenExternalUrl).toHaveBeenCalledWith(
|
||||
'https://refactoringhq.github.io/laputa-app/'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
|
||||
const RELEASE_NOTES_URL = 'https://refactoringhq.github.io/laputa-app/'
|
||||
|
||||
@@ -80,7 +81,7 @@ export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } {
|
||||
}, [])
|
||||
|
||||
const openReleaseNotes = useCallback(() => {
|
||||
window.open(RELEASE_NOTES_URL, '_blank')
|
||||
openExternalUrl(RELEASE_NOTES_URL)
|
||||
}, [])
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
|
||||
@@ -10,7 +10,7 @@ const mockEntries: VaultEntry[] = [
|
||||
status: 'Active', owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 100,
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [],
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -4,9 +4,6 @@ import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
// Force light mode — dark mode removed for now
|
||||
document.documentElement.classList.remove('dark')
|
||||
|
||||
// Disable native WebKit context menu in Tauri (WKWebView intercepts right-click
|
||||
// at native level before React's synthetic events can call preventDefault).
|
||||
// Capture phase fires first → prevents native menu; React bubble phase still fires
|
||||
|
||||
@@ -35,6 +35,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['quarter/q1-2026', 'topic/software-development', 'person/matteo-cellini', 'person/maria-bianchi', 'person/marco-verdi'],
|
||||
},
|
||||
{
|
||||
@@ -69,6 +71,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['essay/on-writing-well', 'essay/engineering-leadership-101', 'essay/ai-agents-primer', 'topic/growth', 'topic/writing'],
|
||||
},
|
||||
{
|
||||
@@ -97,6 +101,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['person/matteo-cellini'],
|
||||
},
|
||||
{
|
||||
@@ -125,6 +131,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['responsibility/grow-newsletter'],
|
||||
},
|
||||
{
|
||||
@@ -153,6 +161,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['responsibility/manage-sponsorships'],
|
||||
},
|
||||
{
|
||||
@@ -182,6 +192,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['topic/trading', 'topic/algorithmic-trading', 'data/ema200-backtest-results'],
|
||||
},
|
||||
{
|
||||
@@ -211,6 +223,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['project/26q1-laputa-app', 'topic/growth', 'topic/ads'],
|
||||
},
|
||||
{
|
||||
@@ -239,6 +253,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['project/26q1-laputa-app'],
|
||||
},
|
||||
{
|
||||
@@ -266,6 +282,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -293,6 +311,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -320,6 +340,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -347,6 +369,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -375,6 +399,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['project/26q1-laputa-app', 'person/matteo-cellini'],
|
||||
},
|
||||
{
|
||||
@@ -403,6 +429,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -431,6 +459,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -459,6 +489,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['responsibility/grow-newsletter'],
|
||||
},
|
||||
{
|
||||
@@ -488,6 +520,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['responsibility/grow-newsletter', 'topic/software-development'],
|
||||
},
|
||||
{
|
||||
@@ -516,6 +550,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['responsibility/grow-newsletter'],
|
||||
},
|
||||
// --- Type documents ---
|
||||
@@ -542,6 +578,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: 0,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -567,6 +605,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: 1,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -592,6 +632,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: 2,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -617,6 +659,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: 3,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -642,6 +686,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: 4,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -667,6 +713,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: 5,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -692,6 +740,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: 6,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -717,6 +767,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: 7,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -742,6 +794,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: 8,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
// --- Custom type documents ---
|
||||
@@ -768,6 +822,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: 'cooking-pot',
|
||||
color: 'orange',
|
||||
order: 9,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -793,6 +849,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: 'book-open',
|
||||
color: 'green',
|
||||
order: 10,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
// --- Instances of custom types ---
|
||||
@@ -821,6 +879,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -848,6 +908,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
// --- Trashed entries ---
|
||||
@@ -877,6 +939,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -904,6 +968,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -932,6 +998,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
// --- Archived entries ---
|
||||
@@ -952,6 +1020,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
modifiedAt: now - 86400 * 120,
|
||||
createdAt: now - 86400 * 200,
|
||||
@@ -980,6 +1050,8 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
modifiedAt: now - 86400 * 90,
|
||||
createdAt: now - 86400 * 150,
|
||||
@@ -1041,7 +1113,9 @@ function generateBulkEntries(count: number): VaultEntry[] {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: Array.from({ length: i % 8 }, (_, j) => `note/link-target-${(i + j) % 50}`),
|
||||
outgoingLinks: Array.from({ length: i % 8 }, (_j, j) => `note/link-target-${(i + j) % 50}`),
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
})
|
||||
}
|
||||
return entries
|
||||
|
||||
@@ -5,6 +5,13 @@ import { createElement, type ReactNode, type ComponentType } from 'react'
|
||||
// Mock scrollIntoView for jsdom (not implemented)
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
|
||||
// Mock ResizeObserver for jsdom (not implemented)
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver
|
||||
|
||||
// Mock @tauri-apps/plugin-opener for test environment
|
||||
vi.mock('@tauri-apps/plugin-opener', () => ({
|
||||
openUrl: vi.fn(),
|
||||
|
||||
@@ -25,6 +25,10 @@ export interface VaultEntry {
|
||||
color: string | null
|
||||
/** Display order for Type entries in sidebar (lower = higher). null = use default order. */
|
||||
order: number | null
|
||||
/** Custom sidebar section label for Type entries, overriding auto-pluralization. */
|
||||
sidebarLabel: string | null
|
||||
/** Markdown template for Type entries. Pre-fills new notes created with this type. */
|
||||
template: string | null
|
||||
/** All wikilink targets found in the note content. Extracted from [[target]] patterns. */
|
||||
outgoingLinks: string[]
|
||||
}
|
||||
|
||||
185
src/utils/ai-context.test.ts
Normal file
185
src/utils/ai-context.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { resolveTarget, collectLinkedEntries, buildContextualPrompt } from './ai-context'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/vault/note/test.md',
|
||||
filename: 'test.md',
|
||||
title: 'Test Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('resolveTarget', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/vault/a.md', title: 'Alpha', filename: 'a.md' }),
|
||||
makeEntry({ path: '/vault/b.md', title: 'Beta', filename: 'beta-note.md', aliases: ['B'] }),
|
||||
]
|
||||
|
||||
it('resolves by title (case-insensitive)', () => {
|
||||
expect(resolveTarget('alpha', entries)?.path).toBe('/vault/a.md')
|
||||
expect(resolveTarget('Alpha', entries)?.path).toBe('/vault/a.md')
|
||||
})
|
||||
|
||||
it('resolves by alias (case-insensitive)', () => {
|
||||
expect(resolveTarget('B', entries)?.path).toBe('/vault/b.md')
|
||||
expect(resolveTarget('b', entries)?.path).toBe('/vault/b.md')
|
||||
})
|
||||
|
||||
it('resolves by filename stem', () => {
|
||||
expect(resolveTarget('beta-note', entries)?.path).toBe('/vault/b.md')
|
||||
})
|
||||
|
||||
it('returns undefined for unresolvable target', () => {
|
||||
expect(resolveTarget('nonexistent', entries)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectLinkedEntries', () => {
|
||||
const entryA = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
|
||||
const entryB = makeEntry({ path: '/vault/b.md', title: 'Beta' })
|
||||
const entryC = makeEntry({ path: '/vault/c.md', title: 'Gamma' })
|
||||
const entryD = makeEntry({ path: '/vault/d.md', title: 'Delta' })
|
||||
const allEntries = [entryA, entryB, entryC, entryD]
|
||||
|
||||
it('returns empty array when active has no links', () => {
|
||||
expect(collectLinkedEntries(entryA, allEntries)).toEqual([])
|
||||
})
|
||||
|
||||
it('collects entries from outgoingLinks', () => {
|
||||
const active = makeEntry({
|
||||
path: '/vault/main.md', title: 'Main',
|
||||
outgoingLinks: ['Alpha', 'Beta'],
|
||||
})
|
||||
const linked = collectLinkedEntries(active, [...allEntries, active])
|
||||
expect(linked.map(e => e.title)).toEqual(['Alpha', 'Beta'])
|
||||
})
|
||||
|
||||
it('collects entries from relationships', () => {
|
||||
const active = makeEntry({
|
||||
path: '/vault/main.md', title: 'Main',
|
||||
relationships: { relatedTo: ['[[Alpha]]', '[[Gamma]]'] },
|
||||
})
|
||||
const linked = collectLinkedEntries(active, [...allEntries, active])
|
||||
expect(linked.map(e => e.title)).toEqual(['Alpha', 'Gamma'])
|
||||
})
|
||||
|
||||
it('collects entries from belongsTo', () => {
|
||||
const active = makeEntry({
|
||||
path: '/vault/main.md', title: 'Main',
|
||||
belongsTo: ['[[Delta]]'],
|
||||
})
|
||||
const linked = collectLinkedEntries(active, [...allEntries, active])
|
||||
expect(linked.map(e => e.title)).toEqual(['Delta'])
|
||||
})
|
||||
|
||||
it('collects entries from relatedTo', () => {
|
||||
const active = makeEntry({
|
||||
path: '/vault/main.md', title: 'Main',
|
||||
relatedTo: ['[[Beta]]'],
|
||||
})
|
||||
const linked = collectLinkedEntries(active, [...allEntries, active])
|
||||
expect(linked.map(e => e.title)).toEqual(['Beta'])
|
||||
})
|
||||
|
||||
it('deduplicates entries across all link sources', () => {
|
||||
const active = makeEntry({
|
||||
path: '/vault/main.md', title: 'Main',
|
||||
outgoingLinks: ['Alpha', 'Beta'],
|
||||
relationships: { people: ['[[Alpha]]'] },
|
||||
belongsTo: ['[[Beta]]'],
|
||||
relatedTo: ['[[Alpha]]'],
|
||||
})
|
||||
const linked = collectLinkedEntries(active, [...allEntries, active])
|
||||
expect(linked.map(e => e.title)).toEqual(['Alpha', 'Beta'])
|
||||
})
|
||||
|
||||
it('excludes the active note itself', () => {
|
||||
const active = makeEntry({
|
||||
path: '/vault/a.md', title: 'Alpha',
|
||||
outgoingLinks: ['Alpha'],
|
||||
})
|
||||
const linked = collectLinkedEntries(active, allEntries)
|
||||
expect(linked).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores unresolvable links', () => {
|
||||
const active = makeEntry({
|
||||
path: '/vault/main.md', title: 'Main',
|
||||
outgoingLinks: ['Alpha', 'Nonexistent'],
|
||||
})
|
||||
const linked = collectLinkedEntries(active, [...allEntries, active])
|
||||
expect(linked.map(e => e.title)).toEqual(['Alpha'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildContextualPrompt', () => {
|
||||
it('includes active note title and content', () => {
|
||||
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha', isA: 'Project' })
|
||||
const content = { '/vault/a.md': '# Alpha\nThis is the alpha project.' }
|
||||
const prompt = buildContextualPrompt(active, [], content)
|
||||
expect(prompt).toContain('Alpha')
|
||||
expect(prompt).toContain('Project')
|
||||
expect(prompt).toContain('This is the alpha project.')
|
||||
})
|
||||
|
||||
it('includes linked note content', () => {
|
||||
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
|
||||
const linked = makeEntry({ path: '/vault/b.md', title: 'Beta', isA: 'Person' })
|
||||
const content = {
|
||||
'/vault/a.md': '# Alpha\nMain note.',
|
||||
'/vault/b.md': '# Beta\nLinked person.',
|
||||
}
|
||||
const prompt = buildContextualPrompt(active, [linked], content)
|
||||
expect(prompt).toContain('Beta')
|
||||
expect(prompt).toContain('Person')
|
||||
expect(prompt).toContain('Linked person.')
|
||||
expect(prompt).toContain('Linked Notes')
|
||||
})
|
||||
|
||||
it('shows (no content) when content is missing', () => {
|
||||
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
|
||||
const prompt = buildContextualPrompt(active, [], {})
|
||||
expect(prompt).toContain('(no content)')
|
||||
})
|
||||
|
||||
it('truncates linked note content to 2000 chars', () => {
|
||||
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
|
||||
const linked = makeEntry({ path: '/vault/b.md', title: 'Beta' })
|
||||
const longContent = 'x'.repeat(3000)
|
||||
const content = {
|
||||
'/vault/a.md': '# Alpha',
|
||||
'/vault/b.md': longContent,
|
||||
}
|
||||
const prompt = buildContextualPrompt(active, [linked], content)
|
||||
// The linked note content should be truncated
|
||||
const betaIdx = prompt.indexOf('### Beta')
|
||||
const afterBeta = prompt.slice(betaIdx)
|
||||
expect(afterBeta.length).toBeLessThan(2200)
|
||||
})
|
||||
|
||||
it('includes the system preamble', () => {
|
||||
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
|
||||
const prompt = buildContextualPrompt(active, [], { '/vault/a.md': 'content' })
|
||||
expect(prompt).toContain('AI assistant integrated into Laputa')
|
||||
})
|
||||
})
|
||||
90
src/utils/ai-context.ts
Normal file
90
src/utils/ai-context.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* AI contextual chat — builds the context note list from the active note
|
||||
* and its first-degree linked notes (outgoingLinks + relationships).
|
||||
*/
|
||||
|
||||
import type { VaultEntry } from '../types'
|
||||
import { wikilinkTarget } from './wikilink'
|
||||
|
||||
/** Resolve a link target string to a VaultEntry by matching title, aliases, or filename stem. */
|
||||
export function resolveTarget(target: string, entries: VaultEntry[]): VaultEntry | undefined {
|
||||
const lower = target.toLowerCase()
|
||||
return entries.find(e => {
|
||||
if (e.title.toLowerCase() === lower) return true
|
||||
if (e.aliases.some(a => a.toLowerCase() === lower)) return true
|
||||
const stem = e.filename.replace(/\.md$/, '')
|
||||
if (stem.toLowerCase() === lower) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
/** Collect first-degree linked notes from the active entry. */
|
||||
export function collectLinkedEntries(
|
||||
active: VaultEntry,
|
||||
entries: VaultEntry[],
|
||||
): VaultEntry[] {
|
||||
const seen = new Set<string>([active.path])
|
||||
const linked: VaultEntry[] = []
|
||||
|
||||
const addTarget = (target: string) => {
|
||||
const entry = resolveTarget(target, entries)
|
||||
if (entry && !seen.has(entry.path)) {
|
||||
seen.add(entry.path)
|
||||
linked.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
// outgoingLinks are raw targets (no [[ ]] wrapper)
|
||||
for (const target of active.outgoingLinks) {
|
||||
addTarget(target)
|
||||
}
|
||||
|
||||
// relationships values are wikilink references like [[target]]
|
||||
for (const refs of Object.values(active.relationships)) {
|
||||
for (const ref of refs) {
|
||||
addTarget(wikilinkTarget(ref))
|
||||
}
|
||||
}
|
||||
|
||||
// belongsTo and relatedTo are also wikilink references
|
||||
for (const ref of active.belongsTo) {
|
||||
addTarget(wikilinkTarget(ref))
|
||||
}
|
||||
for (const ref of active.relatedTo) {
|
||||
addTarget(wikilinkTarget(ref))
|
||||
}
|
||||
|
||||
return linked
|
||||
}
|
||||
|
||||
/** Build a contextual system prompt from the active note and its linked notes. */
|
||||
export function buildContextualPrompt(
|
||||
active: VaultEntry,
|
||||
linkedEntries: VaultEntry[],
|
||||
allContent: Record<string, string>,
|
||||
): string {
|
||||
const parts: string[] = [
|
||||
'You are an AI assistant integrated into Laputa, a personal knowledge management app.',
|
||||
'The user is viewing a specific note. Use the note and its linked context to answer questions accurately.',
|
||||
'You can also use MCP tools to search, read, create, or edit notes in the vault.',
|
||||
'',
|
||||
`## Active Note: ${active.title}`,
|
||||
`Type: ${active.isA ?? 'Note'} | Path: ${active.path}`,
|
||||
'',
|
||||
allContent[active.path] ?? '(no content)',
|
||||
]
|
||||
|
||||
if (linkedEntries.length > 0) {
|
||||
parts.push('', '## Linked Notes')
|
||||
for (const entry of linkedEntries) {
|
||||
const content = allContent[entry.path]
|
||||
parts.push(
|
||||
'',
|
||||
`### ${entry.title} (${entry.isA ?? 'Note'})`,
|
||||
content ? content.slice(0, 2000) : '(no content loaded)',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n')
|
||||
}
|
||||
@@ -10,7 +10,7 @@ function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0,
|
||||
snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, outgoingLinks: [],
|
||||
icon: null, color: null, order: null, template: null, outgoingLinks: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: null, createdAt: null,
|
||||
fileSize: 0, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null,
|
||||
order: null, outgoingLinks: [],
|
||||
order: null, template: null, outgoingLinks: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
5
src/utils/tabLayout.ts
Normal file
5
src/utils/tabLayout.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/** Compute per-tab max-width so all tabs fit within the container. */
|
||||
export function computeTabMaxWidth(containerWidth: number, tabCount: number): number {
|
||||
if (tabCount === 0) return 360
|
||||
return Math.max(60, Math.min(360, Math.floor(containerWidth / tabCount)))
|
||||
}
|
||||
@@ -54,7 +54,7 @@ const baseEntry: VaultEntry = {
|
||||
status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', relationships: {},
|
||||
wordCount: 0,
|
||||
icon: null, color: null, order: null, outgoingLinks: [],
|
||||
icon: null, color: null, order: null, template: null, outgoingLinks: [],
|
||||
}
|
||||
|
||||
describe('buildTypeEntryMap', () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ function makeEntry(overrides: Partial<VaultEntry>): VaultEntry {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, splitFrontmatter, countWords, extractOutgoingLinks } from './wikilinks'
|
||||
import { preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, splitFrontmatter, countWords, extractOutgoingLinks, extractBacklinkContext } from './wikilinks'
|
||||
|
||||
interface TestBlock {
|
||||
type?: string
|
||||
@@ -421,3 +421,67 @@ describe('extractOutgoingLinks', () => {
|
||||
expect(extractOutgoingLinks(content)).toEqual(['Valid'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractBacklinkContext', () => {
|
||||
const targets = new Set(['My Note'])
|
||||
|
||||
it('extracts the paragraph containing a matching wikilink', () => {
|
||||
const content = '---\ntitle: Test\n---\n\n# Test\n\nFirst paragraph.\n\nThis references [[My Note]] in context.\n\nThird paragraph.'
|
||||
const result = extractBacklinkContext(content, targets)
|
||||
expect(result).toBe('This references [[My Note]] in context.')
|
||||
})
|
||||
|
||||
it('returns null when no matching wikilink found', () => {
|
||||
const content = '---\ntitle: Test\n---\n\n# Test\n\nNo links here.'
|
||||
expect(extractBacklinkContext(content, targets)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty content', () => {
|
||||
expect(extractBacklinkContext('', targets)).toBeNull()
|
||||
})
|
||||
|
||||
it('truncates long paragraphs with ellipsis', () => {
|
||||
const longPara = 'A'.repeat(100) + ' [[My Note]] ' + 'B'.repeat(100)
|
||||
const content = `---\ntitle: X\n---\n\n# X\n\n${longPara}`
|
||||
const result = extractBacklinkContext(content, targets, 50)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.length).toBe(50)
|
||||
expect(result!.endsWith('\u2026')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches path-based wikilinks via last segment', () => {
|
||||
const content = '---\ntitle: Test\n---\n\n# Test\n\nSee [[project/My Note]] for details.'
|
||||
const result = extractBacklinkContext(content, targets)
|
||||
expect(result).toBe('See [[project/My Note]] for details.')
|
||||
})
|
||||
|
||||
it('matches aliased wikilinks [[target|display]]', () => {
|
||||
const content = '---\ntitle: Test\n---\n\n# Test\n\nCheck [[My Note|the note]] here.'
|
||||
const result = extractBacklinkContext(content, targets)
|
||||
expect(result).toBe('Check [[My Note|the note]] here.')
|
||||
})
|
||||
|
||||
it('skips frontmatter and title heading', () => {
|
||||
const content = '---\ntitle: My Note\n---\n\n# My Note\n\nBody text with [[My Note]] link.'
|
||||
const result = extractBacklinkContext(content, targets)
|
||||
expect(result).toBe('Body text with [[My Note]] link.')
|
||||
})
|
||||
|
||||
it('collapses internal whitespace', () => {
|
||||
const content = '---\ntitle: X\n---\n\n# X\n\nMultiple spaces\nand newline with [[My Note]] link.'
|
||||
const result = extractBacklinkContext(content, targets)
|
||||
expect(result).toBe('Multiple spaces and newline with [[My Note]] link.')
|
||||
})
|
||||
|
||||
it('returns first matching paragraph when multiple match', () => {
|
||||
const content = '---\ntitle: X\n---\n\n# X\n\nFirst [[My Note]] mention.\n\nSecond [[My Note]] mention.'
|
||||
const result = extractBacklinkContext(content, targets)
|
||||
expect(result).toBe('First [[My Note]] mention.')
|
||||
})
|
||||
|
||||
it('does not return paragraph when maxLength is respected', () => {
|
||||
const content = '---\ntitle: X\n---\n\n# X\n\nShort [[My Note]].'
|
||||
const result = extractBacklinkContext(content, targets, 200)
|
||||
expect(result).toBe('Short [[My Note]].')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -120,6 +120,40 @@ export function extractOutgoingLinks(content: string): string[] {
|
||||
return [...new Set(links)].sort()
|
||||
}
|
||||
|
||||
/** Extract the paragraph surrounding a [[target]] wikilink match from note content.
|
||||
* Searches for any target in the set, returns the first matching paragraph trimmed
|
||||
* to a max length. Returns null if no match found. */
|
||||
export function extractBacklinkContext(
|
||||
content: string,
|
||||
matchTargets: Set<string>,
|
||||
maxLength = 120,
|
||||
): string | null {
|
||||
const [, body] = splitFrontmatter(content)
|
||||
// Remove the H1 title line
|
||||
const withoutTitle = body.replace(/^\s*# [^\n]+\n?/, '')
|
||||
const paragraphs = withoutTitle.split(/\n{2,}/)
|
||||
|
||||
for (const para of paragraphs) {
|
||||
const trimmed = para.trim()
|
||||
if (!trimmed) continue
|
||||
// Check if this paragraph contains a wikilink matching any target
|
||||
const re = /\[\[([^\]]+)\]\]/g
|
||||
let match
|
||||
while ((match = re.exec(trimmed)) !== null) {
|
||||
const inner = match[1]
|
||||
const pipeIdx = inner.indexOf('|')
|
||||
const target = pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner
|
||||
if (matchTargets.has(target) || matchTargets.has(target.split('/').pop() ?? '')) {
|
||||
// Collapse whitespace and truncate
|
||||
const flat = trimmed.replace(/\s+/g, ' ')
|
||||
if (flat.length <= maxLength) return flat
|
||||
return flat.slice(0, maxLength - 1) + '\u2026'
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function countWords(content: string): number {
|
||||
const [, body] = splitFrontmatter(content)
|
||||
const withoutTitle = body.replace(/^\s*# [^\n]+\n?/, '')
|
||||
|
||||
Reference in New Issue
Block a user