Compare commits
6 Commits
v0.2026041
...
v0.2026041
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98d19e4c41 | ||
|
|
5ebffc447b | ||
|
|
a44dd41f95 | ||
|
|
eb67b98d96 | ||
|
|
968c4d05a9 | ||
|
|
c8db92c92d |
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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: ®ex::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]
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' } },
|
||||
' ',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -75,6 +75,7 @@ export function EditorRightPanel({
|
||||
content={inspectorContent}
|
||||
entries={entries}
|
||||
gitHistory={gitHistory}
|
||||
vaultPath={vaultPath}
|
||||
onNavigate={onNavigateWikilink}
|
||||
onViewCommitDiff={onViewCommitDiff}
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]]')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] : []
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -148,5 +148,6 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
config.onFrontmatterPersisted?.()
|
||||
}, [runFrontmatterOp, config]),
|
||||
handleRenameNote: rename.handleRenameNote,
|
||||
handleRenameFilename: rename.handleRenameFilename,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
117
src/utils/rawEditorUtils.test.ts
Normal file
117
src/utils/rawEditorUtils.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
107
tests/smoke/breadcrumb-filename-rename.spec.ts
Normal file
107
tests/smoke/breadcrumb-filename-rename.spec.ts
Normal 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}`)
|
||||
})
|
||||
@@ -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()) ?? ''
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
415
vite.config.ts
415
vite.config.ts
@@ -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() })
|
||||
|
||||
Reference in New Issue
Block a user