feat: add breadcrumb filename rename controls

This commit is contained in:
lucaronin
2026-04-10 17:59:21 +02:00
parent c8db92c92d
commit 968c4d05a9
16 changed files with 1261 additions and 375 deletions

View File

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

View File

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

View File

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

View File

@@ -28,13 +28,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()
}
@@ -81,45 +85,76 @@ fn collect_md_files(vault_path: &Path, exclude: &Path) -> Vec<std::path::PathBuf
/// 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) {
let re = match build_wikilink_pattern(&[params.old_title, params.old_path_stem]) {
Some(r) => r,
None => return 0,
};
replace_wikilinks_in_files(
collect_md_files(params.vault_path, params.exclude_path),
&re,
params.new_title,
)
}
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()
}
fn update_path_wikilinks_in_vault(
vault_path: &Path,
old_path_stem: &str,
new_path_stem: &str,
exclude_path: &Path,
) -> usize {
let re = match build_wikilink_pattern(&[old_path_stem]) {
Some(r) => r,
None => return 0,
};
replace_wikilinks_in_files(
collect_md_files(vault_path, exclude_path),
&re,
new_path_stem,
)
}
/// 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 +203,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
@@ -251,6 +302,63 @@ 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 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 updated_files =
update_path_wikilinks_in_vault(vault, old_path_stem, &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);
@@ -698,6 +806,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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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('Renamed — updated 1 wiki link')
})
it('handleRenameFilename surfaces backend conflict errors', async () => {
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('A note with that name already exists'))
const { result } = renderHook(() => useNoteRename(
{ entries: [makeEntry({ path: '/vault/old-name.md', filename: 'old-name.md' })], setToastMessage },
{ tabs: [], setTabs, activeTabPathRef, handleSwitchTab, updateTabContent },
))
await act(async () => {
await result.current.handleRenameFilename('/vault/old-name.md', 'manual-name', '/vault', vi.fn())
})
expect(setToastMessage).toHaveBeenCalledWith('A note with that name already exists')
})
})

View File

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

View File

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

View File

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

View File

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