Compare commits

...

2 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
35 changed files with 622 additions and 150 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

@@ -65,6 +65,9 @@ pub struct VaultEntry {
/// 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,
@@ -109,6 +112,8 @@ struct Frontmatter {
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.
@@ -153,6 +158,7 @@ const SKIP_KEYS: &[&str] = &[
"color",
"order",
"sidebar label",
"template",
];
/// Extract all wikilink-containing fields from raw YAML frontmatter.
@@ -332,6 +338,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
color: frontmatter.color,
order: frontmatter.order,
sidebar_label: frontmatter.sidebar_label,
template: frontmatter.template,
word_count,
outgoing_links,
})
@@ -1088,6 +1095,45 @@ References:
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

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

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

@@ -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'],
}
@@ -374,6 +376,7 @@ This is a test note with some words to count.
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
}
@@ -400,6 +403,7 @@ This is a test note with some words to count.
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
}
@@ -426,6 +430,7 @@ This is a test note with some words to count.
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
}
@@ -452,6 +457,7 @@ This is a test note with some words to count.
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
}
@@ -594,6 +600,7 @@ Status: Active
icon: null,
color: null,
order: null,
template: null,
// Body text also links to grow-newsletter
outgoingLinks: ['responsibility/grow-newsletter'],
}

View File

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

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

@@ -40,6 +40,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -66,6 +67,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -92,6 +94,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -118,6 +121,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -144,6 +148,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -170,6 +175,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -196,6 +202,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -222,6 +229,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
]
@@ -441,6 +449,7 @@ describe('Sidebar', () => {
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -467,6 +476,7 @@ describe('Sidebar', () => {
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -492,6 +502,7 @@ describe('Sidebar', () => {
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
},
{
@@ -517,6 +528,7 @@ describe('Sidebar', () => {
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
},
]
@@ -602,6 +614,7 @@ describe('Sidebar', () => {
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
}
render(<Sidebar entries={[...mockEntries, projectTypeEntry]} selection={defaultSelection} onSelect={() => {}} />)

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
@@ -229,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
@@ -241,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>
@@ -253,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)
@@ -302,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,
@@ -346,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

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

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

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

@@ -17,6 +17,8 @@ import {
resolveDailyNote,
findDailyNote,
useNoteActions,
DEFAULT_TEMPLATES,
resolveTemplate,
} from './useNoteActions'
import type { NoteActionsConfig } from './useNoteActions'
@@ -57,6 +59,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
color: null,
order: null,
outgoingLinks: [],
template: null,
...overrides,
})
@@ -192,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', () => {
@@ -244,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) => {
@@ -274,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) => {
@@ -523,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()))

View File

@@ -72,7 +72,7 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam
aliases: [], belongsTo: [], relatedTo: [],
status, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: now, createdAt: now, fileSize: 0,
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null,
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null,
}
}
@@ -129,11 +129,26 @@ const TYPE_FOLDER_MAP: Record<string, string> = {
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). */
@@ -150,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] ?? {}
}
@@ -159,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 } {
@@ -340,13 +357,17 @@ export function useNoteActions(config: NoteActionsConfig) {
[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) => persistNew(resolveNewNote(title, type)), [persistNew])
const handleCreateNote = useCallback((title: string, type: string) => {
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)

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

@@ -36,6 +36,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: ['quarter/q1-2026', 'topic/software-development', 'person/matteo-cellini', 'person/maria-bianchi', 'person/marco-verdi'],
},
{
@@ -71,6 +72,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
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'],
},
{
@@ -100,6 +102,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: ['person/matteo-cellini'],
},
{
@@ -129,6 +132,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: ['responsibility/grow-newsletter'],
},
{
@@ -158,6 +162,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: ['responsibility/manage-sponsorships'],
},
{
@@ -188,6 +193,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: ['topic/trading', 'topic/algorithmic-trading', 'data/ema200-backtest-results'],
},
{
@@ -218,6 +224,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: ['project/26q1-laputa-app', 'topic/growth', 'topic/ads'],
},
{
@@ -247,6 +254,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: ['project/26q1-laputa-app'],
},
{
@@ -275,6 +283,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -303,6 +312,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -331,6 +341,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -359,6 +370,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -388,6 +400,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: ['project/26q1-laputa-app', 'person/matteo-cellini'],
},
{
@@ -417,6 +430,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -446,6 +460,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -475,6 +490,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: ['responsibility/grow-newsletter'],
},
{
@@ -505,6 +521,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: ['responsibility/grow-newsletter', 'topic/software-development'],
},
{
@@ -534,6 +551,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: ['responsibility/grow-newsletter'],
},
// --- Type documents ---
@@ -561,6 +579,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 0,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -587,6 +606,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 1,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -613,6 +633,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 2,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -639,6 +660,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 3,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -665,6 +687,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 4,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -691,6 +714,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 5,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -717,6 +741,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 6,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -743,6 +768,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 7,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -769,6 +795,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 8,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
// --- Custom type documents ---
@@ -796,6 +823,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: 'orange',
order: 9,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -822,6 +850,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: 'green',
order: 10,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
// --- Instances of custom types ---
@@ -851,6 +880,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -879,6 +909,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
// --- Trashed entries ---
@@ -909,6 +940,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -937,6 +969,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -966,6 +999,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
// --- Archived entries ---
@@ -987,6 +1021,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
modifiedAt: now - 86400 * 120,
createdAt: now - 86400 * 200,
@@ -1016,6 +1051,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
modifiedAt: now - 86400 * 90,
createdAt: now - 86400 * 150,
@@ -1079,6 +1115,7 @@ function generateBulkEntries(count: number): VaultEntry[] {
order: null,
outgoingLinks: Array.from({ length: i % 8 }, (_j, j) => `note/link-target-${(i + j) % 50}`),
sidebarLabel: null,
template: null,
})
}
return entries

View File

@@ -27,6 +27,8 @@ export interface VaultEntry {
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

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