diff --git a/src-tauri/src/commands/vault.rs b/src-tauri/src/commands/vault.rs index 0e437941..72a77926 100644 --- a/src-tauri/src/commands/vault.rs +++ b/src-tauri/src/commands/vault.rs @@ -1,6 +1,6 @@ use crate::frontmatter::FrontmatterValue; use crate::search::SearchResponse; -use crate::vault::{FolderNode, RenameResult, VaultEntry}; +use crate::vault::{DetectedRename, FolderNode, RenameResult, VaultEntry}; use crate::{frontmatter, git, search, vault}; use super::expand_tilde; @@ -43,6 +43,18 @@ pub fn rename_note( vault::rename_note(&vault_path, &old_path, &new_title, old_title.as_deref()) } +#[tauri::command] +pub fn detect_renames(vault_path: String) -> Result, String> { + let vault_path = expand_tilde(&vault_path); + vault::detect_renames(&vault_path) +} + +#[tauri::command] +pub fn update_wikilinks_for_renames(vault_path: String, renames: Vec) -> Result { + let vault_path = expand_tilde(&vault_path); + vault::update_wikilinks_for_renames(&vault_path, &renames) +} + #[tauri::command] pub fn purge_trash(vault_path: String) -> Result, String> { let vault_path = expand_tilde(&vault_path); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d2f253ee..18c87c29 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -123,6 +123,8 @@ pub fn run() { commands::update_frontmatter, commands::delete_frontmatter_property, commands::rename_note, + commands::detect_renames, + commands::update_wikilinks_for_renames, commands::get_file_history, commands::get_modified_files, commands::get_file_diff, diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index 6df8844e..259b1d10 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -18,7 +18,7 @@ pub use file::{get_note_content, save_note_content}; pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists}; pub use image::{copy_image_to_vault, save_image}; pub use migration::{flatten_vault, migrate_is_a_to_type, vault_health_check, VaultHealthReport}; -pub use rename::{rename_note, RenameResult}; +pub use rename::{detect_renames, rename_note, update_wikilinks_for_renames, DetectedRename, RenameResult}; pub use title_sync::{sync_title_on_open, SyncAction}; pub use trash::{batch_delete_notes, delete_note, empty_trash, is_file_trashed, purge_trash}; diff --git a/src-tauri/src/vault/rename.rs b/src-tauri/src/vault/rename.rs index 79028150..7a632c22 100644 --- a/src-tauri/src/vault/rename.rs +++ b/src-tauri/src/vault/rename.rs @@ -250,6 +250,77 @@ pub fn rename_note( }) } +/// A detected rename: old path → new path (both relative to vault root). +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DetectedRename { + pub old_path: String, + pub new_path: String, +} + +/// Detect renamed files by comparing working tree against HEAD using git diff. +pub fn detect_renames(vault_path: &str) -> Result, String> { + let vault = Path::new(vault_path); + let output = std::process::Command::new("git") + .args(["diff", "HEAD", "--name-status", "--diff-filter=R", "-M"]) + .current_dir(vault) + .output() + .map_err(|e| format!("Failed to run git diff: {e}"))?; + + if !output.status.success() { + return Ok(vec![]); // No HEAD yet or other git issue — no renames + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let renames: Vec = stdout + .lines() + .filter_map(|line| { + let parts: Vec<&str> = line.split('\t').collect(); + if parts.len() >= 3 && parts[0].starts_with('R') { + let old = parts[1].to_string(); + let new = parts[2].to_string(); + if old.ends_with(".md") && new.ends_with(".md") { + return Some(DetectedRename { old_path: old, new_path: new }); + } + } + None + }) + .collect(); + + Ok(renames) +} + +/// Update wikilinks across the vault for a list of detected renames. +/// Returns the total number of files updated. +pub fn update_wikilinks_for_renames(vault_path: &str, renames: &[DetectedRename]) -> Result { + let vault = Path::new(vault_path); + let mut total_updated = 0; + + for rename in renames { + let old_stem = rename.old_path.strip_suffix(".md").unwrap_or(&rename.old_path); + let new_stem = rename.new_path.strip_suffix(".md").unwrap_or(&rename.new_path); + let old_filename_stem = old_stem.split('/').last().unwrap_or(old_stem); + let new_filename_stem = new_stem.split('/').last().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, + }); + total_updated += updated; + } + + Ok(total_updated) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/App.tsx b/src/App.tsx index 9698bda3..c3da020a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -57,6 +57,7 @@ import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/ import { openNoteInNewWindow } from './utils/openNoteWindow' import { isNoteWindow, getNoteWindowParams } from './utils/windowMode' import { GitRequiredModal } from './components/GitRequiredModal' +import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner' import './App.css' // Type declarations for mock content storage and test overrides @@ -132,6 +133,33 @@ function App() { onToast: (msg) => setToastMessage(msg), }) + // Detect external file renames on window focus + const [detectedRenames, setDetectedRenames] = useState([]) + useEffect(() => { + if (!isTauri() || !resolvedPath) return + const handleFocus = () => { + invoke('detect_renames', { vaultPath: resolvedPath }) + .then(renames => { if (renames.length > 0) setDetectedRenames(renames) }) + .catch(() => {}) // ignore errors (e.g., no git) + } + window.addEventListener('focus', handleFocus) + return () => window.removeEventListener('focus', handleFocus) + }, [resolvedPath]) + + const handleUpdateWikilinks = useCallback(async () => { + if (!isTauri()) return + try { + const count = await invoke('update_wikilinks_for_renames', { vaultPath: resolvedPath, renames: detectedRenames }) + setDetectedRenames([]) + vault.reloadVault() + setToastMessage(`Updated wikilinks in ${count} file${count !== 1 ? 's' : ''}`) + } catch (err) { + setToastMessage(`Failed to update wikilinks: ${err}`) + } + }, [resolvedPath, detectedRenames, vault, setToastMessage]) + + const handleDismissRenames = useCallback(() => setDetectedRenames([]), []) + const conflictResolver = useConflictResolver({ vaultPath: resolvedPath, onResolved: () => { @@ -531,6 +559,7 @@ function App() { /> )} + handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} /> setToastMessage(null)} /> diff --git a/src/components/RenameDetectedBanner.test.tsx b/src/components/RenameDetectedBanner.test.tsx new file mode 100644 index 00000000..b85c17af --- /dev/null +++ b/src/components/RenameDetectedBanner.test.tsx @@ -0,0 +1,41 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { RenameDetectedBanner } from './RenameDetectedBanner' + +describe('RenameDetectedBanner', () => { + const renames = [ + { old_path: 'old-note.md', new_path: 'new-note.md' }, + { old_path: 'folder/draft.md', new_path: 'folder/published.md' }, + ] + + it('renders nothing when renames is empty', () => { + const { container } = render( + + ) + expect(container.firstChild).toBeNull() + }) + + it('shows banner with rename count', () => { + render() + expect(screen.getByText(/2 files? renamed/i)).toBeInTheDocument() + }) + + it('shows Update wikilinks button', () => { + render() + expect(screen.getByText('Update wikilinks')).toBeInTheDocument() + }) + + it('calls onUpdate when Update button clicked', () => { + const onUpdate = vi.fn() + render() + fireEvent.click(screen.getByText('Update wikilinks')) + expect(onUpdate).toHaveBeenCalledOnce() + }) + + it('calls onDismiss when Ignore button clicked', () => { + const onDismiss = vi.fn() + render() + fireEvent.click(screen.getByText('Ignore')) + expect(onDismiss).toHaveBeenCalledOnce() + }) +}) diff --git a/src/components/RenameDetectedBanner.tsx b/src/components/RenameDetectedBanner.tsx new file mode 100644 index 00000000..fe6a90af --- /dev/null +++ b/src/components/RenameDetectedBanner.tsx @@ -0,0 +1,38 @@ +import { ArrowsClockwise } from '@phosphor-icons/react' + +export interface DetectedRename { + old_path: string + new_path: string +} + +interface RenameDetectedBannerProps { + renames: DetectedRename[] + onUpdate: () => void + onDismiss: () => void +} + +export function RenameDetectedBanner({ renames, onUpdate, onDismiss }: RenameDetectedBannerProps) { + if (renames.length === 0) return null + + const count = renames.length + return ( +
+ + + {count} file{count !== 1 ? 's' : ''} renamed outside Laputa. Update wikilinks? + + + +
+ ) +}