Compare commits

...

8 Commits

Author SHA1 Message Date
lucaronin
98d19e4c41 fix: align created label to right edge in note list date row 2026-04-10 20:01:40 +02:00
lucaronin
5ebffc447b feat: standardize canonical wikilink targets 2026-04-10 19:57:21 +02:00
lucaronin
a44dd41f95 feat: style note list status chips 2026-04-10 18:31:51 +02:00
lucaronin
eb67b98d96 test: scope quick open smoke locators 2026-04-10 18:14:52 +02:00
lucaronin
968c4d05a9 feat: add breadcrumb filename rename controls 2026-04-10 17:59:21 +02:00
lucaronin
c8db92c92d fix: debounce untitled H1 filename sync 2026-04-10 17:18:48 +02:00
lucaronin
1e36cd2855 test: cover typed note creation smoke paths 2026-04-10 16:27:36 +02:00
lucaronin
d86335c86d fix: harden immediate note creation 2026-04-10 15:47:45 +02:00
46 changed files with 2554 additions and 929 deletions

View File

@@ -13,7 +13,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:regression": "playwright test tests/smoke/",
"playwright:integration": "playwright test --config playwright.integration.config.ts",
"test:coverage": "vitest run --coverage",

View File

@@ -45,6 +45,17 @@ pub fn rename_note(
vault::rename_note(&vault_path, &old_path, &new_title, old_title.as_deref())
}
#[tauri::command]
pub fn rename_note_filename(
vault_path: String,
old_path: String,
new_filename_stem: String,
) -> Result<RenameResult, String> {
let vault_path = expand_tilde(&vault_path);
let old_path = expand_tilde(&old_path);
vault::rename_note_filename(&vault_path, &old_path, &new_filename_stem)
}
#[tauri::command]
pub fn auto_rename_untitled(
vault_path: String,

View File

@@ -144,6 +144,7 @@ pub fn run() {
commands::update_frontmatter,
commands::delete_frontmatter_property,
commands::rename_note,
commands::rename_note_filename,
commands::auto_rename_untitled,
commands::detect_renames,
commands::update_wikilinks_for_renames,

View File

@@ -20,8 +20,8 @@ pub use getting_started::{create_getting_started_vault, default_vault_path, vaul
pub use image::{copy_image_to_vault, save_image};
pub use migration::migrate_is_a_to_type;
pub use rename::{
auto_rename_untitled, detect_renames, rename_note, update_wikilinks_for_renames,
DetectedRename, RenameResult,
auto_rename_untitled, detect_renames, rename_note, rename_note_filename,
update_wikilinks_for_renames, DetectedRename, RenameResult,
};
pub use title_sync::{sync_title_on_open, SyncAction};
pub use trash::{batch_delete_notes, delete_note};

View File

@@ -1,5 +1,6 @@
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use walkdir::WalkDir;
@@ -28,13 +29,17 @@ pub(super) fn title_to_slug(title: &str) -> String {
.join("-")
}
/// Build a regex that matches wiki links referencing old title or path stem.
fn build_wikilink_pattern(old_title: &str, old_path_stem: &str) -> Option<Regex> {
let pattern_str = format!(
r"\[\[(?:{}|{})(\|[^\]]*?)?\]\]",
regex::escape(old_title),
regex::escape(old_path_stem),
);
/// Build a regex that matches wiki links referencing any of the provided targets.
fn build_wikilink_pattern(targets: &[&str]) -> Option<Regex> {
let escaped_targets: Vec<String> = targets
.iter()
.filter(|target| !target.is_empty())
.map(|target| regex::escape(target))
.collect();
if escaped_targets.is_empty() {
return None;
}
let pattern_str = format!(r"\[\[(?:{})(\|[^\]]*?)?\]\]", escaped_targets.join("|"));
Regex::new(&pattern_str).ok()
}
@@ -44,13 +49,13 @@ fn is_replaceable_md_file(path: &Path, exclude: &Path) -> bool {
}
/// Replace wikilink references in a single file's content. Returns updated content if changed.
fn replace_wikilinks_in_content(content: &str, re: &Regex, new_title: &str) -> Option<String> {
fn replace_wikilinks_in_content(content: &str, re: &Regex, new_target: &str) -> Option<String> {
if !re.is_match(content) {
return None;
}
let replaced = re.replace_all(content, |caps: &regex::Captures| match caps.get(1) {
Some(pipe) => format!("[[{}{}]]", new_title, pipe.as_str()),
None => format!("[[{}]]", new_title),
Some(pipe) => format!("[[{}{}]]", new_target, pipe.as_str()),
None => format!("[[{}]]", new_target),
});
if replaced != content {
Some(replaced.into_owned())
@@ -59,15 +64,6 @@ fn replace_wikilinks_in_content(content: &str, re: &Regex, new_title: &str) -> O
}
}
/// Parameters for a vault-wide wikilink replacement.
struct WikilinkReplacement<'a> {
vault_path: &'a Path,
old_title: &'a str,
new_title: &'a str,
old_path_stem: &'a str,
exclude_path: &'a Path,
}
/// Collect all .md file paths in vault eligible for wikilink replacement.
fn collect_md_files(vault_path: &Path, exclude: &Path) -> Vec<std::path::PathBuf> {
WalkDir::new(vault_path)
@@ -79,47 +75,80 @@ fn collect_md_files(vault_path: &Path, exclude: &Path) -> Vec<std::path::PathBuf
.collect()
}
fn unique_wikilink_targets(targets: Vec<&str>) -> Vec<&str> {
let mut seen = HashSet::new();
targets
.into_iter()
.filter(|target| !target.is_empty())
.filter(|target| seen.insert(*target))
.collect()
}
fn collect_legacy_wikilink_targets<'a>(old_title: &'a str, old_path_stem: &'a str) -> Vec<&'a str> {
let old_filename_stem = old_path_stem.rsplit('/').next().unwrap_or(old_path_stem);
unique_wikilink_targets(vec![old_title, old_path_stem, old_filename_stem])
}
/// Replace wiki link references across all vault markdown files.
fn update_wikilinks_in_vault(params: &WikilinkReplacement) -> usize {
let re = match build_wikilink_pattern(params.old_title, params.old_path_stem) {
fn update_wikilinks_in_vault(
vault_path: &Path,
old_targets: &[&str],
new_target: &str,
exclude_path: &Path,
) -> usize {
let re = match build_wikilink_pattern(old_targets) {
Some(r) => r,
None => return 0,
};
replace_wikilinks_in_files(
collect_md_files(vault_path, exclude_path),
&re,
new_target,
)
}
let files = collect_md_files(params.vault_path, params.exclude_path);
fn replace_wikilinks_in_files(
files: Vec<std::path::PathBuf>,
re: &Regex,
replacement: &str,
) -> usize {
files
.iter()
.filter(|path| {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return false,
};
match replace_wikilinks_in_content(&content, &re, params.new_title) {
Some(new_content) => fs::write(path, &new_content).is_ok(),
None => false,
}
})
.filter(|path| rewrite_wikilinks_in_file(path, re, replacement))
.count()
}
fn rewrite_wikilinks_in_file(path: &Path, re: &Regex, replacement: &str) -> bool {
let Ok(content) = fs::read_to_string(path) else {
return false;
};
let Some(new_content) = replace_wikilinks_in_content(&content, re, replacement) else {
return false;
};
fs::write(path, &new_content).is_ok()
}
/// Extract the value of the `title:` frontmatter field from raw content.
fn extract_fm_title_value(content: &str) -> Option<String> {
if !content.starts_with("---\n") {
return None;
}
let fm = content[4..].split("\n---").next()?;
for line in fm.lines() {
let t = line.trim_start();
for prefix in &["title:", "\"title\":"] {
if let Some(rest) = t.strip_prefix(prefix) {
let val = rest.trim().trim_matches('"').trim_matches('\'');
if !val.is_empty() {
return Some(val.to_string());
}
}
}
}
None
fm.lines()
.map(str::trim_start)
.find_map(extract_title_value_from_frontmatter_line)
}
fn extract_title_value_from_frontmatter_line(line: &str) -> Option<String> {
["title:", "\"title\":"]
.iter()
.find_map(|prefix| line.strip_prefix(prefix))
.map(str::trim)
.map(|value| value.trim_matches('"').trim_matches('\''))
.filter(|value| !value.is_empty())
.map(|value| value.to_string())
}
/// Update the `title:` frontmatter field in content.
@@ -168,6 +197,22 @@ fn unique_dest_path(dest_dir: &Path, filename: &str, exclude: &Path) -> std::pat
}
}
fn normalize_filename_stem(new_filename_stem: &str) -> Result<String, String> {
let trimmed = new_filename_stem.trim();
let stem = trimmed.strip_suffix(".md").unwrap_or(trimmed).trim();
if stem.is_empty() {
return Err("New filename cannot be empty".to_string());
}
if is_invalid_filename_stem(stem) {
return Err("Invalid filename".to_string());
}
Ok(stem.to_string())
}
fn is_invalid_filename_stem(stem: &str) -> bool {
stem == "." || stem == ".." || stem.contains('/') || stem.contains('\\')
}
/// Rename a note: update its frontmatter title, rename the file, and update wiki links across the vault.
///
/// When `old_title_hint` is provided it is used instead of extracting the title from
@@ -237,13 +282,9 @@ pub fn rename_note(
// Update wikilinks across the vault
let vault_prefix = format!("{}/", vault.to_string_lossy());
let old_path_stem = to_path_stem(old_path, &vault_prefix);
let updated_files = update_wikilinks_in_vault(&WikilinkReplacement {
vault_path: vault,
old_title,
new_title,
old_path_stem,
exclude_path: &new_file,
});
let new_path_stem = to_path_stem(&new_path_str, &vault_prefix).to_string();
let old_targets = collect_legacy_wikilink_targets(old_title, old_path_stem);
let updated_files = update_wikilinks_in_vault(vault, &old_targets, &new_path_stem, &new_file);
Ok(RenameResult {
new_path: new_path_str,
@@ -251,6 +292,67 @@ pub fn rename_note(
})
}
/// Rename only the file path stem while preserving title/frontmatter content.
pub fn rename_note_filename(
vault_path: &str,
old_path: &str,
new_filename_stem: &str,
) -> Result<RenameResult, String> {
let vault = Path::new(vault_path);
let old_file = Path::new(old_path);
if !old_file.exists() {
return Err(format!("File does not exist: {}", old_path));
}
let normalized_stem = normalize_filename_stem(new_filename_stem)?;
let old_filename = old_file
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let content =
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
let fm_title = extract_fm_title_value(&content);
let old_title = super::extract_title(fm_title.as_deref(), &content, &old_filename);
let new_filename = format!("{}.md", normalized_stem);
if old_filename == new_filename {
return Ok(RenameResult {
new_path: old_path.to_string(),
updated_files: 0,
});
}
let parent_dir = old_file
.parent()
.ok_or("Cannot determine parent directory")?;
let new_file = parent_dir.join(&new_filename);
if new_file.exists() && new_file != old_file {
return Err("A note with that name already exists".to_string());
}
fs::rename(old_file, &new_file).map_err(|e| {
format!(
"Failed to rename {} to {}: {}",
old_path,
new_file.to_string_lossy(),
e
)
})?;
let vault_prefix = format!("{}/", vault.to_string_lossy());
let old_path_stem = to_path_stem(old_path, &vault_prefix);
let new_path = new_file.to_string_lossy().to_string();
let new_path_stem = to_path_stem(&new_path, &vault_prefix).to_string();
let old_targets = collect_legacy_wikilink_targets(&old_title, old_path_stem);
let updated_files = update_wikilinks_in_vault(vault, &old_targets, &new_path_stem, &new_file);
Ok(RenameResult {
new_path,
updated_files,
})
}
/// Check if a filename matches the untitled pattern (e.g. "untitled-note-1234567890.md").
fn is_untitled_filename(filename: &str) -> bool {
let stem = filename.strip_suffix(".md").unwrap_or(filename);
@@ -352,22 +454,12 @@ pub fn update_wikilinks_for_renames(
.strip_suffix(".md")
.unwrap_or(&rename.new_path);
let old_filename_stem = old_stem.split('/').next_back().unwrap_or(old_stem);
let new_filename_stem = new_stem.split('/').next_back().unwrap_or(new_stem);
// Build title from filename stem (kebab-case → Title Case)
let old_title = super::parsing::slug_to_title(old_filename_stem);
let new_title = super::parsing::slug_to_title(new_filename_stem);
// The new file is the exclude target (don't rewrite wikilinks inside the renamed file itself)
let new_file = vault.join(&rename.new_path);
let updated = update_wikilinks_in_vault(&WikilinkReplacement {
vault_path: vault,
old_title: &old_title,
new_title: &new_title,
old_path_stem: old_filename_stem,
exclude_path: &new_file,
});
let old_targets = collect_legacy_wikilink_targets(&old_title, old_stem);
let updated = update_wikilinks_in_vault(vault, &old_targets, new_stem, &new_file);
total_updated += updated;
}
@@ -457,11 +549,11 @@ mod tests {
assert_eq!(result.updated_files, 2);
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
assert!(other_content.contains("[[Sprint Retrospective]]"));
assert!(other_content.contains("[[note/sprint-retrospective]]"));
assert!(!other_content.contains("[[Weekly Review]]"));
let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap();
assert!(project_content.contains("[[Sprint Retrospective]]"));
assert!(project_content.contains("[[note/sprint-retrospective]]"));
}
#[test]
@@ -521,7 +613,29 @@ mod tests {
assert_eq!(result.updated_files, 1);
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
assert!(ref_content.contains("[[Sprint Retro|my review]]"));
assert!(ref_content.contains("[[note/sprint-retro|my review]]"));
}
#[test]
fn test_rename_note_updates_filename_only_wikilinks_to_canonical_path() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/weekly-review.md", "# Weekly Review\n");
create_test_file(vault, "note/ref.md", "# Ref\n\nSee [[weekly-review]] for info.\n");
let old_path = vault.join("note/weekly-review.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Sprint Retro",
None,
)
.unwrap();
assert_eq!(result.updated_files, 1);
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
assert!(ref_content.contains("[[note/sprint-retro]]"));
assert!(!ref_content.contains("[[weekly-review]]"));
}
#[test]
@@ -698,6 +812,58 @@ mod tests {
assert!(vault.join("note/my-note.md").exists());
}
#[test]
fn test_rename_note_filename_preserves_title_and_updates_path_wikilinks() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"note/project-kickoff.md",
"---\ntitle: Project Kickoff\ntype: Note\n---\n\n# Project Kickoff\n\nBody.\n",
);
create_test_file(
vault,
"note/ref.md",
"# Ref\n\nSee [[note/project-kickoff]] and [[Project Kickoff]].\n",
);
let old_path = vault.join("note/project-kickoff.md");
let result = rename_note_filename(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"manual-name",
)
.unwrap();
assert!(result.new_path.ends_with("manual-name.md"));
assert!(!old_path.exists());
let renamed = fs::read_to_string(&result.new_path).unwrap();
assert!(renamed.contains("title: Project Kickoff"));
assert!(renamed.contains("# Project Kickoff"));
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
assert!(ref_content.contains("[[note/manual-name]]"));
assert!(!ref_content.contains("[[Project Kickoff]]"));
assert!(!ref_content.contains("[[note/project-kickoff]]"));
}
#[test]
fn test_rename_note_filename_rejects_existing_destination() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/current.md", "# Current\n");
create_test_file(vault, "note/manual-name.md", "# Existing\n");
let result = rename_note_filename(
vault.to_str().unwrap(),
vault.join("note/current.md").to_str().unwrap(),
"manual-name",
);
assert_eq!(result.unwrap_err(), "A note with that name already exists");
}
#[test]
fn test_rename_note_with_old_title_hint_updates_wikilinks() {
// Simulates H1 sync: content already saved with new H1, but wikilinks still use old title.
@@ -736,11 +902,11 @@ mod tests {
assert!(!vault.join("note/weekly-review.md").exists());
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
assert!(other_content.contains("[[Sprint Retrospective]]"));
assert!(other_content.contains("[[note/sprint-retrospective]]"));
assert!(!other_content.contains("[[Weekly Review]]"));
let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap();
assert!(project_content.contains("[[Sprint Retrospective]]"));
assert!(project_content.contains("[[note/sprint-retrospective]]"));
}
#[test]
@@ -770,7 +936,7 @@ mod tests {
assert_eq!(result.updated_files, 1);
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
assert!(other_content.contains("[[Sprint Retrospective]]"));
assert!(other_content.contains("[[note/sprint-retrospective]]"));
}
#[test]

View File

@@ -288,6 +288,13 @@ function App() {
replaceEntry: vault.replaceEntry, resolvedPath,
})
const handleFilenameRename = useCallback((path: string, newFilenameStem: string) => {
appSave.savePendingForPath(path)
.then(() => notes.handleRenameFilename(path, newFilenameStem, resolvedPath, vault.replaceEntry))
.then(vault.loadModifiedFiles)
.catch((err) => console.error('Filename rename failed:', err))
}, [appSave, notes, resolvedPath, vault])
const aiActivity = useAiActivity({
onOpenNote: vaultBridge.openNoteByPath,
onOpenTab: vaultBridge.openNoteByPath,
@@ -701,6 +708,7 @@ function App() {
onContentChange={appSave.handleContentChange}
onSave={appSave.handleSave}
onTitleSync={activeDeletedFile ? undefined : appSave.handleTitleSync}
onRenameFilename={activeDeletedFile ? undefined : handleFilenameRename}
rawToggleRef={rawToggleRef}
diffToggleRef={diffToggleRef}
canGoBack={canGoBack}

View File

@@ -153,6 +153,79 @@ describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)',
expect(container.querySelector('.breadcrumb-bar')).toHaveAttribute('data-title-hidden')
})
it('keeps the breadcrumb title visible when the separate title section is absent', () => {
const { container } = render(
<BreadcrumbBar entry={baseEntry} {...defaultProps} showTitleSection={false} />,
)
expect(container.querySelector('.breadcrumb-bar')).toHaveAttribute('data-title-hidden')
})
})
describe('BreadcrumbBar — filename controls', () => {
it('shows the sync button when the filename diverges from the title slug', () => {
const entry = { ...baseEntry, title: 'Fresh Title', filename: 'untitled-note-123.md' }
render(<BreadcrumbBar entry={entry} {...defaultProps} onRenameFilename={vi.fn()} />)
expect(screen.getByTestId('breadcrumb-sync-button')).toBeInTheDocument()
})
it('hides the sync button when the filename already matches the title slug', () => {
const entry = { ...baseEntry, title: 'Test Note', filename: 'test-note.md' }
render(<BreadcrumbBar entry={entry} {...defaultProps} onRenameFilename={vi.fn()} />)
expect(screen.queryByTestId('breadcrumb-sync-button')).not.toBeInTheDocument()
})
it('clicking the sync button renames the file to the title slug', () => {
const onRenameFilename = vi.fn()
const entry = { ...baseEntry, title: 'Fresh Title', filename: 'untitled-note-123.md' }
render(<BreadcrumbBar entry={entry} {...defaultProps} onRenameFilename={onRenameFilename} />)
fireEvent.click(screen.getByTestId('breadcrumb-sync-button'))
expect(onRenameFilename).toHaveBeenCalledWith(entry.path, 'fresh-title')
})
it('lets keyboard users press Enter on the filename to start editing', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onRenameFilename={vi.fn()} />)
fireEvent.keyDown(screen.getByTestId('breadcrumb-filename-trigger'), { key: 'Enter' })
expect(screen.getByTestId('breadcrumb-filename-input')).toHaveValue('test')
})
it('double-clicking the filename enters edit mode and Enter confirms the rename', () => {
const onRenameFilename = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onRenameFilename={onRenameFilename} />)
fireEvent.doubleClick(screen.getByTestId('breadcrumb-filename-trigger'))
const input = screen.getByTestId('breadcrumb-filename-input')
fireEvent.change(input, { target: { value: 'renamed-file' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onRenameFilename).toHaveBeenCalledWith(baseEntry.path, 'renamed-file')
})
it('pressing Escape while editing cancels the inline rename', () => {
const onRenameFilename = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onRenameFilename={onRenameFilename} />)
fireEvent.doubleClick(screen.getByTestId('breadcrumb-filename-trigger'))
const input = screen.getByTestId('breadcrumb-filename-input')
fireEvent.change(input, { target: { value: 'renamed-file' } })
fireEvent.keyDown(input, { key: 'Escape' })
expect(onRenameFilename).not.toHaveBeenCalled()
expect(screen.queryByTestId('breadcrumb-filename-input')).not.toBeInTheDocument()
})
it('blur confirms the inline rename when the value changed', () => {
const onRenameFilename = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onRenameFilename={onRenameFilename} />)
fireEvent.doubleClick(screen.getByTestId('breadcrumb-filename-trigger'))
const input = screen.getByTestId('breadcrumb-filename-input')
fireEvent.change(input, { target: { value: 'renamed-on-blur' } })
fireEvent.blur(input)
expect(onRenameFilename).toHaveBeenCalledWith(baseEntry.path, 'renamed-on-blur')
})
})
describe('BreadcrumbBar — action buttons always right-aligned', () => {

View File

@@ -1,6 +1,9 @@
import { memo } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'
import type { VaultEntry } from '../types'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import {
MagnifyingGlass,
GitBranch,
@@ -14,8 +17,10 @@ import {
ArrowUUpLeft,
Star,
CheckCircle,
ArrowsClockwise,
} from '@phosphor-icons/react'
import { NoteTitleIcon } from './NoteTitleIcon'
import { slugify } from '../hooks/useNoteCreation'
interface BreadcrumbBarProps {
entry: VaultEntry
@@ -37,170 +42,451 @@ interface BreadcrumbBarProps {
onDelete?: () => void
onArchive?: () => void
onUnarchive?: () => void
onRenameFilename?: (path: string, newFilenameStem: string) => void
showTitleSection?: boolean
/** Ref for direct DOM manipulation — avoids re-render on scroll. */
barRef?: React.Ref<HTMLDivElement>
}
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggleRaw?: () => void }) {
function IconActionButton({
title,
onClick,
className,
style,
disabled,
tabIndex,
children,
testId,
}: {
title: string
onClick?: () => void
className?: string
style?: CSSProperties
disabled?: boolean
tabIndex?: number
children: ReactNode
testId?: string
}) {
return (
<button
className={cn(
'flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors',
rawMode ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
)}
onClick={onToggleRaw}
title={rawMode ? 'Back to editor' : 'Raw editor'}
<Button
type="button"
variant="ghost"
size="icon-xs"
className={cn('text-muted-foreground', className)}
style={style}
onClick={onClick}
disabled={disabled}
tabIndex={tabIndex}
title={title}
data-testid={testId}
>
<Code size={16} />
</button>
{children}
</Button>
)
}
function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onToggleDiff,
rawMode, onToggleRaw, forceRawMode,
showAIChat, onToggleAIChat, inspectorCollapsed, onToggleInspector,
onToggleFavorite, onToggleOrganized, onDelete, onArchive, onUnarchive,
}: Omit<BreadcrumbBarProps, 'wordCount'>) {
function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggleRaw?: () => void }) {
return (
<div className="breadcrumb-bar__actions ml-auto flex items-center" style={{ gap: 12 }}>
<button
className={cn(
"flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors",
entry.favorite ? "text-yellow-500" : "text-muted-foreground hover:text-foreground"
)}
onClick={onToggleFavorite}
title={entry.favorite ? 'Remove from favorites' : 'Add to favorites'}
>
<Star size={16} weight={entry.favorite ? 'fill' : 'regular'} />
</button>
{onToggleOrganized && (
<button
className={cn(
"flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors",
entry.organized ? "text-green-600" : "text-muted-foreground hover:text-foreground"
)}
onClick={onToggleOrganized}
title={entry.organized ? 'Mark as unorganized (back to Inbox) (Cmd+E)' : 'Mark as organized (remove from Inbox) (Cmd+E)'}
>
<CheckCircle size={16} weight={entry.organized ? 'fill' : 'regular'} />
</button>
)}
<button
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
title="Search in file"
>
<MagnifyingGlass size={16} />
</button>
{showDiffToggle ? (
<button
className={cn(
"flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors",
diffMode ? "text-foreground" : "text-muted-foreground hover:text-foreground"
)}
onClick={onToggleDiff}
disabled={diffLoading}
title={diffLoading ? 'Loading diff...' : diffMode ? 'Back to editor' : 'Show diff'}
>
<GitBranch size={16} />
</button>
) : (
<button
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
style={DISABLED_ICON_STYLE}
title="No changes"
tabIndex={-1}
>
<GitBranch size={16} />
</button>
)}
{!forceRawMode && <RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />}
<button
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
style={DISABLED_ICON_STYLE}
title="Coming soon"
tabIndex={-1}
>
<CursorText size={16} />
</button>
<button
className={cn(
"flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors",
showAIChat ? "" : "text-muted-foreground hover:text-foreground"
)}
style={showAIChat ? { color: 'var(--primary)' } : undefined}
onClick={onToggleAIChat}
title={showAIChat ? 'Close AI Chat' : 'Open AI Chat'}
>
<Sparkle size={16} weight={showAIChat ? 'fill' : 'regular'} />
</button>
{entry.archived ? (
<button
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
onClick={onUnarchive}
title="Unarchive"
>
<ArrowUUpLeft size={16} />
</button>
) : (
<button
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
onClick={onArchive}
title="Archive"
>
<Archive size={16} />
</button>
)}
<button
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-destructive"
onClick={onDelete}
title="Delete (Cmd+Delete)"
>
<Trash size={16} />
</button>
{inspectorCollapsed && (
<button
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
onClick={onToggleInspector}
title="Properties (⌘⇧I)"
>
<SlidersHorizontal size={16} />
</button>
)}
<button
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
style={DISABLED_ICON_STYLE}
title="Coming soon"
tabIndex={-1}
>
<DotsThree size={16} />
</button>
<IconActionButton
title={rawMode ? 'Back to editor' : 'Raw editor'}
onClick={onToggleRaw}
className={cn(rawMode ? 'text-foreground' : 'hover:text-foreground')}
>
<Code size={16} />
</IconActionButton>
)
}
function FavoriteAction({ favorite, onToggleFavorite }: { favorite: boolean; onToggleFavorite?: () => void }) {
return (
<IconActionButton
title={favorite ? 'Remove from favorites' : 'Add to favorites'}
onClick={onToggleFavorite}
className={cn(favorite ? 'text-yellow-500' : 'hover:text-foreground')}
>
<Star size={16} weight={favorite ? 'fill' : 'regular'} />
</IconActionButton>
)
}
function OrganizedAction({
organized,
onToggleOrganized,
}: {
organized: boolean
onToggleOrganized?: () => void
}) {
if (!onToggleOrganized) return null
return (
<IconActionButton
title={organized ? 'Mark as unorganized (back to Inbox) (Cmd+E)' : 'Mark as organized (remove from Inbox) (Cmd+E)'}
onClick={onToggleOrganized}
className={cn(organized ? 'text-green-600' : 'hover:text-foreground')}
>
<CheckCircle size={16} weight={organized ? 'fill' : 'regular'} />
</IconActionButton>
)
}
function SearchAction() {
return (
<IconActionButton title="Search in file" className="hover:text-foreground">
<MagnifyingGlass size={16} />
</IconActionButton>
)
}
function DiffAction({
showDiffToggle,
diffMode,
diffLoading,
onToggleDiff,
}: Pick<BreadcrumbBarProps, 'showDiffToggle' | 'diffMode' | 'diffLoading' | 'onToggleDiff'>) {
if (!showDiffToggle) {
return (
<IconActionButton title="No changes" style={DISABLED_ICON_STYLE} tabIndex={-1}>
<GitBranch size={16} />
</IconActionButton>
)
}
return (
<IconActionButton
title={diffLoading ? 'Loading diff...' : diffMode ? 'Back to editor' : 'Show diff'}
onClick={onToggleDiff}
disabled={diffLoading}
className={cn(diffMode ? 'text-foreground' : 'hover:text-foreground')}
>
<GitBranch size={16} />
</IconActionButton>
)
}
function PlaceholderAction({ title, children }: { title: string; children: ReactNode }) {
return (
<IconActionButton title={title} style={DISABLED_ICON_STYLE} tabIndex={-1}>
{children}
</IconActionButton>
)
}
function AIChatAction({ showAIChat, onToggleAIChat }: Pick<BreadcrumbBarProps, 'showAIChat' | 'onToggleAIChat'>) {
return (
<IconActionButton
title={showAIChat ? 'Close AI Chat' : 'Open AI Chat'}
onClick={onToggleAIChat}
className={cn(showAIChat ? 'text-primary' : 'hover:text-foreground')}
>
<Sparkle size={16} weight={showAIChat ? 'fill' : 'regular'} />
</IconActionButton>
)
}
function ArchiveAction({
archived,
onArchive,
onUnarchive,
}: Pick<VaultEntry, 'archived'> & Pick<BreadcrumbBarProps, 'onArchive' | 'onUnarchive'>) {
if (archived) {
return (
<IconActionButton title="Unarchive" onClick={onUnarchive} className="hover:text-foreground">
<ArrowUUpLeft size={16} />
</IconActionButton>
)
}
return (
<IconActionButton title="Archive" onClick={onArchive} className="hover:text-foreground">
<Archive size={16} />
</IconActionButton>
)
}
function DeleteAction({ onDelete }: Pick<BreadcrumbBarProps, 'onDelete'>) {
return (
<IconActionButton title="Delete (Cmd+Delete)" onClick={onDelete} className="hover:text-destructive">
<Trash size={16} />
</IconActionButton>
)
}
function InspectorAction({
inspectorCollapsed,
onToggleInspector,
}: Pick<BreadcrumbBarProps, 'inspectorCollapsed' | 'onToggleInspector'>) {
if (!inspectorCollapsed) return null
return (
<IconActionButton title="Properties (⌘⇧I)" onClick={onToggleInspector} className="hover:text-foreground">
<SlidersHorizontal size={16} />
</IconActionButton>
)
}
function normalizeFilenameStemInput(value: string): string {
const trimmed = value.trim()
return trimmed.replace(/\.md$/i, '').trim()
}
function deriveSyncStem(entry: VaultEntry): string | null {
const expectedStem = slugify(entry.title.trim())
const filenameStem = entry.filename.replace(/\.md$/, '')
if (!expectedStem || expectedStem === filenameStem) return null
return expectedStem
}
function FilenameInput({
inputRef,
draftStem,
onDraftStemChange,
onBlur,
onKeyDown,
}: {
inputRef: React.RefObject<HTMLInputElement | null>
draftStem: string
onDraftStemChange: (nextValue: string) => void
onBlur: () => void
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void
}) {
return (
<Input
ref={inputRef}
value={draftStem}
onChange={(event) => onDraftStemChange(event.target.value)}
onBlur={onBlur}
onKeyDown={onKeyDown}
className="h-7 w-[180px] text-sm"
data-testid="breadcrumb-filename-input"
aria-label="Rename filename"
/>
)
}
function FilenameTrigger({
entry,
filenameStem,
onStartEditing,
}: {
entry: VaultEntry
filenameStem: string
onStartEditing: () => void
}) {
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== 'Enter') return
event.preventDefault()
onStartEditing()
}, [onStartEditing])
return (
<Button
type="button"
variant="ghost"
size="xs"
className="h-auto min-w-0 gap-1 px-0 py-0 text-sm font-medium text-foreground hover:bg-transparent hover:text-foreground"
onDoubleClick={onStartEditing}
onKeyDown={handleKeyDown}
data-testid="breadcrumb-filename-trigger"
aria-label={`Filename ${filenameStem}. Press Enter to rename`}
>
<NoteTitleIcon icon={entry.icon} size={15} testId="breadcrumb-note-icon" />
<span className="truncate">{filenameStem}</span>
</Button>
)
}
function SyncFilenameButton({
entryPath,
syncStem,
onRenameFilename,
}: {
entryPath: string
syncStem: string | null
onRenameFilename?: (path: string, newFilenameStem: string) => void
}) {
if (!syncStem || !onRenameFilename) return null
return (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-foreground"
onClick={() => onRenameFilename(entryPath, syncStem)}
data-testid="breadcrumb-sync-button"
aria-label="Rename file to match title"
>
<ArrowsClockwise size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>
Rename file to match title
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
function FilenameDisplay({
entry,
filenameStem,
syncStem,
onRenameFilename,
onStartEditing,
}: {
entry: VaultEntry
filenameStem: string
syncStem: string | null
onRenameFilename?: (path: string, newFilenameStem: string) => void
onStartEditing: () => void
}) {
return (
<div className="flex min-w-0 items-center gap-1">
<FilenameTrigger entry={entry} filenameStem={filenameStem} onStartEditing={onStartEditing} />
<SyncFilenameButton entryPath={entry.path} syncStem={syncStem} onRenameFilename={onRenameFilename} />
</div>
)
}
function BreadcrumbTitle({ entry }: { entry: VaultEntry }) {
function FilenameCrumb({ entry, onRenameFilename }: Pick<BreadcrumbBarProps, 'entry' | 'onRenameFilename'>) {
const filenameStem = useMemo(() => entry.filename.replace(/\.md$/, ''), [entry.filename])
const syncStem = useMemo(() => deriveSyncStem(entry), [entry])
const [isEditing, setIsEditing] = useState(false)
const [draftStem, setDraftStem] = useState(filenameStem)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!isEditing) return
inputRef.current?.focus()
inputRef.current?.select()
}, [isEditing])
const startEditing = useCallback(() => {
if (!onRenameFilename) return
setDraftStem(filenameStem)
setIsEditing(true)
}, [onRenameFilename, filenameStem])
const cancelEditing = useCallback(() => {
setDraftStem(filenameStem)
setIsEditing(false)
}, [filenameStem])
const submitRename = useCallback(() => {
const nextStem = normalizeFilenameStemInput(draftStem)
setIsEditing(false)
if (!nextStem || nextStem === filenameStem) return
onRenameFilename?.(entry.path, nextStem)
}, [draftStem, filenameStem, onRenameFilename, entry.path])
const handleInputKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
submitRename()
return
}
if (event.key === 'Escape') {
event.preventDefault()
cancelEditing()
}
}, [submitRename, cancelEditing])
if (isEditing) {
return (
<FilenameInput
inputRef={inputRef}
draftStem={draftStem}
onDraftStemChange={setDraftStem}
onBlur={submitRename}
onKeyDown={handleInputKeyDown}
/>
)
}
return (
<FilenameDisplay
entry={entry}
filenameStem={filenameStem}
syncStem={syncStem}
onRenameFilename={onRenameFilename}
onStartEditing={startEditing}
/>
)
}
function BreadcrumbActions({
entry,
showDiffToggle,
diffMode,
diffLoading,
onToggleDiff,
rawMode,
onToggleRaw,
forceRawMode,
showAIChat,
onToggleAIChat,
inspectorCollapsed,
onToggleInspector,
onToggleFavorite,
onToggleOrganized,
onDelete,
onArchive,
onUnarchive,
}: Omit<BreadcrumbBarProps, 'wordCount' | 'barRef' | 'onRenameFilename'>) {
return (
<div className="breadcrumb-bar__actions ml-auto flex items-center" style={{ gap: 12 }}>
<FavoriteAction favorite={entry.favorite} onToggleFavorite={onToggleFavorite} />
<OrganizedAction organized={entry.organized} onToggleOrganized={onToggleOrganized} />
<SearchAction />
<DiffAction
showDiffToggle={showDiffToggle}
diffMode={diffMode}
diffLoading={diffLoading}
onToggleDiff={onToggleDiff}
/>
{!forceRawMode && <RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />}
<PlaceholderAction title="Coming soon">
<CursorText size={16} />
</PlaceholderAction>
<AIChatAction showAIChat={showAIChat} onToggleAIChat={onToggleAIChat} />
<ArchiveAction archived={entry.archived} onArchive={onArchive} onUnarchive={onUnarchive} />
<DeleteAction onDelete={onDelete} />
<InspectorAction inspectorCollapsed={inspectorCollapsed} onToggleInspector={onToggleInspector} />
<PlaceholderAction title="Coming soon">
<DotsThree size={16} />
</PlaceholderAction>
</div>
)
}
function BreadcrumbTitle({
entry,
onRenameFilename,
}: Pick<BreadcrumbBarProps, 'entry' | 'onRenameFilename'>) {
const typeLabel = entry.isA ?? 'Note'
const filenameStem = entry.filename.replace(/\.md$/, '')
return (
<div className="flex items-center gap-1.5 min-w-0 text-sm text-muted-foreground">
<span className="shrink-0">{typeLabel}</span>
<span className="shrink-0 text-border"></span>
<span className="flex min-w-0 items-center gap-1 truncate font-medium text-foreground">
<NoteTitleIcon icon={entry.icon} size={15} testId="breadcrumb-note-icon" />
<span className="truncate">{filenameStem}</span>
</span>
<div className="flex min-w-0 items-center gap-1 truncate">
<FilenameCrumb entry={entry} onRenameFilename={onRenameFilename} />
</div>
</div>
)
}
export const BreadcrumbBar = memo(function BreadcrumbBar({
entry, barRef, ...actionProps
entry,
barRef,
onRenameFilename,
showTitleSection = true,
...actionProps
}: BreadcrumbBarProps) {
// In raw/diff mode the title section is not rendered — always show title in breadcrumb.
// Using a prop-driven attribute avoids the timing issues of DOM mutation in useEffect.
const titleAlwaysVisible = actionProps.rawMode || actionProps.diffMode
const titleAlwaysVisible = !showTitleSection || actionProps.rawMode || actionProps.diffMode
return (
<div
ref={barRef}
@@ -215,7 +501,7 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
}}
>
<div className="breadcrumb-bar__title flex-1 min-w-0">
<BreadcrumbTitle entry={entry} />
<BreadcrumbTitle entry={entry} onRenameFilename={onRenameFilename} />
</div>
<BreadcrumbActions entry={entry} {...actionProps} />
</div>

View File

@@ -413,7 +413,7 @@ describe('wikilink autocomplete', () => {
expect(items.length).toBeGreaterThan(0)
items[0].onItemClick()
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
{ type: 'wikilink', props: { target: 'vault/project/test|Alpha Project' } },
{ type: 'wikilink', props: { target: 'vault/project/test' } },
' ',
])
})
@@ -566,7 +566,7 @@ describe('person @mention autocomplete', () => {
expect(items.length).toBeGreaterThan(0)
items[0].onItemClick()
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
{ type: 'wikilink', props: { target: 'vault/person/matteo-cellini|Matteo Cellini' } },
{ type: 'wikilink', props: { target: 'vault/person/matteo-cellini' } },
' ',
])
})

