Compare commits

...

7 Commits

Author SHA1 Message Date
Luca Rossi
df2452dcaf fix: back/forward nav arrows reopen replaced tabs instead of skipping them (#172)
handleReplaceActiveTab removes the old tab from the tabs array, but the
old path remained in the navigation history. goBack(isTabOpen) then
skipped it because the tab was no longer open, returning null and making
the buttons appear broken.

Fix: filter by vault entry existence (not tab-open state) and reopen
closed tabs via handleSelectNote when navigating back/forward.

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:00:41 +01:00
Luca Rossi
89d0e39ad2 feat: note templates per type (💡 Note templates per tipo) (#170)
* feat: add template field to type entries and template-aware note creation

- Rust: add template field to Frontmatter, VaultEntry, SKIP_KEYS
- Rust: support YAML block scalar (|) for multi-line strings in frontmatter
- Rust: fix value continuation detection to handle block scalars properly
- TypeScript: add template to VaultEntry, buildNewEntry, frontmatterToEntryPatch
- Add DEFAULT_TEMPLATES for Project, Person, Responsibility, Experiment
- resolveNewNote/buildNoteContent accept optional template parameter
- handleCreateNote and handleCreateNoteImmediate look up type template
- Tests for all new behavior (Rust + TypeScript)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: wire template UI through Sidebar and App, add TypeCustomizePopover template section

- Add template textarea with debounced save to TypeCustomizePopover
- Wire handleUpdateTypeTemplate through useEntryActions → Sidebar → App
- Add useDebouncedCallback hook for 500ms template save debounce
- Add tests for handleUpdateTypeTemplate and TypeCustomizePopover template UI
- Create design/note-templates.pen placeholder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: useRef type arg + template field in bulk mock entries

* style: rustfmt

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 08:37:06 +01:00
Luca Rossi
e3977a6042 feat: contextual AI chat on open note (Cmd+I) (#169)
- Cmd+I toggles AI panel with context from the active note
- Context bar shows note title in AI panel when a note is open
- useAiAgent accepts optional contextPrompt used as system prompt
- ai-context.ts extracts structured context from note + related entries
- Updated AiPanel, EditorRightPanel, App, useAppCommands, useCommandRegistry
- 1292 frontend tests pass, coverage 78.96%

Co-authored-by: Test <test@test.com>
2026-03-02 05:00:29 +01:00
Luca Rossi
4cdc534c01 feat: add daily note command — Cmd+J creates or opens today's journal note (#168)
* feat: add daily note command — Cmd+J creates or opens today's journal note

Adds "Open Today's Note" command accessible via:
- Keyboard shortcut: Cmd+J
- Command Palette (Cmd+K → "Open Today's Note")
- File menu bar entry

If journal/YYYY-MM-DD.md exists, opens it. Otherwise creates it with
Journal type frontmatter and Intentions/Reflections template sections.

Also refactors useNoteActions to extract a shared persistNew callback,
reducing code duplication and keeping CodeScene complexity thresholds green.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: trigger CI for PR #168

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 03:08:15 +01:00
Luca Rossi
1a93acdce7 fix: show new (untracked) notes in Changes view (#161)
* fix: show new (untracked) notes in Changes view

Two fixes:
1. Use `git status --porcelain --untracked-files=all` so individual
   untracked files in subdirectories are listed (instead of just the
   directory name, which gets filtered out by the .md extension check).
2. Call loadModifiedFiles after persistOptimistic completes, so notes
   created via handleCreateNote/handleCreateType appear in Changes
   immediately without requiring a manual Cmd+S.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: trigger CI for PR #161

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 03:07:02 +01:00
Luca Rossi
9c8f7907b8 feat: enhance backlinks panel with collapsible section and paragraph context (#167)
The backlinks section in the Inspector is now collapsed by default with a
"Backlinks (N)" toggle header. Expanding reveals each backlink entry with
a paragraph preview showing the surrounding context of the [[wikilink]].

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 02:01:24 +01:00
Luca Rossi
8d9862ffef feat: allow custom sidebar label for type sections (#165)
* feat: allow custom sidebar label for type sections

- Adds sidebarLabel field to vault type config (Rust + frontend)
- Overrides auto-pluralization with user-defined label in sidebar
- Tests updated to cover custom label display

* fix: add missing sidebarLabel field to buildNewEntry and bulk mock generator

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 02:01:21 +01:00
53 changed files with 1756 additions and 240 deletions

View File

@@ -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
View File

@@ -0,0 +1 @@
81859

View File

@@ -0,0 +1 @@
{"children":[],"variables":{}}

View File

@@ -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 ---

View File

@@ -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]

View File

@@ -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",

View File

@@ -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

View File

@@ -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: [],
},
]

View File

@@ -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(() => {
@@ -271,6 +281,7 @@ 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,
@@ -289,6 +300,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 +342,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} />
</>

View File

@@ -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...')
})
})

View File

@@ -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>
)

View File

@@ -51,6 +51,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
...overrides,
})

View File

@@ -75,6 +75,7 @@ const mockEntry: VaultEntry = {
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
}

View File