View File

@@ -56,6 +56,8 @@ interface EditorProps {
onSave?: () => void
/** Called when the user edits the title in TitleField. */
onTitleSync?: (path: string, newTitle: string) => void
/** Called when the user explicitly renames the filename from the breadcrumb. */
onRenameFilename?: (path: string, newFilenameStem: string) => void
canGoBack?: boolean
canGoForward?: boolean
onGoBack?: () => void
@@ -203,7 +205,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
showAIChat, onToggleAIChat,
vaultPath, noteList, noteListFilter,
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
onContentChange, onSave, onTitleSync,
onContentChange, onSave, onTitleSync, onRenameFilename,
onFileCreated, onFileModified, onVaultChanged,
isConflicted, onKeepMine, onKeepTheirs,
} = props
@@ -255,6 +257,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
vaultPath={vaultPath}
rawLatestContentRef={rawLatestContentRef}
onTitleChange={onTitleSync}
onRenameFilename={onRenameFilename}
isConflicted={isConflicted}
onKeepMine={onKeepMine}
onKeepTheirs={onKeepTheirs}

View File

@@ -75,6 +75,7 @@ export function EditorRightPanel({
content={inspectorContent}
entries={entries}
gitHistory={gitHistory}
vaultPath={vaultPath}
onNavigate={onNavigateWikilink}
onViewCommitDiff={onViewCommitDiff}
onUpdateFrontmatter={onUpdateFrontmatter}

View File

@@ -24,6 +24,7 @@ interface InspectorProps {
content: string | null
entries: VaultEntry[]
gitHistory: GitCommit[]
vaultPath?: string
onNavigate: (target: string) => void
onViewCommitDiff?: (commitHash: string) => void
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
@@ -41,6 +42,7 @@ export function Inspector({
content,
entries,
gitHistory,
vaultPath,
onNavigate,
onViewCommitDiff,
onUpdateFrontmatter,
@@ -96,6 +98,7 @@ export function Inspector({
frontmatter={frontmatter}
entries={entries}
typeEntryMap={typeEntryMap}
vaultPath={vaultPath}
onNavigate={onNavigate}
onAddProperty={onAddProperty ? handleAddProperty : undefined}
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}

View File

@@ -179,7 +179,7 @@ describe('DynamicRelationshipsPanel', () => {
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Related to' } })
fireEvent.change(screen.getByPlaceholderText('Note title'), { target: { value: 'AI' } })
fireEvent.click(screen.getByTestId('submit-add-relationship'))
expect(onAddProperty).toHaveBeenCalledWith('Related to', '[[AI]]')
expect(onAddProperty).toHaveBeenCalledWith('Related to', '[[topic/ai]]')
})
it('cancels add relationship form', () => {
@@ -427,14 +427,14 @@ describe('DynamicRelationshipsPanel', () => {
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value: 'AI' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[AI]]'])
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[topic/ai]]'])
})
it('does not add duplicate refs', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Belongs to': ['[[AI]]'] }}
frontmatter={{ 'Belongs to': ['[[topic/ai]]'] }}
entries={entries}
onNavigate={onNavigate}
onUpdateProperty={onUpdateProperty}
@@ -533,7 +533,7 @@ describe('DynamicRelationshipsPanel', () => {
fireEvent.click(screen.getByTestId('create-and-open-option'))
expect(onCreateAndOpenNote).toHaveBeenCalledWith('Brand New Note')
await vi.waitFor(() => {
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[Brand New Note]]'])
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[brand-new-note]]'])
})
})
@@ -647,7 +647,7 @@ describe('DynamicRelationshipsPanel', () => {
fireEvent.click(screen.getByTestId('create-and-open-option'))
expect(onCreateAndOpenNote).toHaveBeenCalledWith('New Person')
await vi.waitFor(() => {
expect(onAddProperty).toHaveBeenCalledWith('Mentions', '[[New Person]]')
expect(onAddProperty).toHaveBeenCalledWith('Mentions', '[[new-person]]')
})
})
})

View File

@@ -224,7 +224,7 @@ function NoteDateRow({ entry }: { entry: VaultEntry }) {
return (
<div className="flex items-center justify-between gap-3 text-[10px] text-muted-foreground" data-testid="note-date-row">
<span>{modifiedLabel}</span>
{createdLabel && <span className="shrink-0">{createdLabel}</span>}
{createdLabel && <span className="ml-auto">{createdLabel}</span>}
</div>
)
}

View File

@@ -9,6 +9,23 @@ import {
renderNoteList,
} from '../test-utils/noteListTestUtils'
function makeBookTypeEntries(
displayProps: string[] = [],
entryOverrides: Parameters<typeof makeEntry>[0] = {},
) {
return [
makeTypeDefinition('Book', displayProps),
makeEntry({
path: '/vault/book.md',
filename: 'book.md',
title: 'Book Note',
isA: 'Book',
createdAt: 1700000000,
...entryOverrides,
}),
]
}
describe('NoteList rendering', () => {
it('shows an empty state when there are no entries', () => {
renderNoteList({ entries: [] })
@@ -137,20 +154,8 @@ describe('NoteList rendering', () => {
})
it('shows the inbox customize-columns action and falls back to type-defined chips', () => {
const entries = [
makeTypeDefinition('Book', ['Priority']),
makeEntry({
path: '/vault/book.md',
filename: 'book.md',
title: 'Book Note',
isA: 'Book',
properties: { Priority: 'High', Owner: 'Luca' },
createdAt: 1700000000,
}),
]
renderNoteList({
entries,
entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }),
selection: { kind: 'filter', filter: 'inbox' },
inboxNoteListProperties: null,
onUpdateInboxNoteListProperties: () => undefined,
@@ -163,20 +168,9 @@ describe('NoteList rendering', () => {
it('opens the inbox column picker from the global event and saves new columns', () => {
const onUpdateInboxNoteListProperties = vi.fn()
const entries = [
makeTypeDefinition('Book', ['Priority']),
makeEntry({
path: '/vault/book.md',
filename: 'book.md',
title: 'Book Note',
isA: 'Book',
properties: { Priority: 'High', Owner: 'Luca' },
createdAt: 1700000000,
}),
]
renderNoteList({
entries,
entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }),
selection: { kind: 'filter', filter: 'inbox' },
inboxNoteListProperties: null,
onUpdateInboxNoteListProperties,
@@ -194,20 +188,8 @@ describe('NoteList rendering', () => {
})
it('shows status in the type column picker when at least one note has it set', () => {
const entries = [
makeTypeDefinition('Book'),
makeEntry({
path: '/vault/book.md',
filename: 'book.md',
title: 'Book Note',
isA: 'Book',
status: 'Active',
createdAt: 1700000000,
}),
]
renderNoteList({
entries,
entries: makeBookTypeEntries([], { status: 'Active' }),
selection: { kind: 'sectionGroup', type: 'Book' },
onUpdateTypeSort: () => undefined,
})
@@ -221,21 +203,8 @@ describe('NoteList rendering', () => {
})
it('keeps blank statuses out of the type column picker', () => {
const entries = [
makeTypeDefinition('Book'),
makeEntry({
path: '/vault/book.md',
filename: 'book.md',
title: 'Book Note',
isA: 'Book',
status: '',
properties: { Owner: 'Luca' },
createdAt: 1700000000,
}),
]
renderNoteList({
entries,
entries: makeBookTypeEntries([], { status: '', properties: { Owner: 'Luca' } }),
selection: { kind: 'sectionGroup', type: 'Book' },
onUpdateTypeSort: () => undefined,
})
@@ -250,41 +219,41 @@ describe('NoteList rendering', () => {
})
it('renders status as a note-list chip when a type displays it', () => {
const entries = [
makeTypeDefinition('Book', ['status']),
makeEntry({
path: '/vault/book.md',
filename: 'book.md',
title: 'Book Note',
isA: 'Book',
status: 'Active',
createdAt: 1700000000,
}),
]
renderNoteList({
entries,
entries: makeBookTypeEntries(['status'], { status: 'Active' }),
selection: { kind: 'sectionGroup', type: 'Book' },
})
expect(screen.getByTestId('property-chips')).toHaveTextContent('Active')
const chip = screen.getByTestId('property-chip-status-0')
expect(chip).toHaveTextContent('• Active')
expect(chip).toHaveStyle({ backgroundColor: 'var(--accent-green-light)', color: 'var(--accent-green)' })
})
it('auto-detects status-like property values in note-list chips', () => {
renderNoteList({
entries: makeBookTypeEntries(['Phase'], { properties: { Phase: 'Draft' } }),
selection: { kind: 'sectionGroup', type: 'Book' },
})
const chip = screen.getByTestId('property-chip-phase-0')
expect(chip).toHaveTextContent('• Draft')
expect(chip).toHaveStyle({ backgroundColor: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' })
})
it('keeps unknown status values on neutral note-list chip styling', () => {
renderNoteList({
entries: makeBookTypeEntries(['status'], { status: 'Needs Review' }),
selection: { kind: 'sectionGroup', type: 'Book' },
})
const chip = screen.getByTestId('property-chip-status-0')
expect(chip).toHaveTextContent('• Needs Review')
expect(chip.getAttribute('style')).toBeNull()
})
it('uses inbox overrides when configured', () => {
const entries = [
makeTypeDefinition('Book', ['Priority']),
makeEntry({
path: '/vault/book.md',
filename: 'book.md',
title: 'Book Note',
isA: 'Book',
properties: { Priority: 'High', Owner: 'Luca' },
createdAt: 1700000000,
}),
]
renderNoteList({
entries,
entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }),
selection: { kind: 'filter', filter: 'inbox' },
inboxNoteListProperties: ['Owner'],
onUpdateInboxNoteListProperties: () => undefined,

View File

@@ -48,6 +48,7 @@ export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpen
return (
<div
data-testid="quick-open-palette"
className="fixed inset-0 z-[1000] flex justify-center bg-[var(--shadow-dialog)] pt-[15vh]"
onClick={onClose}
>

View File

@@ -1,7 +1,6 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, act } from '@testing-library/react'
import { RawEditorView } from './RawEditorView'
import { extractWikilinkQuery, detectYamlError } from '../utils/rawEditorUtils'
function entry(title: string, path = `/vault/note/${title}.md`) {
return {
@@ -23,61 +22,6 @@ const defaultProps = {
onSave: vi.fn(),
}
describe('extractWikilinkQuery', () => {
it('returns null when no [[ trigger', () => {
expect(extractWikilinkQuery('hello world', 5)).toBeNull()
})
it('returns empty string immediately after [[', () => {
const text = 'see [['
expect(extractWikilinkQuery(text, text.length)).toBe('')
})
it('returns query after [[', () => {
const text = 'see [[Proj'
expect(extractWikilinkQuery(text, text.length)).toBe('Proj')
})
it('returns null when ]] closes the link', () => {
const text = '[[Proj]]'
expect(extractWikilinkQuery(text, text.length)).toBeNull()
})
it('returns null when newline is in query', () => {
const text = '[[Proj\ncontinued'
expect(extractWikilinkQuery(text, text.length)).toBeNull()
})
it('handles cursor before end of text', () => {
const text = '[[Proj after'
expect(extractWikilinkQuery(text, 6)).toBe('Proj')
})
})
describe('detectYamlError', () => {
it('returns null for content without frontmatter', () => {
expect(detectYamlError('# Title\n\nSome content.')).toBeNull()
})
it('returns null for valid frontmatter', () => {
expect(detectYamlError('---\ntitle: My Note\n---\n\n# Title')).toBeNull()
})
it('returns error for unclosed frontmatter', () => {
const error = detectYamlError('---\ntitle: My Note\n\n# Title')
expect(error).toContain('Unclosed frontmatter')
})
it('returns error for tab indentation in frontmatter', () => {
const error = detectYamlError('---\n\ttitle: My Note\n---\n')
expect(error).toContain('tab indentation')
})
it('returns null for content not starting with ---', () => {
expect(detectYamlError('Not frontmatter')).toBeNull()
})
})
describe('RawEditorView', () => {
it('renders CodeMirror container', () => {
render(<RawEditorView {...defaultProps} />)

View File

@@ -1,22 +1,21 @@
import { useRef, useState, useCallback, useEffect, useMemo } from 'react'
import { trackEvent } from '../lib/telemetry'
import type { EditorView } from '@codemirror/view'
import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
import { attachClickHandlers, enrichSuggestionItems } from '../utils/suggestionEnrichment'
import { MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
import { buildTypeEntryMap } from '../utils/typeColors'
import { NoteSearchList } from './NoteSearchList'
import { extractWikilinkQuery, detectYamlError } from '../utils/rawEditorUtils'
import {
buildRawEditorAutocompleteState,
buildRawEditorBaseItems,
detectYamlError,
extractWikilinkQuery,
getRawEditorDropdownPosition,
replaceActiveWikilinkQuery,
type RawEditorAutocompleteState,
} from '../utils/rawEditorUtils'
import { useCodeMirror } from '../hooks/useCodeMirror'
import type { WikilinkSuggestionItem } from './WikilinkSuggestionMenu'
import type { VaultEntry } from '../types'
interface AutocompleteState {
caretTop: number
caretLeft: number
selectedIndex: number
items: WikilinkSuggestionItem[]
}
export interface RawEditorViewProps {
content: string
path: string
@@ -32,13 +31,6 @@ export interface RawEditorViewProps {
const DEBOUNCE_MS = 500
const DROPDOWN_MAX_HEIGHT = 200
function getCursorCoords(view: EditorView): { top: number; left: number } | null {
const pos = view.state.selection.main.head
const coords = view.coordsAtPos(pos)
if (!coords) return null
return { top: coords.bottom, left: coords.left }
}
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath }: RawEditorViewProps) {
const containerRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -52,23 +44,14 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
useEffect(() => { onContentChangeRef.current = onContentChange }, [onContentChange])
useEffect(() => { onSaveRef.current = onSave }, [onSave])
const [autocomplete, setAutocomplete] = useState<AutocompleteState | null>(null)
const [autocomplete, setAutocomplete] = useState<RawEditorAutocompleteState | null>(null)
const [yamlError, setYamlError] = useState<string | null>(() => detectYamlError(content))
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const baseItems = useMemo(
() => deduplicateByPath(entries.map(entry => ({
title: entry.title,
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
group: entry.isA || 'Note',
entryTitle: entry.title,
path: entry.path,
}))),
[entries],
)
const baseItems = useMemo(() => buildRawEditorBaseItems(entries), [entries])
const insertWikilinkRef = useRef<(entryTitle: string) => void>(() => {})
const insertWikilinkRef = useRef<(target: string) => void>(() => {})
const latestContentRefStable = useRef(latestContentRef)
useEffect(() => { latestContentRefStable.current = latestContentRef }, [latestContentRef])
@@ -91,12 +74,15 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
setAutocomplete(null)
return
}
const coords = getCursorCoords(view)
if (!coords) { setAutocomplete(null); return }
const candidates = preFilterWikilinks(baseItems, query)
const withHandlers = attachClickHandlers(candidates, (title: string) => insertWikilinkRef.current(title), vaultPath ?? '')
const items = enrichSuggestionItems(withHandlers, query, typeEntryMap)
setAutocomplete({ caretTop: coords.top, caretLeft: coords.left, selectedIndex: 0, items })
const nextAutocomplete = buildRawEditorAutocompleteState({
view,
baseItems,
query,
typeEntryMap,
onInsertTarget: (target: string) => insertWikilinkRef.current(target),
vaultPath: vaultPath ?? '',
})
setAutocomplete(nextAutocomplete)
}, [baseItems, typeEntryMap, vaultPath])
const handleSave = useCallback(() => {
@@ -120,30 +106,25 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
onEscape: handleEscape,
})
const insertWikilink = useCallback((entryTitle: string) => {
const insertWikilink = useCallback((target: string) => {
const view = viewRef.current
if (!view) return
const cursor = view.state.selection.main.head
const doc = view.state.doc.toString()
const before = doc.slice(0, cursor)
const triggerIdx = before.lastIndexOf('[[')
if (triggerIdx === -1) return
const after = doc.slice(cursor)
const newText = `${doc.slice(0, triggerIdx)}[[${entryTitle}]]${after}`
const newCursor = triggerIdx + entryTitle.length + 4
const replacement = replaceActiveWikilinkQuery(doc, cursor, target)
if (!replacement) return
view.dispatch({
changes: { from: 0, to: doc.length, insert: newText },
selection: { anchor: newCursor },
changes: { from: 0, to: doc.length, insert: replacement.text },
selection: { anchor: replacement.cursor },
})
trackEvent('wikilink_inserted')
setAutocomplete(null)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = null
latestDocRef.current = newText
onContentChangeRef.current(pathRef.current, newText)
latestDocRef.current = replacement.text
onContentChangeRef.current(pathRef.current, replacement.text)
view.focus()
}, [viewRef])
@@ -165,9 +146,9 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
} else if (e.key === 'Enter') {
e.preventDefault()
const item = autocomplete.items[autocomplete.selectedIndex]
if (item) insertWikilink(item.entryTitle ?? item.title)
if (item) item.onItemClick()
}
}, [autocomplete, insertWikilink])
}, [autocomplete])
// Flush pending debounce on unmount
useEffect(() => {
@@ -179,15 +160,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
}
}, [])
const dropdownBelow = autocomplete
? autocomplete.caretTop + 20 + DROPDOWN_MAX_HEIGHT <= window.innerHeight
: true
const dropdownTop = autocomplete
? (dropdownBelow ? autocomplete.caretTop + 4 : autocomplete.caretTop - DROPDOWN_MAX_HEIGHT - 24)
: 0
const dropdownLeft = autocomplete
? Math.min(autocomplete.caretLeft, window.innerWidth - 260)
: 0
const dropdownPosition = getRawEditorDropdownPosition(autocomplete, DROPDOWN_MAX_HEIGHT, window)
return (
<div className="flex flex-1 flex-col min-h-0 relative" style={{ background: 'var(--background)' }} onKeyDown={handleAutocompleteKey} role="presentation">
@@ -212,8 +185,8 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
<div
className="fixed z-50 min-w-64 max-w-xs rounded-md border shadow-lg overflow-auto"
style={{
top: dropdownTop,
left: dropdownLeft,
top: dropdownPosition.top,
left: dropdownPosition.left,
maxHeight: DROPDOWN_MAX_HEIGHT,
background: 'var(--popover)',
borderColor: 'var(--border)',
@@ -224,7 +197,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
items={autocomplete.items}
selectedIndex={autocomplete.selectedIndex}
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
onItemClick={(item) => insertWikilink(item.entryTitle ?? item.title)}
onItemClick={(item) => item.onItemClick()}
onItemHover={(i) => setAutocomplete(prev => prev ? { ...prev, selectedIndex: i } : null)}
/>
</div>

View File

@@ -29,6 +29,7 @@ type BreadcrumbActions = Pick<
| 'onDeleteNote'
| 'onArchiveNote'
| 'onUnarchiveNote'
| 'onRenameFilename'
>
function EditorLoadingSkeleton() {
@@ -98,12 +99,14 @@ function ActiveTabBreadcrumb({
barRef,
wordCount,
path,
showTitleSection,
actions,
}: {
activeTab: NonNullable<EditorContentModel['activeTab']>
barRef: React.RefObject<HTMLDivElement | null>
wordCount: number
path: string
showTitleSection: boolean
actions: BreadcrumbActions
}) {
return (
@@ -111,6 +114,7 @@ function ActiveTabBreadcrumb({
entry={activeTab.entry}
wordCount={wordCount}
barRef={barRef}
showTitleSection={showTitleSection}
showDiffToggle={actions.showDiffToggle}
diffMode={actions.diffMode}
diffLoading={actions.diffLoading}
@@ -127,6 +131,7 @@ function ActiveTabBreadcrumb({
onDelete={bindPath(actions.onDeleteNote, path)}
onArchive={bindPath(actions.onArchiveNote, path)}
onUnarchive={bindPath(actions.onUnarchiveNote, path)}
onRenameFilename={actions.onRenameFilename}
/>
)
}
@@ -316,6 +321,7 @@ export function EditorContentLayout(model: EditorContentModel) {
barRef={breadcrumbBarRef}
wordCount={wordCount}
path={path}
showTitleSection={showTitleSection}
actions={{
diffMode: model.diffMode,
diffLoading: model.diffLoading,
@@ -333,6 +339,7 @@ export function EditorContentLayout(model: EditorContentModel) {
onDeleteNote: model.onDeleteNote,
onArchiveNote: model.onArchiveNote,
onUnarchiveNote: model.onUnarchiveNote,
onRenameFilename: model.onRenameFilename,
}}
/>
<EditorChrome

View File

@@ -40,11 +40,54 @@ export interface EditorContentProps {
vaultPath?: string
rawLatestContentRef?: React.MutableRefObject<string | null>
onTitleChange?: (path: string, newTitle: string) => void
onRenameFilename?: (path: string, newFilenameStem: string) => void
isConflicted?: boolean
onKeepMine?: (path: string) => void
onKeepTheirs?: (path: string) => void
}
function useBreadcrumbTitleVisibility({
showEditor,
showTitleSection,
path,
breadcrumbBarRef,
titleSectionRef,
}: {
showEditor: boolean
showTitleSection: boolean
path: string
breadcrumbBarRef: React.RefObject<HTMLDivElement | null>
titleSectionRef: React.RefObject<HTMLDivElement | null>
}) {
useEffect(() => {
if (!showEditor) return
const bar = breadcrumbBarRef.current
const titleSection = titleSectionRef.current
if (!bar || !titleSection) return
if (!showTitleSection) {
bar.setAttribute('data-title-hidden', '')
return () => {
bar.removeAttribute('data-title-hidden')
}
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) bar.removeAttribute('data-title-hidden')
else bar.setAttribute('data-title-hidden', '')
},
{ threshold: 0 },
)
observer.observe(titleSection)
return () => {
observer.disconnect()
bar.removeAttribute('data-title-hidden')
}
}, [path, showEditor, showTitleSection, breadcrumbBarRef, titleSectionRef])
}
export function useEditorContentModel(props: EditorContentProps) {
const {
activeTab,
@@ -77,26 +120,13 @@ export function useEditorContentModel(props: EditorContentProps) {
const titleSectionRef = useRef<HTMLDivElement | null>(null)
const breadcrumbBarRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!showEditor) return
const bar = breadcrumbBarRef.current
const titleSection = titleSectionRef.current
if (!bar || !titleSection) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) bar.removeAttribute('data-title-hidden')
else bar.setAttribute('data-title-hidden', '')
},
{ threshold: 0 },
)
observer.observe(titleSection)
return () => {
observer.disconnect()
bar.removeAttribute('data-title-hidden')
}
}, [path, showEditor])
useBreadcrumbTitleVisibility({
showEditor,
showTitleSection,
path,
breadcrumbBarRef,
titleSectionRef,
})
return {
...props,

View File

@@ -6,7 +6,12 @@ import { containsWikilinks } from '../DynamicPropertiesPanel'
import type { FrontmatterValue } from '../Inspector'
import { NoteSearchList } from '../NoteSearchList'
import { useNoteSearch } from '../../hooks/useNoteSearch'
import { resolveEntry } from '../../utils/wikilink'
import {
resolveEntry,
canonicalWikilinkTargetForEntry,
canonicalWikilinkTargetForTitle,
formatWikilinkRef,
} from '../../utils/wikilink'
import { isWikilink, resolveRefProps } from './shared'
import { LinkButton } from './LinkButton'
@@ -15,6 +20,61 @@ function hasExactTitleMatch(entries: VaultEntry[], title: string): boolean {
return resolveEntry(entries, title) !== undefined
}
function inferVaultPath(entries: VaultEntry[]): string {
if (entries.length === 0) return ''
const segments = entries.map((entry) => entry.path.split('/').slice(0, -1))
const prefix: string[] = []
const maxDepth = Math.min(...segments.map((parts) => parts.length))
for (let i = 0; i < maxDepth; i += 1) {
const segment = segments[0][i]
if (segments.every((parts) => parts[i] === segment)) prefix.push(segment)
else break
}
return prefix.join('/')
}
function canonicalRefForEntry(entry: VaultEntry, vaultPath: string): string {
return formatWikilinkRef(canonicalWikilinkTargetForEntry(entry, vaultPath))
}
function canonicalRefForTitle(title: string, entries: VaultEntry[], vaultPath: string): string {
return formatWikilinkRef(canonicalWikilinkTargetForTitle(title, entries, vaultPath))
}
function shouldShowSearchDropdown(focused: boolean, trimmed: string, resultCount: number, showCreate: boolean): boolean {
return focused && trimmed.length > 0 && (resultCount > 0 || showCreate)
}
function confirmRelationshipSelection({
showCreate,
selectedIndex,
createIndex,
trimmed,
selectedEntry,
onCreate,
onSelectEntry,
onFallback,
}: {
showCreate: boolean
selectedIndex: number
createIndex: number
trimmed: string
selectedEntry?: VaultEntry
onCreate?: (title: string) => void
onSelectEntry?: (entry: VaultEntry) => void
onFallback?: () => void
}): void {
if (showCreate && selectedIndex === createIndex && trimmed) {
onCreate?.(trimmed)
return
}
if (selectedEntry) {
onSelectEntry?.(selectedEntry)
return
}
onFallback?.()
}
/** Shared keyboard navigation for search dropdowns with an optional "create" item. */
function useSearchKeyboard(
search: ReturnType<typeof useNoteSearch>,
@@ -90,7 +150,7 @@ function CreateAndOpenOption({ title, selected, onClick, onHover }: {
function SearchDropdownWithCreate({ search, onSelect, query, entries, onCreateAndOpen }: {
search: ReturnType<typeof useNoteSearch>
onSelect: (title: string) => void
onSelect: (entry: VaultEntry) => void
query: string
entries: VaultEntry[]
onCreateAndOpen?: (title: string) => void
@@ -108,7 +168,7 @@ function SearchDropdownWithCreate({ search, onSelect, query, entries, onCreateAn
items={search.results}
selectedIndex={search.selectedIndex}
getItemKey={(item) => item.entry.path}
onItemClick={(item) => onSelect(item.entry.title)}
onItemClick={(item) => onSelect(item.entry)}
onItemHover={(i) => search.setSelectedIndex(i)}
className="max-h-[160px] overflow-y-auto"
/>
@@ -125,11 +185,12 @@ function SearchDropdownWithCreate({ search, onSelect, query, entries, onCreateAn
)
}
function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
entries: VaultEntry[]
onAdd: (noteTitle: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
}) {
function useInlineAddNoteState(
entries: VaultEntry[],
vaultPath: string,
onAdd: (ref: string) => void,
onCreateAndOpenNote?: (title: string) => Promise<boolean>,
) {
const [active, setActive] = useState(false)
const [query, setQuery] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
@@ -138,25 +199,82 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
const trimmed = query.trim()
const { showCreate, createIndex, totalItems } = useCreateOption(entries, trimmed, search.results.length, !!onCreateAndOpenNote)
const dismiss = useCallback(() => { setQuery(''); setActive(false) }, [])
const dismiss = useCallback(() => {
setQuery('')
setActive(false)
}, [])
const selectAndClose = useCallback((title: string) => {
onAdd(title)
const selectAndClose = useCallback((ref: string) => {
onAdd(ref)
dismiss()
}, [onAdd, dismiss])
const handleCreateAndOpen = useCreateAndOpen(onCreateAndOpenNote, onAdd, dismiss)
const selectEntryAndClose = useCallback((entry: VaultEntry) => {
selectAndClose(canonicalRefForEntry(entry, vaultPath))
}, [selectAndClose, vaultPath])
const handleCreateAndOpen = useCreateAndOpen(
onCreateAndOpenNote,
(title) => onAdd(canonicalRefForTitle(title, entries, vaultPath)),
dismiss,
)
const handleFallback = useCallback(() => {
if (!trimmed) return
selectAndClose(canonicalRefForTitle(trimmed, entries, vaultPath))
}, [trimmed, selectAndClose, entries, vaultPath])
const handleConfirm = useCallback(() => {
if (showCreate && search.selectedIndex === createIndex) {
handleCreateAndOpen(trimmed)
return
}
const title = search.selectedEntry?.title ?? trimmed
if (title) selectAndClose(title)
}, [search.selectedEntry, search.selectedIndex, trimmed, selectAndClose, showCreate, createIndex, handleCreateAndOpen])
confirmRelationshipSelection({
showCreate,
selectedIndex: search.selectedIndex,
createIndex,
trimmed,
selectedEntry: search.selectedEntry,
onCreate: handleCreateAndOpen,
onSelectEntry: selectEntryAndClose,
onFallback: handleFallback,
})
}, [showCreate, search.selectedIndex, search.selectedEntry, createIndex, trimmed, handleCreateAndOpen, selectEntryAndClose, handleFallback])
const handleKeyDown = useSearchKeyboard(search, totalItems, handleConfirm, dismiss)
const showDropdown = shouldShowSearchDropdown(active, trimmed, search.results.length, showCreate)
return {
active,
setActive,
query,
setQuery,
inputRef,
search,
dismiss,
handleKeyDown,
showDropdown,
selectEntryAndClose,
showCreate,
handleCreateAndOpen,
}
}
function InlineAddNote({ entries, vaultPath, onAdd, onCreateAndOpenNote }: {
entries: VaultEntry[]
vaultPath: string
onAdd: (ref: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
}) {
const {
active,
setActive,
query,
setQuery,
inputRef,
search,
dismiss,
handleKeyDown,
showDropdown,
selectEntryAndClose,
handleCreateAndOpen,
} = useInlineAddNoteState(entries, vaultPath, onAdd, onCreateAndOpenNote)
if (!active) {
return (
@@ -171,8 +289,6 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
)
}
const showDropdown = trimmed.length > 0 && (search.results.length > 0 || showCreate)
return (
<div className="relative mt-1">
<div className="group/add relative flex items-center">
@@ -197,7 +313,7 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
{showDropdown && (
<SearchDropdownWithCreate
search={search}
onSelect={selectAndClose}
onSelect={selectEntryAndClose}
query={query}
entries={entries}
onCreateAndOpen={onCreateAndOpenNote ? (title) => { handleCreateAndOpen(title) } : undefined}
@@ -207,11 +323,11 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
)
}
function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onRemoveRef, onAddRef, onCreateAndOpenNote }: {
label: string; refs: string[]; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>
function RelationshipGroup({ label, refs, entries, typeEntryMap, vaultPath, onNavigate, onRemoveRef, onAddRef, onCreateAndOpenNote }: {
label: string; refs: string[]; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; vaultPath: string
onNavigate: (target: string) => void
onRemoveRef?: (ref: string) => void
onAddRef?: (noteTitle: string) => void
onAddRef?: (ref: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
}) {
if (refs.length === 0) return null
@@ -234,6 +350,7 @@ function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onR
{onAddRef && (
<InlineAddNote
entries={entries}
vaultPath={vaultPath}
onAdd={onAddRef}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
@@ -269,22 +386,30 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreat
const trimmed = value.trim()
const { showCreate, createIndex, totalItems } = useCreateOption(entries, trimmed, search.results.length, !!onCreateAndOpenNote)
const handleConfirm = useCallback(() => {
if (showCreate && search.selectedIndex === createIndex) {
onSubmitWithCreate?.(trimmed)
} else if (search.selectedEntry) {
onChange(search.selectedEntry.title)
setFocused(false)
} else {
onSubmit?.()
}
}, [showCreate, search.selectedIndex, search.selectedEntry, createIndex, trimmed, onChange, onSubmit, onSubmitWithCreate])
const selectEntry = useCallback((entry: VaultEntry) => {
onChange(entry.title)
setFocused(false)
}, [onChange])
const handleEscape = useCallback(() => { onCancel?.() }, [onCancel])
const handleConfirm = useCallback(() => {
confirmRelationshipSelection({
showCreate,
selectedIndex: search.selectedIndex,
createIndex,
trimmed,
selectedEntry: search.selectedEntry,
onCreate: onSubmitWithCreate,
onSelectEntry: selectEntry,
onFallback: onSubmit,
})
}, [showCreate, search.selectedIndex, search.selectedEntry, createIndex, trimmed, onSubmitWithCreate, selectEntry, onSubmit])
const handleEscape = useCallback(() => {
onCancel?.()
}, [onCancel])
const handleKeyDown = useSearchKeyboard(search, totalItems, handleConfirm, handleEscape)
const showDropdown = focused && trimmed.length > 0 && (search.results.length > 0 || showCreate)
const showDropdown = shouldShowSearchDropdown(focused, trimmed, search.results.length, showCreate)
return (
<div className="relative">
@@ -301,7 +426,7 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreat
{showDropdown && (
<SearchDropdownWithCreate
search={search}
onSelect={(title) => { onChange(title); setFocused(false) }}
onSelect={selectEntry}
query={value}
entries={entries}
onCreateAndOpen={onCreateAndOpenNote ? (title) => onSubmitWithCreate?.(title) : undefined}
@@ -311,8 +436,56 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreat
)
}
function AddRelationshipForm({ entries, onAddProperty, onCreateAndOpenNote }: {
function useRelationshipPanelState(
frontmatter: ParsedFrontmatter,
entries: VaultEntry[],
vaultPath: string | undefined,
onAddProperty?: (key: string, value: FrontmatterValue) => void,
onUpdateProperty?: (key: string, value: FrontmatterValue) => void,
onDeleteProperty?: (key: string) => void,
) {
const relationshipEntries = useMemo(() => extractRelationshipRefs(frontmatter), [frontmatter])
const resolvedVaultPath = useMemo(() => vaultPath ?? inferVaultPath(entries), [vaultPath, entries])
const handleRemoveRef = useCallback((key: string, refToRemove: string) => {
if (!onUpdateProperty || !onDeleteProperty) return
const group = relationshipEntries.find(g => g.key === key)
if (!group) return
const result = updateRefsForRemoval(group.refs, refToRemove)
if (result === null) onDeleteProperty(key)
else onUpdateProperty(key, result)
}, [relationshipEntries, onUpdateProperty, onDeleteProperty])
const handleAddRef = useCallback((key: string, ref: string) => {
if (!onUpdateProperty) return
const existing = relationshipEntries.find(g => g.key === key)?.refs ?? []
const result = updateRefsForAddition(existing, ref)
if (result !== false) onUpdateProperty(key, result)
}, [relationshipEntries, onUpdateProperty])
const canEdit = !!onUpdateProperty && !!onDeleteProperty
const existingRelKeys = useMemo(
() => new Set(relationshipEntries.map(g => g.key.toLowerCase())),
[relationshipEntries],
)
const missingSuggestedRels = useMemo(
() => (onAddProperty ? SUGGESTED_RELATIONSHIPS.filter(r => !existingRelKeys.has(r.toLowerCase())) : []),
[onAddProperty, existingRelKeys],
)
return {
relationshipEntries,
resolvedVaultPath,
handleRemoveRef,
handleAddRef,
canEdit,
missingSuggestedRels,
}
}
function AddRelationshipForm({ entries, vaultPath, onAddProperty, onCreateAndOpenNote }: {
entries: VaultEntry[]
vaultPath: string
onAddProperty: (key: string, value: FrontmatterValue) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
}) {
@@ -327,16 +500,16 @@ function AddRelationshipForm({ entries, onAddProperty, onCreateAndOpenNote }: {
const submitForm = useCallback((targetOverride?: string) => {
const key = relKey.trim()
const target = (targetOverride ?? relTarget).trim()
if (!key || !target) return
onAddProperty(key, `[[${target}]]`)
const rawTarget = (targetOverride ?? relTarget).trim()
if (!key || !rawTarget) return
onAddProperty(key, canonicalRefForTitle(rawTarget, entries, vaultPath))
resetForm()
}, [relKey, relTarget, onAddProperty, resetForm])
}, [relKey, relTarget, entries, vaultPath, onAddProperty, resetForm])
const addPropertyForKey = useCallback((title: string) => {
const key = relKey.trim()
if (key) onAddProperty(key, `[[${title}]]`)
}, [relKey, onAddProperty])
if (key) onAddProperty(key, canonicalRefForTitle(title, entries, vaultPath))
}, [relKey, entries, vaultPath, onAddProperty])
const handleCreateAndSubmit = useCreateAndOpen(onCreateAndOpenNote, addPropertyForKey, resetForm)
@@ -381,10 +554,9 @@ function updateRefsForRemoval(refs: string[], refToRemove: string): FrontmatterV
return remaining.length === 1 ? remaining[0] : remaining
}
function updateRefsForAddition(refs: string[], noteTitle: string): FrontmatterValue | false {
const newRef = `[[${noteTitle}]]`
if (refs.includes(newRef)) return false
const updated = [...refs, newRef]
function updateRefsForAddition(refs: string[], refToAdd: string): FrontmatterValue | false {
if (refs.includes(refToAdd)) return false
const updated = [...refs, refToAdd]
return updated.length === 1 ? updated[0] : updated
}
@@ -396,10 +568,11 @@ function DisabledLinkButton() {
const SUGGESTED_RELATIONSHIPS = ['Belongs to', 'Related to', 'Has'] as const
function SuggestedRelationshipSlot({ label, entries, onAdd, onCreateAndOpenNote }: {
function SuggestedRelationshipSlot({ label, entries, vaultPath, onAdd, onCreateAndOpenNote }: {
label: string
entries: VaultEntry[]
onAdd: (noteTitle: string) => void
vaultPath: string
onAdd: (ref: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
}) {
return (
@@ -407,6 +580,7 @@ function SuggestedRelationshipSlot({ label, entries, onAdd, onCreateAndOpenNote
<span className="mb-1 block text-[12px] text-muted-foreground/50">{label}</span>
<InlineAddNote
entries={entries}
vaultPath={vaultPath}
onAdd={onAdd}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
@@ -414,50 +588,30 @@ function SuggestedRelationshipSlot({ label, entries, onAdd, onCreateAndOpenNote
)
}
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote }: {
frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, vaultPath, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote }: {
frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; vaultPath?: string
onNavigate: (target: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
}) {
const relationshipEntries = useMemo(() => extractRelationshipRefs(frontmatter), [frontmatter])
const handleRemoveRef = useCallback((key: string, refToRemove: string) => {
if (!onUpdateProperty || !onDeleteProperty) return
const group = relationshipEntries.find(g => g.key === key)
if (!group) return
const result = updateRefsForRemoval(group.refs, refToRemove)
if (result === null) onDeleteProperty(key)
else onUpdateProperty(key, result)
}, [relationshipEntries, onUpdateProperty, onDeleteProperty])
const handleAddRef = useCallback((key: string, noteTitle: string) => {
if (!onUpdateProperty) return
const existing = relationshipEntries.find(g => g.key === key)?.refs ?? []
const result = updateRefsForAddition(existing, noteTitle)
if (result !== false) onUpdateProperty(key, result)
}, [relationshipEntries, onUpdateProperty])
const canEdit = !!onUpdateProperty && !!onDeleteProperty
const existingRelKeys = useMemo(
() => new Set(relationshipEntries.map(g => g.key.toLowerCase())),
[relationshipEntries],
)
const missingSuggestedRels = onAddProperty
? SUGGESTED_RELATIONSHIPS.filter(r => !existingRelKeys.has(r.toLowerCase()))
: []
const {
relationshipEntries,
resolvedVaultPath,
handleRemoveRef,
handleAddRef,
canEdit,
missingSuggestedRels,
} = useRelationshipPanelState(frontmatter, entries, vaultPath, onAddProperty, onUpdateProperty, onDeleteProperty)
return (
<div>
{relationshipEntries.map(({ key, refs }) => (
<RelationshipGroup
key={key} label={key} refs={refs} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate}
key={key} label={key} refs={refs} entries={entries} typeEntryMap={typeEntryMap} vaultPath={resolvedVaultPath} onNavigate={onNavigate}
onRemoveRef={canEdit ? (ref) => handleRemoveRef(key, ref) : undefined}
onAddRef={canEdit ? (noteTitle) => handleAddRef(key, noteTitle) : undefined}
onAddRef={canEdit ? (ref) => handleAddRef(key, ref) : undefined}
onCreateAndOpenNote={canEdit ? onCreateAndOpenNote : undefined}
/>
))}
@@ -466,12 +620,13 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
key={label}
label={label}
entries={entries}
onAdd={(noteTitle) => onAddProperty!(label, `[[${noteTitle}]]`)}
vaultPath={resolvedVaultPath}
onAdd={(ref) => onAddProperty!(label, ref)}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
))}
{onAddProperty
? <AddRelationshipForm entries={entries} onAddProperty={onAddProperty} onCreateAndOpenNote={onCreateAndOpenNote} />
? <AddRelationshipForm entries={entries} vaultPath={resolvedVaultPath} onAddProperty={onAddProperty} onCreateAndOpenNote={onCreateAndOpenNote} />
: <DisabledLinkButton />
}
</div>

View File

@@ -11,10 +11,10 @@ function targetMatchesEntry(target: string, entryPath: string, matchTargets: Set
return false
}
function refsMatchTargets(refs: string[], targets: Set<string>): boolean {
function refsMatchTargets(refs: string[], entryPath: string, targets: Set<string>): boolean {
return refs.some((ref) => {
const target = wikilinkTarget(ref)
return targets.has(target) || targets.has(target.split('/').pop() ?? '')
return targetMatchesEntry(target, entryPath, targets)
})
}
@@ -29,7 +29,7 @@ export function useReferencedBy(entry: VaultEntry | null, entries: VaultEntry[])
for (const other of entries) {
if (other.path === entry.path) continue
for (const [key, refs] of Object.entries(other.relationships)) {
if (key !== 'Type' && refsMatchTargets(refs, matchTargets)) {
if (key !== 'Type' && refsMatchTargets(refs, entry.path, matchTargets)) {
results.push({ entry: other, viaKey: key })
}
}

View File

@@ -3,6 +3,8 @@ import { Link } from '@phosphor-icons/react'
import { cn } from '@/lib/utils'
import type { VaultEntry } from '../../types'
import { resolveNoteIcon } from '../../utils/noteIcon'
import { detectPropertyType } from '../../utils/propertyTypes'
import { getMappedStatusStyle } from '../../utils/statusStyles'
import { getTypeColor, getTypeLightColor } from '../../utils/typeColors'
import { isUrlValue, normalizeUrl, openExternalUrl } from '../../utils/url'
import { resolveEntry, wikilinkDisplay, wikilinkTarget } from '../../utils/wikilink'
@@ -14,7 +16,7 @@ interface PropertyChipValue {
typeIcon: ComponentType<SVGAttributes<SVGSVGElement>> | null
style?: CSSProperties
action?: { kind: 'note'; entry: VaultEntry } | { kind: 'url'; url: string }
tone: 'neutral' | 'relationship' | 'url'
tone: 'neutral' | 'relationship' | 'status' | 'url'
}
const URL_CHIP_STYLE: CSSProperties = {
@@ -22,6 +24,8 @@ const URL_CHIP_STYLE: CSSProperties = {
color: 'var(--accent-blue)',
}
type ChipScalarValue = string | number | boolean | null
function toChipTestId(propName: string, index: number): string {
const slug = propName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
return `property-chip-${slug || 'value'}-${index}`
@@ -115,6 +119,29 @@ function resolveScalarChip(value: unknown): PropertyChipValue | null {
}
}
function resolveStatusChip(value: ChipScalarValue): PropertyChipValue | null {
const label = formatChipLabel(value)
if (!label) return null
const status = String(value)
const style = getMappedStatusStyle(status)
return {
label: `${label}`,
noteIcon: null,
typeIcon: null,
style: style ? { backgroundColor: style.bg, color: style.color } : undefined,
tone: 'status',
}
}
function resolvePropertyValueChip(propName: string, value: ChipScalarValue | undefined): PropertyChipValue | null {
if (value === undefined) return null
if (detectPropertyType(propName, value) !== 'status') {
return resolveScalarChip(value)
}
return resolveStatusChip(value)
}
function resolveRelationshipChipValues(
entry: VaultEntry,
propName: string,
@@ -135,7 +162,7 @@ function resolveScalarChipValues(entry: VaultEntry, propName: string): PropertyC
const rawValue = entry.properties[propertyKey]
const values = Array.isArray(rawValue) ? rawValue : [rawValue]
return values
.map((value) => resolveScalarChip(value))
.map((value) => resolvePropertyValueChip(propertyKey, value))
.filter((chip): chip is PropertyChipValue => chip !== null)
}
@@ -146,7 +173,7 @@ function resolvePropertyChipValues(
typeEntryMap: Record<string, VaultEntry>,
): PropertyChipValue[] {
if (propName.toLowerCase() === 'status') {
const statusChip = resolveScalarChip(entry.status)
const statusChip = resolvePropertyValueChip(propName, entry.status)
return statusChip ? [statusChip] : []
}

View File

@@ -1,14 +1,17 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useAppSave } from './useAppSave'
import type { VaultEntry } from '../types'
import { isTauri } from '../mock-tauri'
import { invoke } from '@tauri-apps/api/core'
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
isTauri: vi.fn(() => false),
mockInvoke: vi.fn().mockResolvedValue(undefined),
updateMockContent: vi.fn(),
}))
function makeEntry(path: string, title = 'Test', filename = 'test.md'): VaultEntry {
@@ -32,12 +35,18 @@ describe('useAppSave', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
vi.mocked(isTauri).mockReturnValue(false)
deps.unsavedPaths = new Set()
deps.tabs = []
deps.activeTabPath = null
deps.handleRenameNote.mockResolvedValue(undefined)
})
afterEach(() => {
vi.useRealTimers()
})
function renderSave(overrides = {}) {
return renderHook(() => useAppSave({ ...deps, ...overrides }))
}
@@ -91,4 +100,85 @@ describe('useAppSave', () => {
const { result } = renderSave()
expect(typeof result.current.handleContentChange).toBe('function')
})
it('debounces untitled H1 auto-rename until the user pauses typing', async () => {
vi.useFakeTimers()
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(invoke).mockImplementation(async (command: string, args?: Record<string, unknown>) => {
if (command === 'save_note_content') return undefined
if (command === 'auto_rename_untitled') return { new_path: '/vault/fresh-title.md', updated_files: 0 }
if (command === 'reload_vault_entry') return makeEntry('/vault/fresh-title.md', 'Fresh Title', 'fresh-title.md')
if (command === 'get_note_content' && args?.path === '/vault/fresh-title.md') return '# Fresh Title\n\nBody'
return undefined
})
const entry = makeEntry('/vault/untitled-note-123.md', 'Untitled Note 123', 'untitled-note-123.md')
const tabs = [{ entry, content: '# Fresh Title\n\nBody' }]
const { result } = renderSave({
tabs,
activeTabPath: entry.path,
unsavedPaths: new Set([entry.path]),
})
await act(async () => {
result.current.handleContentChange(entry.path, '# Fresh Title\n\nBody')
await vi.advanceTimersByTimeAsync(500)
})
expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('auto_rename_untitled', expect.anything())
await act(async () => {
await vi.advanceTimersByTimeAsync(2_499)
})
expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('auto_rename_untitled', expect.anything())
await act(async () => {
await vi.advanceTimersByTimeAsync(1)
})
expect(vi.mocked(invoke)).toHaveBeenCalledWith('auto_rename_untitled', {
vaultPath: '/vault',
notePath: entry.path,
})
expect(deps.replaceEntry).toHaveBeenCalledWith(
entry.path,
expect.objectContaining({ path: '/vault/fresh-title.md', filename: 'fresh-title.md' }),
'# Fresh Title\n\nBody',
)
})
it('cancels a pending untitled auto-rename when the user navigates away', async () => {
vi.useFakeTimers()
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(invoke).mockImplementation(async (command: string) => {
if (command === 'save_note_content') return undefined
if (command === 'auto_rename_untitled') return { new_path: '/vault/fresh-title.md', updated_files: 0 }
return undefined
})
const entry = makeEntry('/vault/untitled-note-123.md', 'Untitled Note 123', 'untitled-note-123.md')
const tabs = [{ entry, content: '# Fresh Title\n\nBody' }]
const { result, rerender } = renderHook(
({ currentActiveTabPath }: { currentActiveTabPath: string | null }) => useAppSave({
...deps,
tabs,
activeTabPath: currentActiveTabPath,
unsavedPaths: new Set([entry.path]),
}),
{ initialProps: { currentActiveTabPath: entry.path } },
)
await act(async () => {
result.current.handleContentChange(entry.path, '# Fresh Title\n\nBody')
await vi.advanceTimersByTimeAsync(500)
})
rerender({ currentActiveTabPath: '/vault/other.md' })
await act(async () => {
await vi.advanceTimersByTimeAsync(2_500)
})
expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('auto_rename_untitled', expect.anything())
})
})

View File

@@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef } from 'react'
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { useEditorSaveWithLinks } from './useEditorSaveWithLinks'
import { needsRenameOnSave } from './useNoteRename'
import { flushEditorContent } from '../utils/autoSave'
import { extractH1TitleFromContent } from '../utils/noteTitle'
import { isTauri } from '../mock-tauri'
import type { VaultEntry } from '../types'
@@ -11,6 +12,13 @@ interface TabState {
content: string
}
const UNTITLED_RENAME_DEBOUNCE_MS = 2500
interface PendingUntitledRename {
path: string
timer: ReturnType<typeof setTimeout>
}
function findUnsavedFallback(
tabs: TabState[], activeTabPath: string | null, unsavedPaths: Set<string>,
): { path: string; content: string } | undefined {
@@ -27,6 +35,74 @@ function activeTabNeedsRename(tabs: TabState[], activeTabPath: string | null): {
: null
}
function isUntitledRenameCandidate(path: string): boolean {
const filename = path.split('/').pop() ?? ''
const stem = filename.replace(/\.md$/, '')
return stem.startsWith('untitled-') && /\d+$/.test(stem)
}
function shouldScheduleUntitledRename(path: string, content: string): boolean {
return isTauri()
&& isUntitledRenameCandidate(path)
&& extractH1TitleFromContent(content) !== null
}
function matchingPendingRename(
pending: PendingUntitledRename | null,
path?: string,
): PendingUntitledRename | null {
if (!pending) return null
if (path && pending.path !== path) return null
return pending
}
function takePendingRename(
pendingRenameRef: MutableRefObject<PendingUntitledRename | null>,
path?: string,
): PendingUntitledRename | null {
const pending = matchingPendingRename(pendingRenameRef.current, path)
if (!pending) return null
clearTimeout(pending.timer)
pendingRenameRef.current = null
return pending
}
function schedulePendingRename(
pendingRenameRef: MutableRefObject<PendingUntitledRename | null>,
path: string,
onFire: (path: string) => void,
): void {
takePendingRename(pendingRenameRef)
const timer = setTimeout(() => {
const pending = takePendingRename(pendingRenameRef, path)
if (pending) onFire(pending.path)
}, UNTITLED_RENAME_DEBOUNCE_MS)
pendingRenameRef.current = { path, timer }
}
function pendingRenameOutsideActiveTab(
pendingRenameRef: MutableRefObject<PendingUntitledRename | null>,
activeTabPath: string | null,
): string | null {
const pending = pendingRenameRef.current
if (!pending || pending.path === activeTabPath) return null
return pending.path
}
async function reloadAutoRenamedNote(
oldPath: string,
newPath: string,
replaceEntry: AppSaveDeps['replaceEntry'],
loadModifiedFiles: AppSaveDeps['loadModifiedFiles'],
): Promise<void> {
const [newEntry, newContent] = await Promise.all([
invoke<VaultEntry>('reload_vault_entry', { path: newPath }),
invoke<string>('get_note_content', { path: newPath }),
])
replaceEntry(oldPath, { ...newEntry, path: newPath }, newContent)
loadModifiedFiles()
}
interface AppSaveDeps {
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
setTabs: Parameters<typeof useEditorSaveWithLinks>[0]['setTabs']
@@ -49,41 +125,63 @@ export function useAppSave({
handleRenameNote, replaceEntry, resolvedPath,
}: AppSaveDeps) {
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
const pendingUntitledRenameRef = useRef<PendingUntitledRename | null>(null)
const onAfterSave = useCallback(() => {
loadModifiedFiles()
}, [loadModifiedFiles])
const onNotePersisted = useCallback((path: string) => {
clearUnsaved(path)
if (path.endsWith('.yml')) reloadViews?.()
// Auto-rename untitled notes when they have an H1 heading
const filename = path.split('/').pop() ?? ''
const stem = filename.replace(/\.md$/, '')
if (isTauri() && stem.startsWith('untitled-') && /\d+$/.test(stem)) {
invoke<{ new_path: string; updated_files: number } | null>('auto_rename_untitled', {
const cancelPendingUntitledRename = useCallback((path?: string) => (
takePendingRename(pendingUntitledRenameRef, path) !== null
), [])
const executeUntitledRename = useCallback(async (path: string) => {
try {
const result = await invoke<{ new_path: string; updated_files: number } | null>('auto_rename_untitled', {
vaultPath: resolvedPath,
notePath: path,
}).then((result) => {
if (result) {
// Re-read the renamed file content and entry, then replace
Promise.all([
invoke<VaultEntry>('reload_vault_entry', { path: result.new_path }),
invoke<string>('get_note_content', { path: result.new_path }),
]).then(([newEntry, newContent]) => {
replaceEntry(path, { ...newEntry, path: result.new_path }, newContent)
loadModifiedFiles()
}).catch(() => { /* ignore reload failure */ })
}
}).catch(() => { /* auto-rename is best-effort */ })
})
if (!result) return false
await reloadAutoRenamedNote(path, result.new_path, replaceEntry, loadModifiedFiles)
return true
} catch {
return false
}
}, [clearUnsaved, reloadViews, resolvedPath, replaceEntry, loadModifiedFiles])
}, [resolvedPath, replaceEntry, loadModifiedFiles])
const flushPendingUntitledRename = useCallback(async (path?: string) => {
const pending = takePendingRename(pendingUntitledRenameRef, path)
if (!pending) return false
return executeUntitledRename(pending.path)
}, [executeUntitledRename])
const scheduleUntitledRename = useCallback((path: string, content: string) => {
if (!shouldScheduleUntitledRename(path, content)) {
cancelPendingUntitledRename(path)
return
}
schedulePendingRename(pendingUntitledRenameRef, path, (pendingPath) => {
void executeUntitledRename(pendingPath)
})
}, [cancelPendingUntitledRename, executeUntitledRename])
const onNotePersisted = useCallback((path: string, content: string) => {
clearUnsaved(path)
if (path.endsWith('.yml')) reloadViews?.()
scheduleUntitledRename(path, content)
}, [clearUnsaved, reloadViews, scheduleUntitledRename])
const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
updateEntry, setTabs, setToastMessage, onAfterSave, onNotePersisted,
})
useEffect(() => { contentChangeRef.current = handleContentChange }, [handleContentChange])
useEffect(() => () => { cancelPendingUntitledRename() }, [cancelPendingUntitledRename])
useEffect(() => {
const pendingPath = pendingRenameOutsideActiveTab(pendingUntitledRenameRef, activeTabPath)
if (pendingPath) cancelPendingUntitledRename(pendingPath)
}, [activeTabPath, cancelPendingUntitledRename])
// Refs for stable closure in flushBeforeAction
const tabsRef = useRef(tabs)
@@ -99,29 +197,33 @@ export function useAppSave({
isUnsaved: (p) => unsavedPathsRef.current.has(p),
onSaved: (p) => { clearUnsaved(p) },
})
await flushPendingUntitledRename(path)
} catch (err) {
setToastMessage(`Auto-save failed: ${err}`)
throw err
}
}, [savePendingForPath, clearUnsaved, setToastMessage])
}, [savePendingForPath, clearUnsaved, setToastMessage, flushPendingUntitledRename])
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
await savePendingForPath(path)
cancelPendingUntitledRename(path)
await handleRenameNote(path, newTitle, resolvedPath, replaceEntry).then(loadModifiedFiles)
}, [handleRenameNote, resolvedPath, replaceEntry, savePendingForPath, loadModifiedFiles])
}, [handleRenameNote, resolvedPath, replaceEntry, savePendingForPath, loadModifiedFiles, cancelPendingUntitledRename])
const handleSave = useCallback(async () => {
await handleSaveRaw(findUnsavedFallback(tabs, activeTabPath, unsavedPaths))
const flushedUntitledRename = await flushPendingUntitledRename(activeTabPath ?? undefined)
const rename = activeTabNeedsRename(tabs, activeTabPath)
if (rename) await handleRenameTab(rename.path, rename.title)
}, [handleSaveRaw, handleRenameTab, tabs, activeTabPath, unsavedPaths])
if (!flushedUntitledRename && rename) await handleRenameTab(rename.path, rename.title)
}, [handleSaveRaw, handleRenameTab, tabs, activeTabPath, unsavedPaths, flushPendingUntitledRename])
const handleTitleSync = useCallback((path: string, newTitle: string) => {
cancelPendingUntitledRename(path)
savePendingForPath(path)
.then(() => handleRenameNote(path, newTitle, resolvedPath, replaceEntry))
.then(loadModifiedFiles)
.catch((err) => console.error('Title rename failed:', err))
}, [handleRenameNote, resolvedPath, replaceEntry, savePendingForPath, loadModifiedFiles])
}, [handleRenameNote, resolvedPath, replaceEntry, savePendingForPath, loadModifiedFiles, cancelPendingUntitledRename])
return {
contentChangeRef,

View File

@@ -566,7 +566,7 @@ describe('useNoteActions hook', () => {
new_title: 'Sprint Retro',
old_title: 'Weekly Review',
}))
expect(setToastMessage).toHaveBeenCalledWith('Renamed — updated 2 wiki links')
expect(setToastMessage).toHaveBeenCalledWith('Updated 2 notes')
})
it('handleRenameNote passes null old_title when entry not found', async () => {

View File

@@ -148,5 +148,6 @@ export function useNoteActions(config: NoteActionsConfig) {
config.onFrontmatterPersisted?.()
}, [runFrontmatterOp, config]),
handleRenameNote: rename.handleRenameNote,
handleRenameFilename: rename.handleRenameFilename,
}
}

View File

@@ -114,12 +114,12 @@ describe('buildNewEntry', () => {
describe('generateUntitledName', () => {
it('returns base name when no conflicts', () => {
expect(generateUntitledName([], 'Note')).toBe('Untitled note')
expect(generateUntitledName({ entries: [], type: 'Note' })).toBe('Untitled note')
})
it('appends counter when base name exists', () => {
const entries = [makeEntry({ title: 'Untitled note' })]
expect(generateUntitledName(entries, 'Note')).toBe('Untitled note 2')
expect(generateUntitledName({ entries, type: 'Note' })).toBe('Untitled note 2')
})
it('increments counter past existing numbered entries', () => {
@@ -128,87 +128,87 @@ describe('generateUntitledName', () => {
makeEntry({ title: 'Untitled note 2' }),
makeEntry({ title: 'Untitled note 3' }),
]
expect(generateUntitledName(entries, 'Note')).toBe('Untitled note 4')
expect(generateUntitledName({ entries, type: 'Note' })).toBe('Untitled note 4')
})
it('uses type name in lowercase', () => {
expect(generateUntitledName([], 'Project')).toBe('Untitled project')
expect(generateUntitledName({ entries: [], type: 'Project' })).toBe('Untitled project')
})
it('avoids names in the pending set', () => {
const pending = new Set(['Untitled note'])
expect(generateUntitledName([], 'Note', pending)).toBe('Untitled note 2')
expect(generateUntitledName({ entries: [], type: 'Note', pendingTitles: pending })).toBe('Untitled note 2')
})
it('avoids both existing and pending names', () => {
const entries = [makeEntry({ title: 'Untitled note' })]
const pending = new Set(['Untitled note 2'])
expect(generateUntitledName(entries, 'Note', pending)).toBe('Untitled note 3')
expect(generateUntitledName({ entries, type: 'Note', pendingTitles: pending })).toBe('Untitled note 3')
})
})
describe('entryMatchesTarget', () => {
it('matches by exact title (case-insensitive)', () => {
const entry = makeEntry({ title: 'My Project' })
expect(entryMatchesTarget(entry, 'my project')).toBe(true)
expect(entryMatchesTarget({ entry, target: 'my project' })).toBe(true)
})
it('matches by alias', () => {
const entry = makeEntry({ aliases: ['MP', 'TheProject'] })
expect(entryMatchesTarget(entry, 'mp')).toBe(true)
expect(entryMatchesTarget({ entry, target: 'mp' })).toBe(true)
})
it('matches legacy path-style target via filename stem', () => {
const entry = makeEntry({ filename: 'my-project.md' })
expect(entryMatchesTarget(entry, 'project/my-project')).toBe(true)
expect(entryMatchesTarget({ entry, target: 'project/my-project' })).toBe(true)
})
it('matches by filename stem', () => {
const entry = makeEntry({ filename: 'my-project.md' })
expect(entryMatchesTarget(entry, 'my-project')).toBe(true)
expect(entryMatchesTarget({ entry, target: 'my-project' })).toBe(true)
})
it('matches when target as words matches title', () => {
const entry = makeEntry({ title: 'my project' })
expect(entryMatchesTarget(entry, 'my-project')).toBe(true)
expect(entryMatchesTarget({ entry, target: 'my-project' })).toBe(true)
})
it('returns false when nothing matches', () => {
const entry = makeEntry({ title: 'Something Else', aliases: [], filename: 'else.md' })
expect(entryMatchesTarget(entry, 'nonexistent')).toBe(false)
expect(entryMatchesTarget({ entry, target: 'nonexistent' })).toBe(false)
})
it('handles pipe syntax targets', () => {
const entry = makeEntry({ path: '/vault/project/alpha.md', filename: 'alpha.md', title: 'Alpha' })
expect(entryMatchesTarget(entry, 'project/alpha|Alpha Project')).toBe(true)
expect(entryMatchesTarget({ entry, target: 'project/alpha|Alpha Project' })).toBe(true)
})
})
describe('buildNoteContent', () => {
it('generates frontmatter with title and status for regular types', () => {
const content = buildNoteContent('My Note', 'Note', 'Active')
const content = buildNoteContent({ title: 'My Note', type: 'Note', status: 'Active' })
expect(content).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n')
})
it('omits title when null', () => {
const content = buildNoteContent(null, 'Note', 'Active')
const content = buildNoteContent({ title: null, type: 'Note', status: 'Active' })
expect(content).toBe('---\ntype: Note\nstatus: Active\n---\n')
})
it('omits status when null', () => {
const content = buildNoteContent('AI', 'Topic', null)
const content = buildNoteContent({ title: 'AI', type: 'Topic', status: null })
expect(content).toBe('---\ntitle: AI\ntype: Topic\n---\n')
})
it('includes template body when provided', () => {
const content = buildNoteContent('My Project', 'Project', 'Active', '## Objective\n\n## Notes\n\n')
const content = buildNoteContent({ title: 'My Project', type: 'Project', status: 'Active', template: '## Objective\n\n## Notes\n\n' })
expect(content).not.toContain('# My Project')
expect(content).toContain('## Objective')
expect(content).toContain('## Notes')
})
it('ignores null template', () => {
const content = buildNoteContent('My Note', 'Note', 'Active', null)
const content = buildNoteContent({ title: 'My Note', type: 'Note', status: 'Active', template: null })
expect(content).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n')
})
})
@@ -216,20 +216,20 @@ describe('buildNoteContent', () => {
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')
expect(resolveTemplate({ entries: [typeEntry], typeName: '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)
expect(resolveTemplate({ entries: [], typeName: 'Project' })).toBe(DEFAULT_TEMPLATES.Project)
})
it('returns null when no template and no default', () => {
expect(resolveTemplate([], 'CustomType')).toBeNull()
expect(resolveTemplate({ entries: [], typeName: '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')
expect(resolveTemplate({ entries: [typeEntry], typeName: 'Project' })).toBe('## Custom\n\n')
})
})
@@ -244,7 +244,7 @@ describe('DEFAULT_TEMPLATES', () => {
describe('resolveNewNote', () => {
it('creates note at vault root', () => {
const { entry, content } = resolveNewNote('My Project', 'Project', '/my/vault')
const { entry, content } = resolveNewNote({ title: 'My Project', type: 'Project', vaultPath: '/my/vault' })
expect(entry.path).toBe('/my/vault/my-project.md')
expect(entry.isA).toBe('Project')
expect(entry.status).toBe('Active')
@@ -253,35 +253,35 @@ describe('resolveNewNote', () => {
})
it('creates custom type note at vault root', () => {
const { entry } = resolveNewNote('First Recipe', 'Recipe', '/my/vault')
const { entry } = resolveNewNote({ title: 'First Recipe', type: 'Recipe', vaultPath: '/my/vault' })
expect(entry.path).toBe('/my/vault/first-recipe.md')
})
it('omits status for Topic type', () => {
const { entry, content } = resolveNewNote('Machine Learning', 'Topic', '/my/vault')
const { entry, content } = resolveNewNote({ title: 'Machine Learning', type: 'Topic', vaultPath: '/my/vault' })
expect(entry.status).toBeNull()
expect(content).not.toContain('status:')
})
it('omits status for Person type', () => {
const { entry } = resolveNewNote('John Doe', 'Person', '/my/vault')
const { entry } = resolveNewNote({ title: 'John Doe', type: 'Person', vaultPath: '/my/vault' })
expect(entry.status).toBeNull()
})
it('uses provided vault path', () => {
const { entry } = resolveNewNote('Test', 'Note', '/other/vault')
const { entry } = resolveNewNote({ title: 'Test', type: 'Note', vaultPath: '/other/vault' })
expect(entry.path).toBe('/other/vault/test.md')
})
it('produces a valid path for custom types with special characters', () => {
const { entry } = resolveNewNote('My Note', 'Q&A', '/vault')
const { entry } = resolveNewNote({ title: 'My Note', type: 'Q&A', vaultPath: '/vault' })
expect(entry.path).not.toContain('//')
expect(entry.path).toMatch(/\.md$/)
expect(entry.filename).not.toBe('.md')
})
it('produces a valid path when type is all special characters', () => {
const { entry } = resolveNewNote('My Note', '+++', '/vault')
const { entry } = resolveNewNote({ title: 'My Note', type: '+++', vaultPath: '/vault' })
expect(entry.path).not.toContain('//')
expect(entry.path).toMatch(/\.md$/)
})
@@ -289,7 +289,7 @@ describe('resolveNewNote', () => {
describe('resolveNewType', () => {
it('creates a type entry at vault root', () => {
const { entry, content } = resolveNewType('Recipe', '/my/vault')
const { entry, content } = resolveNewType({ typeName: 'Recipe', vaultPath: '/my/vault' })
expect(entry.path).toBe('/my/vault/recipe.md')
expect(entry.isA).toBe('Type')
expect(entry.status).toBeNull()
@@ -298,7 +298,7 @@ describe('resolveNewType', () => {
})
it('uses provided vault path instead of hardcoded path', () => {
const { entry } = resolveNewType('Responsibility', '/other/vault')
const { entry } = resolveNewType({ typeName: 'Responsibility', vaultPath: '/other/vault' })
expect(entry.path).toBe('/other/vault/responsibility.md')
expect(entry.path).not.toContain('/Users/luca/Laputa')
})

View File

@@ -79,11 +79,11 @@ describe('buildNewEntry', () => {
describe('generateUntitledName', () => {
it('returns base name when no conflicts', () => {
expect(generateUntitledName([], 'Note')).toBe('Untitled note')
expect(generateUntitledName({ entries: [], type: 'Note' })).toBe('Untitled note')
})
it('appends counter when base name exists', () => {
expect(generateUntitledName([makeEntry({ title: 'Untitled note' })], 'Note')).toBe('Untitled note 2')
expect(generateUntitledName({ entries: [makeEntry({ title: 'Untitled note' })], type: 'Note' })).toBe('Untitled note 2')
})
it('increments counter past existing numbered entries', () => {
@@ -92,50 +92,50 @@ describe('generateUntitledName', () => {
makeEntry({ title: 'Untitled note 2' }),
makeEntry({ title: 'Untitled note 3' }),
]
expect(generateUntitledName(entries, 'Note')).toBe('Untitled note 4')
expect(generateUntitledName({ entries, type: 'Note' })).toBe('Untitled note 4')
})
it('avoids names in the pending set', () => {
expect(generateUntitledName([], 'Note', new Set(['Untitled note']))).toBe('Untitled note 2')
expect(generateUntitledName({ entries: [], type: 'Note', pendingTitles: new Set(['Untitled note']) })).toBe('Untitled note 2')
})
})
describe('entryMatchesTarget', () => {
it('matches by exact title (case-insensitive)', () => {
expect(entryMatchesTarget(makeEntry({ title: 'My Project' }), 'my project')).toBe(true)
expect(entryMatchesTarget({ entry: makeEntry({ title: 'My Project' }), target: 'my project' })).toBe(true)
})
it('matches by alias', () => {
expect(entryMatchesTarget(makeEntry({ aliases: ['MP'] }), 'mp')).toBe(true)
expect(entryMatchesTarget({ entry: makeEntry({ aliases: ['MP'] }), target: 'mp' })).toBe(true)
})
it('returns false when nothing matches', () => {
expect(entryMatchesTarget(makeEntry({ title: 'Something' }), 'nonexistent')).toBe(false)
expect(entryMatchesTarget({ entry: makeEntry({ title: 'Something' }), target: 'nonexistent' })).toBe(false)
})
})
describe('buildNoteContent', () => {
it('generates frontmatter with title and status', () => {
expect(buildNoteContent('My Note', 'Note', 'Active')).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n')
expect(buildNoteContent({ title: 'My Note', type: 'Note', status: 'Active' })).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n')
})
it('omits title when null', () => {
expect(buildNoteContent(null, 'Note', 'Active')).toBe('---\ntype: Note\nstatus: Active\n---\n')
expect(buildNoteContent({ title: null, type: 'Note', status: 'Active' })).toBe('---\ntype: Note\nstatus: Active\n---\n')
})
it('omits status when null', () => {
expect(buildNoteContent('AI', 'Topic', null)).toBe('---\ntitle: AI\ntype: Topic\n---\n')
expect(buildNoteContent({ title: 'AI', type: 'Topic', status: null })).toBe('---\ntitle: AI\ntype: Topic\n---\n')
})
it('includes template body when provided', () => {
const content = buildNoteContent('P', 'Project', 'Active', '## Objective\n\n')
const content = buildNoteContent({ title: 'P', type: 'Project', status: 'Active', template: '## Objective\n\n' })
expect(content).toContain('## Objective')
})
})
describe('resolveNewNote', () => {
it('creates note at vault root', () => {
const { entry, content } = resolveNewNote('My Project', 'Project', '/vault')
const { entry, content } = resolveNewNote({ title: 'My Project', type: 'Project', vaultPath: '/vault' })
expect(entry.path).toBe('/vault/my-project.md')
expect(entry.isA).toBe('Project')
expect(entry.status).toBe('Active')
@@ -143,14 +143,14 @@ describe('resolveNewNote', () => {
})
it('omits status for Topic type', () => {
const { entry } = resolveNewNote('ML', 'Topic', '/vault')
const { entry } = resolveNewNote({ title: 'ML', type: 'Topic', vaultPath: '/vault' })
expect(entry.status).toBeNull()
})
})
describe('resolveNewType', () => {
it('creates a type entry', () => {
const { entry, content } = resolveNewType('Recipe', '/vault')
const { entry, content } = resolveNewType({ typeName: 'Recipe', vaultPath: '/vault' })
expect(entry.path).toBe('/vault/recipe.md')
expect(entry.isA).toBe('Type')
expect(content).toContain('type: Type')
@@ -160,15 +160,15 @@ describe('resolveNewType', () => {
describe('resolveTemplate', () => {
it('returns template from type entry when set', () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', template: '## Ingredients\n\n' })
expect(resolveTemplate([typeEntry], 'Recipe')).toBe('## Ingredients\n\n')
expect(resolveTemplate({ entries: [typeEntry], typeName: 'Recipe' })).toBe('## Ingredients\n\n')
})
it('falls back to DEFAULT_TEMPLATES', () => {
expect(resolveTemplate([], 'Project')).toBe(DEFAULT_TEMPLATES.Project)
expect(resolveTemplate({ entries: [], typeName: 'Project' })).toBe(DEFAULT_TEMPLATES.Project)
})
it('returns null when no template and no default', () => {
expect(resolveTemplate([], 'CustomType')).toBeNull()
expect(resolveTemplate({ entries: [], typeName: 'CustomType' })).toBeNull()
})
})
@@ -266,12 +266,42 @@ describe('useNoteCreation hook', () => {
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate avoids filename collisions when called twice in the same second', () => {
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
})
const filenames = addEntry.mock.calls.map(([entry]: [VaultEntry]) => entry.filename)
expect(filenames).toEqual([
'untitled-note-1700000000.md',
'untitled-note-1700000000-2.md',
])
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate accepts custom type', () => {
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleCreateNoteImmediate('Project') })
expect(addEntry.mock.calls[0][0].isA).toBe('Project')
})
it('handleCreateNoteImmediate slugifies custom type names for filenames', () => {
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => {
result.current.handleCreateNoteImmediate('Q&A / Ops')
})
expect(addEntry.mock.calls[0][0].filename).toBe('untitled-q-a-ops-1700000000.md')
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate tracks unsaved state', async () => {
const trackUnsaved = vi.fn()
const markContentPending = vi.fn()

View File

@@ -35,10 +35,16 @@ function slug_to_title(slug: string): string {
}
/** Generate a unique "Untitled <type>" name by checking existing entries and pending names. */
export function generateUntitledName(entries: VaultEntry[], type: string, pending?: Set<string>): string {
export interface UntitledNameParams {
entries: VaultEntry[]
type: string
pendingTitles?: Set<string>
}
export function generateUntitledName({ entries, type, pendingTitles }: UntitledNameParams): string {
const baseName = `Untitled ${type.toLowerCase()}`
const existingTitles = new Set(entries.map(e => e.title))
if (pending) pending.forEach(n => existingTitles.add(n))
if (pendingTitles) pendingTitles.forEach((title) => existingTitles.add(title))
let title = baseName
let counter = 2
while (existingTitles.has(title)) {
@@ -48,8 +54,13 @@ export function generateUntitledName(entries: VaultEntry[], type: string, pendin
return title
}
export function entryMatchesTarget(e: VaultEntry, target: string): boolean {
return resolveEntry([e], target) === e
export interface EntryMatchParams {
entry: VaultEntry
target: string
}
export function entryMatchesTarget({ entry, target }: EntryMatchParams): boolean {
return resolveEntry([entry], target) === entry
}
const NO_STATUS_TYPES = new Set(['Topic', 'Person', 'Journal'])
@@ -63,12 +74,24 @@ export const DEFAULT_TEMPLATES: Record<string, string> = {
}
/** 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)
export interface TemplateLookupParams {
entries: VaultEntry[]
typeName: string
}
export function resolveTemplate({ entries, typeName }: TemplateLookupParams): string | null {
const typeEntry = entries.find((entry) => entry.isA === 'Type' && entry.title === typeName)
return typeEntry?.template ?? DEFAULT_TEMPLATES[typeName] ?? null
}
export function buildNoteContent(title: string | null, type: string, status: string | null, template?: string | null): string {
export interface NoteContentParams {
title: string | null
type: string
status: string | null
template?: string | null
}
export function buildNoteContent({ title, type, status, template }: NoteContentParams): string {
const lines = ['---']
if (title) lines.push(`title: ${title}`)
lines.push(`type: ${type}`)
@@ -78,14 +101,26 @@ export function buildNoteContent(title: string | null, type: string, status: str
return `${lines.join('\n')}\n${body}`
}
export function resolveNewNote(title: string, type: string, vaultPath: string, template?: string | null): { entry: VaultEntry; content: string } {
export interface NewNoteParams {
title: string
type: string
vaultPath: string
template?: string | null
}
export function resolveNewNote({ title, type, vaultPath, template }: NewNoteParams): { entry: VaultEntry; content: string } {
const slug = slugify(title)
const status = NO_STATUS_TYPES.has(type) ? null : 'Active'
const entry = buildNewEntry({ path: `${vaultPath}/${slug}.md`, slug, title, type, status })
return { entry, content: buildNoteContent(title, type, status, template) }
return { entry, content: buildNoteContent({ title, type, status, template }) }
}
export function resolveNewType(typeName: string, vaultPath: string): { entry: VaultEntry; content: string } {
export interface NewTypeParams {
typeName: string
vaultPath: string
}
export function resolveNewType({ typeName, vaultPath }: NewTypeParams): { entry: VaultEntry; content: string } {
const slug = slugify(typeName)
const entry = buildNewEntry({ path: `${vaultPath}/${slug}.md`, slug, title: typeName, type: 'Type', status: null })
return { entry, content: `---\ntype: Type\n---\n` }
@@ -168,7 +203,7 @@ function openDailyNote(entries: VaultEntry[], selectNote: (e: VaultEntry) => voi
interface ImmediateCreateDeps {
entries: VaultEntry[]
vaultPath: string
pendingNames: Set<string>
pendingSlugs: Set<string>
openTabWithContent: (entry: VaultEntry, content: string) => void
addEntry: (entry: VaultEntry) => void
trackUnsaved?: (path: string) => void
@@ -176,21 +211,32 @@ interface ImmediateCreateDeps {
}
/** Generate a unique untitled filename using a timestamp. */
function generateUntitledFilename(type: string): string {
function generateUntitledFilename(entries: VaultEntry[], type: string, pendingSlugs?: Set<string>): string {
const ts = Math.floor(Date.now() / 1000)
const prefix = type === 'Note' ? 'untitled-note' : `untitled-${type.toLowerCase()}`
return `${prefix}-${ts}`
const typeSlug = type === 'Note' ? 'note' : slugify(type)
const base = `untitled-${typeSlug}-${ts}`
const existingSlugs = new Set(entries.map((entry) => entry.filename.replace(/\.md$/, '')))
let candidate = base
let suffix = 2
while (existingSlugs.has(candidate) || pendingSlugs?.has(candidate)) {
candidate = `${base}-${suffix}`
suffix += 1
}
pendingSlugs?.add(candidate)
return candidate
}
/** Create an untitled note without persisting to disk (deferred save). */
function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): void {
const noteType = type || 'Note'
const slug = generateUntitledFilename(noteType)
const slug = generateUntitledFilename(deps.entries, noteType, deps.pendingSlugs)
const title = slug_to_title(slug)
const template = resolveTemplate(deps.entries, noteType)
const template = resolveTemplate({ entries: deps.entries, typeName: noteType })
const status = NO_STATUS_TYPES.has(noteType) ? null : 'Active'
const entry = buildNewEntry({ path: `${deps.vaultPath}/${slug}.md`, slug, title, type: noteType, status })
const content = buildNoteContent(null, noteType, status, template)
const content = buildNoteContent({ title: null, type: noteType, status, template })
deps.openTabWithContent(entry, content)
addEntryWithMock(entry, content, deps.addEntry)
deps.trackUnsaved?.(entry.path)
@@ -210,8 +256,8 @@ interface RelationshipCreateDeps {
/** Create a note for a relationship link; persist in background. */
function createNoteForRelationship(deps: RelationshipCreateDeps, title: string): void {
const template = resolveTemplate(deps.entries, 'Note')
const resolved = resolveNewNote(title, 'Note', deps.vaultPath, template)
const template = resolveTemplate({ entries: deps.entries, typeName: 'Note' })
const resolved = resolveNewNote({ title, type: 'Note', vaultPath: deps.vaultPath, template })
deps.openTabWithContent(resolved.entry, resolved.content)
addEntryWithMock(resolved.entry, resolved.content, deps.addEntry)
persistNewNote(resolved.entry.path, resolved.content)
@@ -251,7 +297,7 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
setToastMessage('Failed to create note — disk write error')
}, [removeEntry, setToastMessage])
const pendingNamesRef = useRef<Set<string>>(new Set())
const pendingSlugsRef = useRef<Set<string>>(new Set())
const persistNew: PersistFn = useCallback(
(resolved) => createAndPersist(resolved, addEntry, openTabWithContent, {
@@ -264,14 +310,14 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
)
const handleCreateNote = useCallback((title: string, type: string) => {
const template = resolveTemplate(entries, type)
persistNew(resolveNewNote(title, type, config.vaultPath, template))
const template = resolveTemplate({ entries, typeName: type })
persistNew(resolveNewNote({ title, type, vaultPath: config.vaultPath, template }))
trackEvent('note_created', { has_type: type !== 'Note' ? 1 : 0, creation_path: 'plus_button' })
}, [entries, persistNew, config.vaultPath])
const handleCreateNoteImmediate = useCallback((type?: string) => {
createNoteImmediate({
entries, vaultPath: config.vaultPath, pendingNames: pendingNamesRef.current,
entries, vaultPath: config.vaultPath, pendingSlugs: pendingSlugsRef.current,
openTabWithContent, addEntry, trackUnsaved: config.trackUnsaved, markContentPending: config.markContentPending,
}, type)
trackEvent('note_created', { has_type: type ? 1 : 0, creation_path: type ? 'type_section' : 'cmd_n' })
@@ -288,13 +334,13 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
const handleOpenDailyNote = useCallback(() => openDailyNote(entries, handleSelectNote, persistNew, config.vaultPath), [entries, handleSelectNote, persistNew, config.vaultPath])
const handleCreateType = useCallback((typeName: string) => {
persistNew(resolveNewType(typeName, config.vaultPath))
persistNew(resolveNewType({ typeName, vaultPath: config.vaultPath }))
trackEvent('type_created')
}, [persistNew, config.vaultPath])
/** Create a Type entry file silently (no tab opened). Adds to state and persists to disk. */
const createTypeEntrySilent = useCallback(async (typeName: string): Promise<VaultEntry> => {
const resolved = resolveNewType(typeName, config.vaultPath)
const resolved = resolveNewType({ typeName, vaultPath: config.vaultPath })
addEntryWithMock(resolved.entry, resolved.content, addEntry)
await persistNewNote(resolved.entry.path, resolved.content)
return resolved.entry

View File

@@ -66,11 +66,11 @@ describe('renameToastMessage', () => {
})
it('returns singular when 1 file updated', () => {
expect(renameToastMessage(1)).toBe('Renamed — updated 1 wiki link')
expect(renameToastMessage(1)).toBe('Updated 1 note')
})
it('returns plural when multiple files updated', () => {
expect(renameToastMessage(3)).toBe('Renamed — updated 3 wiki links')
expect(renameToastMessage(3)).toBe('Updated 3 notes')
})
})
@@ -110,7 +110,7 @@ describe('useNoteRename hook', () => {
new_title: 'New',
old_title: 'Old',
}))
expect(setToastMessage).toHaveBeenCalledWith('Renamed — updated 2 wiki links')
expect(setToastMessage).toHaveBeenCalledWith('Updated 2 notes')
expect(onEntryRenamed).toHaveBeenCalled()
})
@@ -167,4 +167,53 @@ describe('useNoteRename hook', () => {
expect(handleSwitchTab).toHaveBeenCalledWith('/vault/new.md')
})
it('handleRenameFilename renames the file while preserving the existing title', async () => {
const entry = makeEntry({ path: '/vault/old-name.md', filename: 'old-name.md', title: 'Project Kickoff' })
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
if (cmd === 'rename_note_filename') return { new_path: '/vault/manual-name.md', updated_files: 1 }
if (cmd === 'get_note_content') return '# Project Kickoff\n'
return ''
})
const { result } = renderHook(() => useNoteRename(
{ entries: [entry], setToastMessage },
{ tabs: [], setTabs, activeTabPathRef, handleSwitchTab, updateTabContent },
))
const onEntryRenamed = vi.fn()
await act(async () => {
await result.current.handleRenameFilename('/vault/old-name.md', 'manual-name', '/vault', onEntryRenamed)
})
expect(mockInvoke).toHaveBeenCalledWith('rename_note_filename', expect.objectContaining({
old_path: '/vault/old-name.md',
new_filename_stem: 'manual-name',
}))
expect(onEntryRenamed).toHaveBeenCalledWith(
'/vault/old-name.md',
expect.objectContaining({
path: '/vault/manual-name.md',
filename: 'manual-name.md',
title: 'Project Kickoff',
}),
'# Project Kickoff\n',
)
expect(setToastMessage).toHaveBeenCalledWith('Updated 1 note')
})
it('handleRenameFilename surfaces backend conflict errors', async () => {
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('A note with that name already exists'))
const { result } = renderHook(() => useNoteRename(
{ entries: [makeEntry({ path: '/vault/old-name.md', filename: 'old-name.md' })], setToastMessage },
{ tabs: [], setTabs, activeTabPathRef, handleSwitchTab, updateTabContent },
))
await act(async () => {
await result.current.handleRenameFilename('/vault/old-name.md', 'manual-name', '/vault', vi.fn())
})
expect(setToastMessage).toHaveBeenCalledWith('A note with that name already exists')
})
})

View File

@@ -28,11 +28,35 @@ export async function performRename(
return mockInvoke<RenameResult>('rename_note', { vault_path: vaultPath, old_path: path, new_title: newTitle, old_title: oldTitle ?? null })
}
export async function performFilenameRename(
path: string,
newFilenameStem: string,
vaultPath: string,
): Promise<RenameResult> {
if (isTauri()) {
return invoke<RenameResult>('rename_note_filename', {
vaultPath,
oldPath: path,
newFilenameStem,
})
}
return mockInvoke<RenameResult>('rename_note_filename', {
vault_path: vaultPath,
old_path: path,
new_filename_stem: newFilenameStem,
})
}
export function buildRenamedEntry(entry: VaultEntry, newTitle: string, newPath: string): VaultEntry {
const slug = newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
return { ...entry, path: newPath, filename: `${slug}.md`, title: newTitle }
}
export function buildFilenameRenamedEntry(entry: VaultEntry, newPath: string): VaultEntry {
const filename = newPath.split('/').pop() ?? entry.filename
return { ...entry, path: newPath, filename }
}
export async function loadNoteContent(path: string): Promise<string> {
return isTauri()
? invoke<string>('get_note_content', { path })
@@ -41,7 +65,7 @@ export async function loadNoteContent(path: string): Promise<string> {
export function renameToastMessage(updatedFiles: number): string {
if (updatedFiles === 0) return 'Renamed'
return `Renamed — updated ${updatedFiles} wiki link${updatedFiles > 1 ? 's' : ''}`
return `Updated ${updatedFiles} note${updatedFiles > 1 ? 's' : ''}`
}
/** Reload content for open tabs whose wikilinks may have changed after a rename. */
@@ -61,6 +85,18 @@ interface Tab {
content: string
}
function renameErrorMessage(err: unknown): string {
const message = typeof err === 'string'
? err.trim()
: err instanceof Error
? err.message.trim()
: ''
if (message === 'A note with that name already exists' || message === 'Invalid filename') {
return message
}
return 'Failed to rename note'
}
export interface NoteRenameConfig {
entries: VaultEntry[]
setToastMessage: (msg: string | null) => void
@@ -82,6 +118,23 @@ export function useNoteRename(config: NoteRenameConfig, tabDeps: RenameTabDeps)
// eslint-disable-next-line react-hooks/refs
tabsRef.current = tabDeps.tabs
const applyRenameResult = useCallback(async (
oldPath: string,
result: RenameResult,
buildEntry: (entry: VaultEntry | undefined, newPath: string) => VaultEntry,
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
) => {
const entry = entries.find((item) => item.path === oldPath)
const newContent = await loadNoteContent(result.new_path)
const newEntry = buildEntry(entry, result.new_path)
const otherTabPaths = tabsRef.current.filter((tab) => tab.entry.path !== oldPath).map((tab) => tab.entry.path)
setTabs((prev) => prev.map((tab) => tab.entry.path === oldPath ? { entry: newEntry, content: newContent } : tab))
if (activeTabPathRef.current === oldPath) handleSwitchTab(result.new_path)
onEntryRenamed(oldPath, newEntry, newContent)
await reloadTabsAfterRename(otherTabPaths, updateTabContent)
setToastMessage(renameToastMessage(result.updated_files))
}, [entries, setTabs, activeTabPathRef, handleSwitchTab, updateTabContent, setToastMessage])
const handleRenameNote = useCallback(async (
path: string, newTitle: string, vaultPath: string,
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
@@ -89,19 +142,37 @@ export function useNoteRename(config: NoteRenameConfig, tabDeps: RenameTabDeps)
try {
const entry = entries.find((e) => e.path === path)
const result = await performRename(path, newTitle, vaultPath, entry?.title)
const newContent = await loadNoteContent(result.new_path)
const newEntry = buildRenamedEntry(entry ?? {} as VaultEntry, newTitle, result.new_path)
const otherTabPaths = tabsRef.current.filter(t => t.entry.path !== path).map(t => t.entry.path)
setTabs((prev) => prev.map((t) => t.entry.path === path ? { entry: newEntry, content: newContent } : t))
if (activeTabPathRef.current === path) handleSwitchTab(result.new_path)
onEntryRenamed(path, newEntry, newContent)
await reloadTabsAfterRename(otherTabPaths, updateTabContent)
setToastMessage(renameToastMessage(result.updated_files))
await applyRenameResult(
path,
result,
(currentEntry, newPath) => buildRenamedEntry(currentEntry ?? {} as VaultEntry, newTitle, newPath),
onEntryRenamed,
)
} catch (err) {
console.error('Failed to rename note:', err)
setToastMessage('Failed to rename note')
setToastMessage(renameErrorMessage(err))
}
}, [entries, setTabs, activeTabPathRef, handleSwitchTab, updateTabContent, setToastMessage])
}, [entries, applyRenameResult, setToastMessage])
return { handleRenameNote, tabsRef }
const handleRenameFilename = useCallback(async (
path: string,
newFilenameStem: string,
vaultPath: string,
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
) => {
try {
const result = await performFilenameRename(path, newFilenameStem, vaultPath)
await applyRenameResult(
path,
result,
(currentEntry, newPath) => buildFilenameRenamedEntry(currentEntry ?? {} as VaultEntry, newPath),
onEntryRenamed,
)
} catch (err) {
console.error('Failed to rename note filename:', err)
setToastMessage(renameErrorMessage(err))
}
}, [applyRenameResult, setToastMessage])
return { handleRenameNote, handleRenameFilename, tabsRef }
}

View File

@@ -110,35 +110,81 @@ let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vaul
let mockDeviceFlowPollCount = 0
function escapeRegex({ text }: { text: string }) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function relativePathStem({ path, vaultPath }: { path: string; vaultPath: string }) {
const prefix = vaultPath.endsWith('/') ? vaultPath : `${vaultPath}/`
if (path.startsWith(prefix)) return path.slice(prefix.length).replace(/\.md$/, '')
return (path.split('/').pop() ?? path).replace(/\.md$/, '')
}
function canonicalRenameTargets({ oldTitle, oldPathStem }: { oldTitle: string; oldPathStem: string }) {
const oldFilenameStem = oldPathStem.split('/').pop() ?? oldPathStem
return [...new Set([oldTitle, oldPathStem, oldFilenameStem].filter(Boolean))]
}
function slugifyMockTitle({ title }: { title: string }) {
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
}
function buildRenamedMockPath({ oldPath, newTitle }: { oldPath: string; newTitle: string }) {
const parentDir = oldPath.replace(/\/[^/]+$/, '')
return `${parentDir}/${slugifyMockTitle({ title: newTitle })}.md`
}
function replaceMockTitleFrontmatter({ content, newTitle }: { content: string; newTitle: string }) {
return /^title:\s*/m.test(content)
? content.replace(/^title:\s*.*$/m, `title: ${newTitle}`)
: content
}
function replaceRenamedWikilinks({ content, oldTargets, newPathStem }: {
content: string
oldTargets: string[]
newPathStem: string
}) {
if (oldTargets.length === 0) return content
const pattern = new RegExp(`\\[\\[(?:${oldTargets.map((target) => escapeRegex({ text: target })).join('|')})(\\|[^\\]]*?)?\\]\\]`, 'g')
return content.replace(pattern, (_match: string, pipe: string | undefined) =>
pipe ? `[[${newPathStem}${pipe}]]` : `[[${newPathStem}]]`
)
}
function updateMockRenameReferences({ newPath, newPathStem, oldTargets }: {
newPath: string
newPathStem: string
oldTargets: string[]
}) {
let updatedFiles = 0
for (const [path, content] of Object.entries(MOCK_CONTENT)) {
if (path === newPath) continue
const replaced = replaceRenamedWikilinks({ content, oldTargets, newPathStem })
if (replaced === content) continue
MOCK_CONTENT[path] = replaced
updatedFiles += 1
}
return updatedFiles
}
function handleRenameNote(args: { vault_path: string; old_path: string; new_title: string; old_title?: string | null }) {
const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path)
const oldTitle = args.old_title ?? oldEntry?.title ?? ''
if (oldTitle === args.new_title) {
const oldContent = MOCK_CONTENT[args.old_path] ?? ''
const newPath = buildRenamedMockPath({ oldPath: args.old_path, newTitle: args.new_title })
const oldPathStem = relativePathStem({ path: args.old_path, vaultPath: args.vault_path })
const newPathStem = relativePathStem({ path: newPath, vaultPath: args.vault_path })
if (oldTitle === args.new_title && newPath === args.old_path) {
return { new_path: args.old_path, updated_files: 0 }
}
const oldContent = MOCK_CONTENT[args.old_path] ?? ''
const slug = args.new_title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const parentDir = args.old_path.replace(/\/[^/]+$/, '')
const newPath = `${parentDir}/${slug}.md`
const newContent = oldContent.replace(/^# .+$/m, `# ${args.new_title}`)
const newContent = replaceMockTitleFrontmatter({ content: oldContent, newTitle: args.new_title })
delete MOCK_CONTENT[args.old_path]
MOCK_CONTENT[newPath] = newContent
let updatedFiles = 0
if (oldTitle) {
const pattern = new RegExp(`\\[\\[${oldTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\|[^\\]]*?)?\\]\\]`, 'g')
for (const [path, content] of Object.entries(MOCK_CONTENT)) {
if (path === newPath) continue
const replaced = content.replace(pattern, (_m: string, pipe: string | undefined) =>
pipe ? `[[${args.new_title}${pipe}]]` : `[[${args.new_title}]]`
)
if (replaced !== content) {
MOCK_CONTENT[path] = replaced
updatedFiles++
}
}
}
const oldTargets = canonicalRenameTargets({ oldTitle, oldPathStem })
const updatedFiles = updateMockRenameReferences({ newPath, newPathStem, oldTargets })
syncWindowContent()
return { new_path: newPath, updated_files: updatedFiles }

View File

@@ -46,6 +46,16 @@ const VAULT_API_COMMANDS: Record<string, (args: Record<string, unknown>) => Vaul
args.path ? { url: '/api/vault/save', method: 'POST', body: { path: args.path, content: args.content } } : null,
rename_note: (args) =>
args.old_path ? { url: '/api/vault/rename', method: 'POST', body: { vault_path: args.vault_path, old_path: args.old_path, new_title: args.new_title } } : null,
rename_note_filename: (args) =>
args.old_path ? {
url: '/api/vault/rename-filename',
method: 'POST',
body: {
vault_path: args.vault_path,
old_path: args.old_path,
new_filename_stem: args.new_filename_stem,
},
} : null,
delete_note: (args) =>
args.path ? { url: '/api/vault/delete', method: 'POST', body: { path: args.path } } : null,
search_vault: (args) => {

View File

@@ -0,0 +1,117 @@
import { describe, it, expect } from 'vitest'
import type { VaultEntry } from '../types'
import { buildRawEditorBaseItems, detectYamlError, extractWikilinkQuery, getRawEditorDropdownPosition, replaceActiveWikilinkQuery } from './rawEditorUtils'
describe('extractWikilinkQuery', () => {
it('returns null when no [[ trigger', () => {
expect(extractWikilinkQuery('hello world', 5)).toBeNull()
})
it('returns empty string immediately after [[', () => {
const text = 'see [['
expect(extractWikilinkQuery(text, text.length)).toBe('')
})
it('returns query after [[', () => {
const text = 'see [[Proj'
expect(extractWikilinkQuery(text, text.length)).toBe('Proj')
})
it('returns null when ]] closes the link', () => {
const text = '[[Proj]]'
expect(extractWikilinkQuery(text, text.length)).toBeNull()
})
it('returns null when newline is in query', () => {
const text = '[[Proj\ncontinued'
expect(extractWikilinkQuery(text, text.length)).toBeNull()
})
it('handles cursor before end of text', () => {
const text = '[[Proj after'
expect(extractWikilinkQuery(text, 6)).toBe('Proj')
})
})
describe('replaceActiveWikilinkQuery', () => {
it('replaces the active wikilink query with the canonical target', () => {
expect(replaceActiveWikilinkQuery('See [[Proj', 10, 'projects/alpha')).toEqual({
text: 'See [[projects/alpha]]',
cursor: 22,
})
})
it('preserves text after the cursor', () => {
expect(replaceActiveWikilinkQuery('See [[Proj today', 10, 'projects/alpha')).toEqual({
text: 'See [[projects/alpha]] today',
cursor: 22,
})
})
it('returns null when no active wikilink trigger exists', () => {
expect(replaceActiveWikilinkQuery('See Proj', 8, 'projects/alpha')).toBeNull()
})
})
describe('detectYamlError', () => {
it('returns null for content without frontmatter', () => {
expect(detectYamlError('# Title\n\nSome content.')).toBeNull()
})
it('returns null for valid frontmatter', () => {
expect(detectYamlError('---\ntitle: My Note\n---\n\n# Title')).toBeNull()
})
it('returns error for unclosed frontmatter', () => {
const error = detectYamlError('---\ntitle: My Note\n\n# Title')
expect(error).toContain('Unclosed frontmatter')
})
it('returns error for tab indentation in frontmatter', () => {
const error = detectYamlError('---\n\ttitle: My Note\n---\n')
expect(error).toContain('tab indentation')
})
it('returns null for content not starting with ---', () => {
expect(detectYamlError('Not frontmatter')).toBeNull()
})
})
describe('buildRawEditorBaseItems', () => {
it('includes filename aliases and deduplicates entries by path', () => {
expect(buildRawEditorBaseItems([
{
title: 'Project Alpha',
aliases: ['Alpha'],
filename: 'project-alpha.md',
isA: 'Project',
path: 'projects/project-alpha.md',
},
{
title: 'Project Alpha',
aliases: ['Ignored'],
filename: 'project-alpha.md',
isA: 'Project',
path: 'projects/project-alpha.md',
},
] as VaultEntry[])).toEqual([
{
title: 'Project Alpha',
aliases: ['project-alpha', 'Alpha'],
group: 'Project',
entryTitle: 'Project Alpha',
path: 'projects/project-alpha.md',
},
])
})
})
describe('getRawEditorDropdownPosition', () => {
it('renders above the cursor when there is not enough space below', () => {
expect(getRawEditorDropdownPosition(
{ caretTop: 500, caretLeft: 900, selectedIndex: 0, items: [] },
200,
{ innerHeight: 640, innerWidth: 1000 },
)).toEqual({ top: 276, left: 740 })
})
})

View File

@@ -1,3 +1,33 @@
import type { EditorView } from '@codemirror/view'
import { attachClickHandlers, enrichSuggestionItems } from './suggestionEnrichment'
import { deduplicateByPath, preFilterWikilinks } from './wikilinkSuggestions'
import type { VaultEntry } from '../types'
import type { WikilinkSuggestionItem } from '../components/WikilinkSuggestionMenu'
export interface RawEditorBaseItem {
title: string
aliases: string[]
group: string
entryTitle: string
path: string
}
export interface RawEditorAutocompleteState {
caretTop: number
caretLeft: number
selectedIndex: number
items: WikilinkSuggestionItem[]
}
interface RawEditorAutocompleteParams {
view: EditorView
baseItems: RawEditorBaseItem[]
query: string
typeEntryMap: Record<string, VaultEntry>
onInsertTarget: (target: string) => void
vaultPath: string
}
/** Extract the wikilink query that the user is currently typing after [[ */
export function extractWikilinkQuery(text: string, cursor: number): string | null {
const before = text.slice(0, cursor)
@@ -9,6 +39,75 @@ export function extractWikilinkQuery(text: string, cursor: number): string | nul
return afterTrigger
}
export function replaceActiveWikilinkQuery(
text: string,
cursor: number,
target: string,
): { text: string; cursor: number } | null {
const before = text.slice(0, cursor)
const triggerIdx = before.lastIndexOf('[[')
if (triggerIdx === -1) return null
const after = text.slice(cursor)
return {
text: `${text.slice(0, triggerIdx)}[[${target}]]${after}`,
cursor: triggerIdx + target.length + 4,
}
}
export function buildRawEditorBaseItems(entries: VaultEntry[]): RawEditorBaseItem[] {
return deduplicateByPath(entries.map(entry => ({
title: entry.title,
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
group: entry.isA || 'Note',
entryTitle: entry.title,
path: entry.path,
})))
}
export function getRawEditorCursorCoords(view: EditorView): { top: number; left: number } | null {
const pos = view.state.selection.main.head
const coords = view.coordsAtPos(pos)
if (!coords) return null
return { top: coords.bottom, left: coords.left }
}
export function buildRawEditorAutocompleteState({
view,
baseItems,
query,
typeEntryMap,
onInsertTarget,
vaultPath,
}: RawEditorAutocompleteParams): RawEditorAutocompleteState | null {
const coords = getRawEditorCursorCoords(view)
if (!coords) return null
const candidates = preFilterWikilinks(baseItems, query)
const withHandlers = attachClickHandlers(candidates, onInsertTarget, vaultPath)
const items = enrichSuggestionItems(withHandlers, query, typeEntryMap)
return {
caretTop: coords.top,
caretLeft: coords.left,
selectedIndex: 0,
items,
}
}
export function getRawEditorDropdownPosition(
autocomplete: RawEditorAutocompleteState | null,
maxHeight: number,
viewport: { innerHeight: number; innerWidth: number },
): { top: number; left: number } {
if (!autocomplete) return { top: 0, left: 0 }
const dropdownBelow = autocomplete.caretTop + 20 + maxHeight <= viewport.innerHeight
return {
top: dropdownBelow ? autocomplete.caretTop + 4 : autocomplete.caretTop - maxHeight - 24,
left: Math.min(autocomplete.caretLeft, viewport.innerWidth - 260),
}
}
/** Basic YAML frontmatter structural checks. */
export function detectYamlError(content: string): string | null {
if (!content.startsWith('---')) return null

View File

@@ -81,11 +81,15 @@ export function getStatusColorKey(status: string): string | null {
return colorOverrides[status] ?? null
}
export function getStatusStyle(status: string): StatusStyle {
export function getMappedStatusStyle(status: string): StatusStyle | null {
const overrideKey = colorOverrides[status]
if (overrideKey) {
const style = COLOR_KEY_TO_STYLE[overrideKey]
if (style) return style
}
return STATUS_STYLES[status] ?? DEFAULT_STATUS_STYLE
return STATUS_STYLES[status] ?? null
}
export function getStatusStyle(status: string): StatusStyle {
return getMappedStatusStyle(status) ?? DEFAULT_STATUS_STYLE
}

View File

@@ -32,9 +32,9 @@ describe('attachClickHandlers', () => {
expect(result).toHaveLength(2)
result[0].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('a|Note A')
expect(insertWikilink).toHaveBeenCalledWith('a')
result[1].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('b|Note B')
expect(insertWikilink).toHaveBeenCalledWith('b')
})
it('preserves all original properties', () => {
@@ -55,13 +55,13 @@ describe('attachClickHandlers', () => {
const result = attachClickHandlers(candidates, insertWikilink, vaultPath)
result[0].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('docs/adr/0001-tauri-stack|ADR 001')
expect(insertWikilink).toHaveBeenCalledWith('docs/adr/0001-tauri-stack')
})
it('omits pipe display when title matches path stem', () => {
it('omits any default alias even when the title differs from the path stem', () => {
const insertWikilink = vi.fn()
const candidates = [
{ title: 'roadmap', aliases: [], group: 'Note', entryTitle: 'roadmap', path: '/vault/roadmap.md' },
{ title: 'Roadmap', aliases: [], group: 'Note', entryTitle: 'Roadmap', path: '/vault/roadmap.md' },
]
const result = attachClickHandlers(candidates, insertWikilink, vaultPath)

View File

@@ -17,17 +17,14 @@ interface BaseSuggestionItem {
path: string
}
/** Build the wikilink target: relative path stem with pipe display for the title.
* e.g. "docs/adr/0001-tauri-stack|Tauri Stack" for subfolders,
* "roadmap|Roadmap" for root files. */
/** Build the canonical wikilink target: vault-relative path stem without a default alias. */
function buildTarget(item: BaseSuggestionItem, vaultPath: string): string {
const stem = relativePathStem(item.path, vaultPath)
return stem === item.entryTitle ? stem : `${stem}|${item.entryTitle}`
return relativePathStem(item.path, vaultPath)
}
/** Add onItemClick to raw suggestion candidates.
* Always inserts the vault-relative path as the wikilink target
* so links are unambiguous and work across subfolders. */
* Always inserts the canonical vault-relative path target so links are
* unambiguous and remain stable across renames. */
export function attachClickHandlers(
candidates: BaseSuggestionItem[],
insertWikilink: (target: string) => void,

View File

@@ -1,6 +1,15 @@
import { describe, it, expect } from 'vitest'
import type { VaultEntry } from '../types'
import { wikilinkTarget, wikilinkDisplay, resolveEntry, relativePathStem } from './wikilink'
import {
wikilinkTarget,
wikilinkDisplay,
resolveEntry,
relativePathStem,
slugifyWikilinkTarget,
canonicalWikilinkTargetForEntry,
canonicalWikilinkTargetForTitle,
formatWikilinkRef,
} from './wikilink'
function makeEntry(overrides: Partial<VaultEntry>): VaultEntry {
return {
@@ -124,3 +133,42 @@ describe('relativePathStem', () => {
expect(relativePathStem('/other/path/note.md', '/Users/luca/Vault')).toBe('note')
})
})
describe('slugifyWikilinkTarget', () => {
it('slugifies a human title to a canonical target', () => {
expect(slugifyWikilinkTarget('Weekly Review')).toBe('weekly-review')
})
it('falls back to untitled when the title has no slug characters', () => {
expect(slugifyWikilinkTarget('+++')).toBe('untitled')
})
})
describe('canonicalWikilinkTargetForEntry', () => {
it('returns a vault-relative path stem', () => {
const entry = makeEntry({ path: '/vault/projects/alpha.md', filename: 'alpha.md', title: 'Alpha' })
expect(canonicalWikilinkTargetForEntry(entry, '/vault')).toBe('projects/alpha')
})
})
describe('canonicalWikilinkTargetForTitle', () => {
const project = makeEntry({ path: '/vault/projects/alpha.md', filename: 'alpha.md', title: 'Alpha Project' })
it('resolves an existing entry to its canonical path target', () => {
expect(canonicalWikilinkTargetForTitle('Alpha Project', [project], '/vault')).toBe('projects/alpha')
})
it('keeps a canonical path input canonical', () => {
expect(canonicalWikilinkTargetForTitle('projects/alpha', [project], '/vault')).toBe('projects/alpha')
})
it('falls back to a slug for a newly created note title', () => {
expect(canonicalWikilinkTargetForTitle('Brand New Note', [], '/vault')).toBe('brand-new-note')
})
})
describe('formatWikilinkRef', () => {
it('wraps a canonical target in wikilink syntax', () => {
expect(formatWikilinkRef('projects/alpha')).toBe('[[projects/alpha]]')
})
})

View File

@@ -27,6 +27,86 @@ export function relativePathStem(absolutePath: string, vaultPath: string): strin
return filename.replace(/\.md$/, '')
}
/** Slugify a human-readable title into the canonical wikilink filename stem. */
export function slugifyWikilinkTarget(title: string): string {
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
return slug || 'untitled'
}
/** Build the canonical wikilink target for a vault entry. */
export function canonicalWikilinkTargetForEntry(entry: VaultEntry, vaultPath: string): string {
return relativePathStem(entry.path, vaultPath)
}
/** Resolve a user-facing title/path input to the canonical wikilink target. */
export function canonicalWikilinkTargetForTitle(
titleOrTarget: string,
entries: VaultEntry[],
vaultPath: string,
): string {
const trimmed = titleOrTarget.trim()
const resolved = resolveEntry(entries, trimmed)
return resolved
? canonicalWikilinkTargetForEntry(resolved, vaultPath)
: trimmed.includes('/')
? trimmed.replace(/^\/+/, '').replace(/\.md$/, '')
: slugifyWikilinkTarget(trimmed)
}
/** Wrap a target in wikilink syntax. */
export function formatWikilinkRef(target: string): string {
return `[[${target}]]`
}
interface ResolutionKey {
exactTarget: string
lastSegment: string
pathSuffix: string | null
humanizedTarget: string | null
}
function buildResolutionKey(rawTarget: string): ResolutionKey {
const exactTarget = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget
const normalizedTarget = exactTarget.toLowerCase()
const lastSegment = exactTarget.includes('/') ? (exactTarget.split('/').pop() ?? exactTarget).toLowerCase() : normalizedTarget
const humanizedTarget = lastSegment.replace(/-/g, ' ')
return {
exactTarget: normalizedTarget,
lastSegment,
pathSuffix: exactTarget.includes('/') ? `/${normalizedTarget}.md` : null,
humanizedTarget: humanizedTarget === normalizedTarget ? null : humanizedTarget,
}
}
function findEntryByPathSuffix(entries: VaultEntry[], pathSuffix: string | null): VaultEntry | undefined {
if (!pathSuffix) return undefined
return entries.find(entry => entry.path.toLowerCase().endsWith(pathSuffix))
}
function findEntryByFilename(entries: VaultEntry[], { exactTarget, lastSegment }: ResolutionKey): VaultEntry | undefined {
return entries.find((entry) => {
const stem = entry.filename.replace(/\.md$/, '').toLowerCase()
return stem === exactTarget || stem === lastSegment
})
}
function findEntryByAlias(entries: VaultEntry[], exactTarget: string): VaultEntry | undefined {
return entries.find(entry => entry.aliases.some(alias => alias.toLowerCase() === exactTarget))
}
function findEntryByTitle(entries: VaultEntry[], exactTarget: string, lastSegment: string): VaultEntry | undefined {
return entries.find((entry) => {
const lowerTitle = entry.title.toLowerCase()
return lowerTitle === exactTarget || lowerTitle === lastSegment
})
}
function findEntryByHumanizedTitle(entries: VaultEntry[], humanizedTarget: string | null): VaultEntry | undefined {
if (!humanizedTarget) return undefined
return entries.find(entry => entry.title.toLowerCase() === humanizedTarget)
}
/**
* Unified wikilink resolution: find the VaultEntry matching a wikilink target.
* Handles pipe syntax, case-insensitive matching.
@@ -38,37 +118,12 @@ export function relativePathStem(absolutePath: string, vaultPath: string): strin
* 5. Humanized title match (kebab-case → words)
*/
export function resolveEntry(entries: VaultEntry[], rawTarget: string): VaultEntry | undefined {
const key = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget
const keyLower = key.toLowerCase()
const lastSegment = key.includes('/') ? (key.split('/').pop() ?? key) : key
const lastSegmentLower = lastSegment.toLowerCase()
const asWords = lastSegmentLower.replace(/-/g, ' ')
const pathSuffix = key.includes('/') ? '/' + key.toLowerCase() + '.md' : null
// Pass 1: path-suffix match (for subfolder targets like "docs/adr/0031-foo")
if (pathSuffix) {
for (const e of entries) {
if (e.path.toLowerCase().endsWith(pathSuffix)) return e
}
}
// Pass 2: filename stem (strongest for flat vault)
for (const e of entries) {
const stem = e.filename.replace(/\.md$/, '').toLowerCase()
if (stem === keyLower || stem === lastSegmentLower) return e
}
// Pass 3: alias
for (const e of entries) {
if (e.aliases.some(a => a.toLowerCase() === keyLower)) return e
}
// Pass 4: exact title
for (const e of entries) {
if (e.title.toLowerCase() === keyLower || e.title.toLowerCase() === lastSegmentLower) return e
}
// Pass 5: humanized title (kebab-case → words)
if (asWords !== keyLower) {
for (const e of entries) {
if (e.title.toLowerCase() === asWords) return e
}
}
return undefined
const resolutionKey = buildResolutionKey(rawTarget)
return (
findEntryByPathSuffix(entries, resolutionKey.pathSuffix)
?? findEntryByFilename(entries, resolutionKey)
?? findEntryByAlias(entries, resolutionKey.exactTarget)
?? findEntryByTitle(entries, resolutionKey.exactTarget, resolutionKey.lastSegment)
?? findEntryByHumanizedTitle(entries, resolutionKey.humanizedTarget)
)
}

View File

@@ -0,0 +1,107 @@
import fs from 'fs'
import path from 'path'
import { test, expect, type Page } from '@playwright/test'
import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { executeCommand, openCommandPalette } from './helpers'
let tempVaultDir: string
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(60_000)
tempVaultDir = createFixtureVaultCopy()
await openFixtureVault(page, tempVaultDir)
})
test.afterEach(async () => {
removeFixtureVaultCopy(tempVaultDir)
})
async function openNote(page: Page, title: string) {
const noteList = page.locator('[data-testid="note-list-container"]')
await noteList.getByText(title, { exact: true }).click()
}
async function openRawMode(page: Page) {
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5_000 })
}
async function openBlockNoteMode(page: Page) {
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}
async function getRawEditorContent(page: Page): Promise<string> {
return page.evaluate(() => {
const el = document.querySelector('.cm-content')
if (!el) return ''
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const view = (el as any).cmTile?.view
if (view) return view.state.doc.toString() as string
return el.textContent ?? ''
})
}
async function setRawEditorContent(page: Page, content: string) {
await page.evaluate((newContent) => {
const el = document.querySelector('.cm-content')
if (!el) return
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const view = (el as any).cmTile?.view
if (!view) return
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: newContent },
})
}, content)
}
test('breadcrumb sync button and inline rename keep the file path aligned with H1 title changes', async ({ page }) => {
const syncedPath = path.join(tempVaultDir, 'note', 'breadcrumb-sync-target.md')
const manuallyRenamedPath = path.join(tempVaultDir, 'note', 'manual-breadcrumb-name.md')
const originalPath = path.join(tempVaultDir, 'note', 'note-b.md')
const updatedTitle = 'Breadcrumb Sync Target'
await openNote(page, 'Note B')
await openRawMode(page)
const rawContent = await getRawEditorContent(page)
expect(rawContent).toContain('# Note B')
await setRawEditorContent(page, rawContent.replace('# Note B', `# ${updatedTitle}`))
await page.keyboard.press('Meta+s')
await openBlockNoteMode(page)
await expect(page.getByRole('heading', { name: updatedTitle, level: 1 })).toBeVisible({ timeout: 5_000 })
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText('note-b')
await expect(page.getByTestId('breadcrumb-sync-button')).toBeVisible()
await page.getByTestId('breadcrumb-sync-button').click()
await expect(page.locator('.fixed.bottom-8')).toContainText('Renamed', { timeout: 5_000 })
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText('breadcrumb-sync-target')
await expect(page.getByTestId('breadcrumb-sync-button')).toHaveCount(0)
await expect.poll(() => fs.existsSync(originalPath)).toBe(false)
await expect.poll(() => fs.existsSync(syncedPath)).toBe(true)
await page.getByTestId('breadcrumb-filename-trigger').focus()
await page.keyboard.press('Enter')
const firstInput = page.getByTestId('breadcrumb-filename-input')
await expect(firstInput).toHaveValue('breadcrumb-sync-target')
await page.keyboard.press('Escape')
await expect(page.getByTestId('breadcrumb-filename-input')).toHaveCount(0)
await page.getByTestId('breadcrumb-filename-trigger').dblclick()
const renameInput = page.getByTestId('breadcrumb-filename-input')
await renameInput.fill('manual-breadcrumb-name')
await renameInput.press('Enter')
await expect(page.locator('.fixed.bottom-8')).toContainText('Renamed', { timeout: 5_000 })
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText('manual-breadcrumb-name')
await expect.poll(() => fs.existsSync(syncedPath)).toBe(false)
await expect.poll(() => fs.existsSync(manuallyRenamedPath)).toBe(true)
await openRawMode(page)
await expect.poll(async () => getRawEditorContent(page)).toContain(`# ${updatedTitle}`)
})

View File

@@ -15,7 +15,7 @@ async function openQuickOpen(page: import('@playwright/test').Page) {
*/
async function getFirstResultTitle(page: import('@playwright/test').Page): Promise<string> {
// The selected result row contains the title in a span.truncate
const titleSpan = page.locator('[class*="bg-accent"] span.truncate')
const titleSpan = page.getByTestId('quick-open-palette').locator('[class*="bg-accent"] span.truncate')
await titleSpan.first().waitFor({ timeout: 3000 })
return (await titleSpan.first().textContent()) ?? ''
}

View File

@@ -1,41 +1,71 @@
import { test, expect } from '@playwright/test'
import { sendShortcut } from './helpers'
import fs from 'fs'
import path from 'path'
import { test, expect, type Page } from '@playwright/test'
import { executeCommand, openCommandPalette, sendShortcut } from './helpers'
import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault'
let tempVaultDir: string
function seedTypeEntry(vaultPath: string, typeName: string, template: string): void {
const slug = typeName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'type'
const body = [
'---',
`title: ${typeName}`,
'type: Type',
'template: |',
...template.split('\n').map((line) => ` ${line}`),
'---',
'',
].join('\n')
fs.writeFileSync(path.join(vaultPath, `${slug}.md`), body)
}
async function openTestVault(page: Page): Promise<void> {
await openFixtureVault(page, tempVaultDir)
}
async function selectSection(page: Page, label: string): Promise<void> {
await page.locator('aside').getByText(label, { exact: true }).first().click()
}
async function createNoteFromListHeader(page: Page): Promise<void> {
await page.locator('button[title="Create new note"]').click()
}
function untitledRow(page: Page, typeLabel: string) {
return page.getByText(new RegExp(`^Untitled ${typeLabel}(?: \\d+)?$`, 'i')).first()
}
test.describe('Create note crash fix', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
test.beforeEach(() => {
tempVaultDir = createFixtureVaultCopy()
})
test('clicking + next to a type section creates a note without crashing', async ({ page }) => {
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('clicking + next to a type section creates a note without crashing @smoke', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => errors.push(err.message))
// Hover over the Projects section to reveal the + button
const sectionHeader = page.getByText('Projects').first()
await sectionHeader.hover()
// Click the actual create button (exact match avoids the parent sortable div)
const createBtn = page.getByRole('button', { name: 'Create new Project', exact: true })
await createBtn.click({ force: true })
// The new note should appear — check for tab + heading
await expect(page.getByText('Untitled project').first()).toBeVisible({ timeout: 3000 })
await openTestVault(page)
await selectSection(page, 'Projects')
await createNoteFromListHeader(page)
await expect(untitledRow(page, 'project')).toBeVisible({ timeout: 5_000 })
expect(errors).toEqual([])
})
test('Cmd+N creates a note without crashing', async ({ page }) => {
test('Cmd+N creates a note without crashing @smoke', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => errors.push(err.message))
await openTestVault(page)
await page.waitForTimeout(300)
await page.locator('body').click()
await sendShortcut(page, 'n', ['Control'])
await page.waitForTimeout(500)
// An "Untitled note" tab should be visible
await expect(page.getByText(/Untitled note/).first()).toBeVisible({ timeout: 3000 })
await expect(untitledRow(page, 'note')).toBeVisible({ timeout: 5_000 })
expect(errors).toEqual([])
})
@@ -44,33 +74,25 @@ test.describe('Create note crash fix', () => {
const errors: string[] = []
page.on('pageerror', (err) => errors.push(err.message))
// Hover over a custom type section (Areas exists in mock vault)
const sectionHeader = page.getByText('Areas').first()
await sectionHeader.hover()
const createBtn = page.getByRole('button', { name: 'Create new Area', exact: true })
await createBtn.click({ force: true })
await expect(page.getByText('Untitled area').first()).toBeVisible({ timeout: 3000 })
await openTestVault(page)
await selectSection(page, 'Events')
await createNoteFromListHeader(page)
await expect(untitledRow(page, 'event')).toBeVisible({ timeout: 5_000 })
expect(errors).toEqual([])
})
test('rapid note creation does not crash', async ({ page }) => {
test('command palette creates typed notes without crashing when a type template is present @smoke', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => errors.push(err.message))
const sectionHeader = page.getByText('Experiments').first()
await sectionHeader.hover()
seedTypeEntry(tempVaultDir, 'Procedure', '## Checklist\n\n- first step\n- [[Alpha Project]]\n- unmatched [link')
await openTestVault(page)
const createBtn = page.getByRole('button', { name: 'Create new Experiment', exact: true })
await createBtn.click({ force: true })
await page.waitForTimeout(200)
await createBtn.click({ force: true })
// At least one untitled experiment should exist (single-note model: second replaces first)
await expect(page.getByText('Untitled experiment').first()).toBeVisible({ timeout: 5000 })
await openCommandPalette(page)
await executeCommand(page, 'new procedure')
await expect(untitledRow(page, 'procedure')).toBeVisible({ timeout: 5_000 })
expect(errors).toEqual([])
})
})

View File

@@ -61,6 +61,10 @@ async function openQuickOpen(page: Page) {
await expect(page.locator('input[placeholder="Search notes..."]')).toBeVisible({ timeout: 5_000 })
}
function quickOpenSelectedTitle(page: Page) {
return page.getByTestId('quick-open-palette').locator('[class*="bg-accent"] span.truncate').first()
}
test('creating an untitled draft hides the legacy title section in the editor', async ({ page }) => {
await page.locator('button[title="Create new note"]').click()
@@ -104,7 +108,7 @@ test('@smoke edited H1 titles drive note list, search, and wikilink autocomplete
await openQuickOpen(page)
const quickOpenInput = page.locator('input[placeholder="Search notes..."]')
await quickOpenInput.fill(updatedTitle)
await expect(page.locator('[class*="bg-accent"] span.truncate').first()).toHaveText(updatedTitle, { timeout: 5_000 })
await expect(quickOpenSelectedTitle(page)).toHaveText(updatedTitle, { timeout: 5_000 })
await page.keyboard.press('Escape')
await openNote(page, 'Alpha Project')

View File

@@ -1,4 +1,5 @@
/// <reference types="vitest/config" />
import type { IncomingMessage, ServerResponse } from 'http'
import path from 'path'
import fs from 'fs'
import { defineConfig, type Plugin } from 'vite'
@@ -194,183 +195,251 @@ function findMarkdownFiles(dir: string): string[] {
return results
}
function sendJson(res: ServerResponse, payload: unknown, statusCode = 200): void {
res.statusCode = statusCode
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(payload))
}
function readExistingQueryPath(url: URL, res: ServerResponse, key: string): string | null {
const filePath = url.searchParams.get(key)
if (!filePath || !fs.existsSync(filePath)) {
sendJson(res, { error: 'Invalid or missing path' }, 400)
return null
}
return filePath
}
function updateTitleWikilinks(vaultPath: string, oldTitle: string, newTitle: string, excludePath: string): number {
if (!oldTitle) return 0
const allFiles = findMarkdownFiles(vaultPath)
const escaped = oldTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(`\\[\\[${escaped}(\\|[^\\]]*?)?\\]\\]`, 'g')
let updatedFiles = 0
for (const filePath of allFiles) {
if (filePath === excludePath) continue
try {
const content = fs.readFileSync(filePath, 'utf-8')
const replaced = content.replace(pattern, (_m: string, pipe: string | undefined) =>
pipe ? `[[${newTitle}${pipe}]]` : `[[${newTitle}]]`
)
if (replaced !== content) {
fs.writeFileSync(filePath, replaced, 'utf-8')
updatedFiles++
}
} catch {
// Skip unreadable files in the dev vault API.
}
}
return updatedFiles
}
function updatePathWikilinks(vaultPath: string, oldPath: string, newPath: string): number {
const oldRelativeStem = path.relative(vaultPath, oldPath).replace(/\.md$/i, '')
const newRelativeStem = path.relative(vaultPath, newPath).replace(/\.md$/i, '')
const escaped = oldRelativeStem.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(`\\[\\[${escaped}(\\|[^\\]]*?)?\\]\\]`, 'g')
let updatedFiles = 0
for (const filePath of findMarkdownFiles(vaultPath)) {
if (filePath === newPath) continue
try {
const content = fs.readFileSync(filePath, 'utf-8')
const replaced = content.replace(pattern, (_m: string, pipe: string | undefined) =>
pipe ? `[[${newRelativeStem}${pipe}]]` : `[[${newRelativeStem}]]`
)
if (replaced !== content) {
fs.writeFileSync(filePath, replaced, 'utf-8')
updatedFiles++
}
} catch {
// Skip unreadable files in the dev vault API.
}
}
return updatedFiles
}
function handleVaultPing(url: URL, res: ServerResponse): boolean {
if (url.pathname !== '/api/vault/ping') return false
sendJson(res, { ok: true })
return true
}
function handleVaultList(url: URL, res: ServerResponse): boolean {
if (url.pathname !== '/api/vault/list') return false
const dirPath = readExistingQueryPath(url, res, 'path')
if (!dirPath) return true
const entries = findMarkdownFiles(dirPath).map(parseMarkdownFile).filter(Boolean)
sendJson(res, entries)
return true
}
function handleVaultContent(url: URL, res: ServerResponse): boolean {
if (url.pathname !== '/api/vault/content') return false
const filePath = readExistingQueryPath(url, res, 'path')
if (!filePath) return true
sendJson(res, { content: fs.readFileSync(filePath, 'utf-8') })
return true
}
function handleVaultAllContent(url: URL, res: ServerResponse): boolean {
if (url.pathname !== '/api/vault/all-content') return false
const dirPath = readExistingQueryPath(url, res, 'path')
if (!dirPath) return true
const contentMap: Record<string, string> = {}
for (const filePath of findMarkdownFiles(dirPath)) {
try {
contentMap[filePath] = fs.readFileSync(filePath, 'utf-8')
} catch {
// Skip unreadable files.
}
}
sendJson(res, contentMap)
return true
}
function handleVaultEntry(url: URL, res: ServerResponse): boolean {
if (url.pathname !== '/api/vault/entry') return false
const filePath = readExistingQueryPath(url, res, 'path')
if (!filePath) return true
sendJson(res, parseMarkdownFile(filePath))
return true
}
function handleVaultSearch(url: URL, res: ServerResponse): boolean {
if (url.pathname !== '/api/vault/search') return false
const vaultPath = url.searchParams.get('vault_path')
const query = (url.searchParams.get('query') ?? '').toLowerCase()
const mode = url.searchParams.get('mode') ?? 'all'
if (!vaultPath || !query) {
sendJson(res, { results: [], elapsed_ms: 0, query, mode })
return true
}
const results: { title: string; path: string; snippet: string; score: number; note_type: string | null }[] = []
for (const filePath of findMarkdownFiles(vaultPath)) {
const entry = parseMarkdownFile(filePath)
if (!entry || entry.trashed) continue
const raw = fs.readFileSync(filePath, 'utf-8')
if (entry.title.toLowerCase().includes(query) || raw.toLowerCase().includes(query)) {
results.push({ title: entry.title, path: entry.path, snippet: entry.snippet, score: 1.0, note_type: entry.isA })
}
}
sendJson(res, { results: results.slice(0, 20), elapsed_ms: 1, query, mode })
return true
}
async function handleVaultSave(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
if (url.pathname !== '/api/vault/save' || req.method !== 'POST') return false
try {
const body = await readRequestBody(req)
const { path: filePath, content } = JSON.parse(body)
if (!filePath || content === undefined) {
sendJson(res, { error: 'Missing path or content' }, 400)
return true
}
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, content, 'utf-8')
sendJson(res, null)
} catch (err: unknown) {
sendJson(res, { error: err instanceof Error ? err.message : 'Save failed' }, 500)
}
return true
}
async function handleVaultRename(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
if (url.pathname !== '/api/vault/rename' || req.method !== 'POST') return false
try {
const body = await readRequestBody(req)
const { vault_path: vaultPath, old_path: oldPath, new_title: newTitle } = JSON.parse(body)
const oldContent = fs.readFileSync(oldPath, 'utf-8')
const oldTitle = oldContent.match(/^# (.+)$/m)?.[1]?.trim() ?? ''
const slug = newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const newPath = path.join(path.dirname(oldPath), `${slug}.md`)
const newContent = oldContent.replace(/^# .+$/m, `# ${newTitle}`)
fs.writeFileSync(newPath, newContent, 'utf-8')
if (newPath !== oldPath) fs.unlinkSync(oldPath)
const updatedFiles = vaultPath ? updateTitleWikilinks(vaultPath, oldTitle, newTitle, newPath) : 0
sendJson(res, { new_path: newPath, updated_files: updatedFiles })
} catch (err: unknown) {
sendJson(res, { error: err instanceof Error ? err.message : 'Rename failed' }, 500)
}
return true
}
async function handleVaultRenameFilename(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
if (url.pathname !== '/api/vault/rename-filename' || req.method !== 'POST') return false
try {
const body = await readRequestBody(req)
const {
vault_path: vaultPath,
old_path: oldPath,
new_filename_stem: newFilenameStem,
} = JSON.parse(body)
const trimmed = String(newFilenameStem ?? '').trim().replace(/\.md$/i, '').trim()
if (!trimmed) {
sendJson(res, { error: 'New filename cannot be empty' }, 400)
return true
}
if (trimmed === '.' || trimmed === '..' || trimmed.includes('/') || trimmed.includes('\\')) {
sendJson(res, { error: 'Invalid filename' }, 400)
return true
}
const newPath = path.join(path.dirname(oldPath), `${trimmed}.md`)
if (newPath !== oldPath && fs.existsSync(newPath)) {
sendJson(res, { error: 'A note with that name already exists' }, 409)
return true
}
fs.renameSync(oldPath, newPath)
const updatedFiles = vaultPath ? updatePathWikilinks(vaultPath, oldPath, newPath) : 0
sendJson(res, { new_path: newPath, updated_files: updatedFiles })
} catch (err: unknown) {
sendJson(res, { error: err instanceof Error ? err.message : 'Rename failed' }, 500)
}
return true
}
async function handleVaultDelete(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
if (url.pathname !== '/api/vault/delete' || req.method !== 'POST') return false
try {
const body = await readRequestBody(req)
const { path: filePath } = JSON.parse(body)
if (!filePath) {
sendJson(res, { error: 'Missing path' }, 400)
return true
}
fs.unlinkSync(filePath)
sendJson(res, filePath)
} catch (err: unknown) {
sendJson(res, { error: err instanceof Error ? err.message : 'Delete failed' }, 500)
}
return true
}
async function handleVaultApiRequest(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
if (handleVaultPing(url, res)) return true
if (handleVaultList(url, res)) return true
if (handleVaultContent(url, res)) return true
if (handleVaultAllContent(url, res)) return true
if (handleVaultEntry(url, res)) return true
if (handleVaultSearch(url, res)) return true
if (await handleVaultSave(url, req, res)) return true
if (await handleVaultRename(url, req, res)) return true
if (await handleVaultRenameFilename(url, req, res)) return true
if (await handleVaultDelete(url, req, res)) return true
return false
}
function vaultApiPlugin(): Plugin {
return {
name: 'vault-api',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
if (url.pathname === '/api/vault/ping') {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ ok: true }))
return
}
if (url.pathname === '/api/vault/list') {
const dirPath = url.searchParams.get('path')
if (!dirPath || !fs.existsSync(dirPath)) {
res.statusCode = 400
res.end(JSON.stringify({ error: 'Invalid or missing path' }))
return
}
const files = findMarkdownFiles(dirPath)
const entries = files.map(parseMarkdownFile).filter(Boolean)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(entries))
return
}
if (url.pathname === '/api/vault/content') {
const filePath = url.searchParams.get('path')
if (!filePath || !fs.existsSync(filePath)) {
res.statusCode = 400
res.end(JSON.stringify({ error: 'Invalid or missing path' }))
return
}
const content = fs.readFileSync(filePath, 'utf-8')
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ content }))
return
}
if (url.pathname === '/api/vault/all-content') {
const dirPath = url.searchParams.get('path')
if (!dirPath || !fs.existsSync(dirPath)) {
res.statusCode = 400
res.end(JSON.stringify({ error: 'Invalid or missing path' }))
return
}
const files = findMarkdownFiles(dirPath)
const contentMap: Record<string, string> = {}
for (const f of files) {
try {
contentMap[f] = fs.readFileSync(f, 'utf-8')
} catch {
// skip unreadable files
}
}
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(contentMap))
return
}
if (url.pathname === '/api/vault/entry') {
const filePath = url.searchParams.get('path')
if (!filePath || !fs.existsSync(filePath)) {
res.statusCode = 400
res.end(JSON.stringify({ error: 'Invalid or missing path' }))
return
}
const entry = parseMarkdownFile(filePath)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(entry))
return
}
if (url.pathname === '/api/vault/search') {
const vaultPath = url.searchParams.get('vault_path')
const query = (url.searchParams.get('query') ?? '').toLowerCase()
const mode = url.searchParams.get('mode') ?? 'all'
if (!vaultPath || !query) {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ results: [], elapsed_ms: 0, query, mode }))
return
}
const files = findMarkdownFiles(vaultPath)
const results: { title: string; path: string; snippet: string; score: number; note_type: string | null }[] = []
for (const f of files) {
const entry = parseMarkdownFile(f)
if (!entry || entry.trashed) continue
const raw = fs.readFileSync(f, 'utf-8')
if (entry.title.toLowerCase().includes(query) || raw.toLowerCase().includes(query)) {
results.push({ title: entry.title, path: entry.path, snippet: entry.snippet, score: 1.0, note_type: entry.isA })
}
}
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ results: results.slice(0, 20), elapsed_ms: 1, query, mode }))
return
}
// --- POST endpoints for write operations ---
if (url.pathname === '/api/vault/save' && req.method === 'POST') {
try {
const body = await readRequestBody(req)
const { path: filePath, content } = JSON.parse(body)
if (!filePath || content === undefined) {
res.statusCode = 400
res.end(JSON.stringify({ error: 'Missing path or content' }))
return
}
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, content, 'utf-8')
res.setHeader('Content-Type', 'application/json')
res.end('null')
} catch (err: unknown) {
res.statusCode = 500
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Save failed' }))
}
return
}
if (url.pathname === '/api/vault/rename' && req.method === 'POST') {
try {
const body = await readRequestBody(req)
const { vault_path: vaultPath, old_path: oldPath, new_title: newTitle } = JSON.parse(body)
const oldContent = fs.readFileSync(oldPath, 'utf-8')
const h1Match = oldContent.match(/^# (.+)$/m)
const oldTitle = h1Match ? h1Match[1].trim() : ''
const slug = newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const parentDir = path.dirname(oldPath)
const newPath = path.join(parentDir, `${slug}.md`)
const newContent = oldContent.replace(/^# .+$/m, `# ${newTitle}`)
fs.writeFileSync(newPath, newContent, 'utf-8')
if (newPath !== oldPath) fs.unlinkSync(oldPath)
let updatedFiles = 0
if (oldTitle && vaultPath) {
const allFiles = findMarkdownFiles(vaultPath)
const escaped = oldTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(`\\[\\[${escaped}(\\|[^\\]]*?)?\\]\\]`, 'g')
for (const f of allFiles) {
if (f === newPath) continue
try {
const c = fs.readFileSync(f, 'utf-8')
const replaced = c.replace(pattern, (_m: string, pipe: string | undefined) =>
pipe ? `[[${newTitle}${pipe}]]` : `[[${newTitle}]]`
)
if (replaced !== c) { fs.writeFileSync(f, replaced, 'utf-8'); updatedFiles++ }
} catch { /* skip */ }
}
}
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ new_path: newPath, updated_files: updatedFiles }))
} catch (err: unknown) {
res.statusCode = 500
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Rename failed' }))
}
return
}
if (url.pathname === '/api/vault/delete' && req.method === 'POST') {
try {
const body = await readRequestBody(req)
const { path: filePath } = JSON.parse(body)
if (!filePath) {
res.statusCode = 400
res.end(JSON.stringify({ error: 'Missing path' }))
return
}
fs.unlinkSync(filePath)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(filePath))
} catch (err: unknown) {
res.statusCode = 500
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Delete failed' }))
}
return
}
if (await handleVaultApiRequest(req, res)) return
next()
})
},
@@ -379,7 +448,7 @@ function vaultApiPlugin(): Plugin {
// --- Proxy helpers ---
function readRequestBody(req: import('http').IncomingMessage): Promise<string> {
function readRequestBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve) => {
let body = ''
req.on('data', (chunk: Buffer) => { body += chunk.toString() })