@@ -38,6 +38,9 @@ export function EditorRightPanel({
onClose={() => onToggleAIChat?.()}
onOpenNote={onOpenNote}
vaultPath={vaultPath}
activeEntry={inspectorEntry}
entries={entries}
allContent={allContent}
/>
</div>
)

View File

@@ -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', () => {

View File

@@ -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> = {}

View File

@@ -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()
})
})

View File

@@ -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>
)
}

View File

@@ -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: [],
}

View File

@@ -26,6 +26,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
...overrides,
})

View File

@@ -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'],
},
]

View File

@@ -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: [],
},
]

View File

@@ -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 */
@@ -228,10 +230,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 +243,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 +257,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 +306,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 +354,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>
)
})

View File

@@ -10,7 +10,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 +215,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'])

View File

@@ -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('')
})
})

View File

@@ -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

View File

@@ -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 }))

View File

@@ -21,6 +21,7 @@ interface AppCommandsConfig {
onCommandPalette: () => void
onSearch: () => void
onCreateNote: () => void
onOpenDailyNote: () => void
onCreateNoteOfType: (type: string) => void
onSave: () => void
onOpenSettings: () => void
@@ -48,6 +49,7 @@ interface AppCommandsConfig {
onSwitchTheme?: (themeId: string) => void
onCreateTheme?: () => void
onOpenVault?: () => void
onToggleAIChat?: () => void
}
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
@@ -57,6 +59,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onCommandPalette: config.onCommandPalette,
onSearch: config.onSearch,
onCreateNote: config.onCreateNote,
onOpenDailyNote: config.onOpenDailyNote,
onSave: config.onSave,
onOpenSettings: config.onOpenSettings,
onTrashNote: config.onTrashNote,
@@ -67,6 +70,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onZoomReset: config.onZoomReset,
onGoBack: config.onGoBack,
onGoForward: config.onGoForward,
onToggleAIChat: config.onToggleAIChat,
activeTabPathRef: config.activeTabPathRef,
handleCloseTabRef: config.handleCloseTabRef,
})
@@ -74,6 +78,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,
@@ -112,6 +117,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 +128,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onSwitchTheme: config.onSwitchTheme,
onCreateTheme: config.onCreateTheme,
onOpenVault: config.onOpenVault,
onToggleAIChat: config.onToggleAIChat,
})
useKeyboardNavigation({

View File

@@ -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()
})
})
})

View File

@@ -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])
}

View File

@@ -26,6 +26,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
...overrides,
})
@@ -52,6 +53,7 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
zoomLevel: 100,
onSelect: vi.fn(),
onCloseTab: vi.fn(),
onOpenDailyNote: vi.fn(),
...overrides,
}
}
@@ -176,6 +178,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 = [

View File

@@ -31,11 +31,13 @@ interface CommandRegistryConfig {
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
@@ -127,9 +129,9 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
activeTabPath, entries, modifiedCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
onTrashNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onOpenVault,
onCommitPush, onSetViewMode, onToggleInspector, onToggleAIChat, onOpenVault,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onCloseTab,
onSelect, onOpenDailyNote, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
themes, activeThemeId, onSwitchTheme, onCreateTheme,
} = config
@@ -158,6 +160,7 @@ 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) } },
@@ -176,6 +179,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 },
@@ -196,9 +200,9 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
hasActiveNote, activeTabPath, isArchived, modifiedCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
onTrashNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onOpenVault,
onCommitPush, onSetViewMode, onToggleInspector, onToggleAIChat, onOpenVault,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onCloseTab,
onSelect, onOpenDailyNote, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenVault,
])

View File

@@ -26,6 +26,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
...overrides,
})
@@ -142,6 +143,43 @@ describe('useEntryActions', () => {
})
})
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.handleUpdateTypeTemplate('NonExistent', '## Template')
})
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
expect(updateEntry).not.toHaveBeenCalled()
})
})
describe('handleReorderSections', () => {
it('updates order on multiple type entries', () => {
const typeA = makeEntry({ isA: 'Type', title: 'Note', path: '/vault/type/note.md' })

View File

@@ -60,5 +60,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 }
}

View File

@@ -30,6 +30,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
...overrides,
})

View File

@@ -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)

View File

@@ -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',

View File

@@ -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(() => {

View File

@@ -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)', () => {

View File

@@ -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,16 @@ 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])
const fmCallbacks = { updateTab: updateTabContent, updateEntry, toast: setToastMessage }
@@ -368,6 +424,7 @@ export function useNoteActions(config: NoteActionsConfig) {
handleNavigateWikilink,
handleCreateNote,
handleCreateNoteImmediate,
handleOpenDailyNote,
handleCreateType,
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]),

View File

@@ -22,6 +22,7 @@ function makeEntry(path: string, title: string): VaultEntry {
fileSize: 100,
color: null,
icon: null,
template: null,
outgoingLinks: [],
relationships: {},
}

View File

@@ -32,6 +32,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
...overrides,
})

View File

@@ -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: [],
},
]

View File

@@ -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

View File

@@ -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[]
}

View 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
View 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')
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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', () => {

View File

@@ -26,6 +26,7 @@ function makeEntry(overrides: Partial<VaultEntry>): VaultEntry {
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
...overrides,
}

View File

@@ -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]].')
})
})

View File

@@ -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?/, '')