From d23ce5a0b560e8484f767bb50f1e9b76d6e46486 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 21 Feb 2026 22:27:18 +0100 Subject: [PATCH] feat: clickable commit hashes in git history panel open diff view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add get_file_diff_at_commit Rust command to show diff at a specific commit - Make shortHash in GitHistoryPanel a clickable button that opens DiffView - Show author name below commit message (small, muted) - Remove disabled "View all revisions" button - Wire onLoadDiffAtCommit through Editor → Inspector → App.tsx - Add mock handler for get_file_diff_at_commit in browser mode - Fix pre-existing missing `order` field on trashed mock entries - Update Inspector tests for new behavior Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/git.rs | 133 ++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 6 ++ src/App.tsx | 1 + src/components/Editor.tsx | 18 +++- src/components/Inspector.test.tsx | 23 +++++- src/components/Inspector.tsx | 42 ++++++---- src/hooks/useVaultLoader.ts | 9 ++ src/mock-tauri.ts | 21 +++++ 8 files changed, 231 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index f9441427..89e037b4 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -196,6 +196,52 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result Result { + let vault = Path::new(vault_path); + let file = Path::new(file_path); + + let relative = file + .strip_prefix(vault) + .map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?; + + let relative_str = relative + .to_str() + .ok_or_else(|| "Invalid UTF-8 in path".to_string())?; + + // Show diff between commit^ and commit for this file + let output = Command::new("git") + .args(["diff", &format!("{}^", commit_hash), commit_hash, "--", relative_str]) + .current_dir(vault) + .output() + .map_err(|e| format!("Failed to run git diff: {}", e))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + + // If diff is empty, it might be the initial commit (no parent). + // Fall back to showing the full file content as added. + if stdout.is_empty() { + let show = Command::new("git") + .args(["show", &format!("{}:{}", commit_hash, relative_str)]) + .current_dir(vault) + .output() + .map_err(|e| format!("Failed to run git show: {}", e))?; + + if show.status.success() { + let content = String::from_utf8_lossy(&show.stdout); + let lines: Vec = content.lines().map(|l| format!("+{}", l)).collect(); + return Ok(format!( + "diff --git a/{0} b/{0}\nnew file\n--- /dev/null\n+++ b/{0}\n@@ -0,0 +1,{1} @@\n{2}", + relative_str, + lines.len(), + lines.join("\n") + )); + } + } + + Ok(stdout) +} + /// Commit all changes with a message. pub fn git_commit(vault_path: &str, message: &str) -> Result { let vault = Path::new(vault_path); @@ -401,6 +447,93 @@ mod tests { assert!(diff.contains("+Modified content.")); } + #[test] + fn test_get_file_diff_at_commit() { + let dir = setup_git_repo(); + let vault = dir.path(); + let file = vault.join("diff-at-commit.md"); + + fs::write(&file, "# First\n\nOriginal content.").unwrap(); + Command::new("git") + .args(["add", "diff-at-commit.md"]) + .current_dir(vault) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "First commit"]) + .current_dir(vault) + .output() + .unwrap(); + + fs::write(&file, "# First\n\nModified content.").unwrap(); + Command::new("git") + .args(["add", "diff-at-commit.md"]) + .current_dir(vault) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "Second commit"]) + .current_dir(vault) + .output() + .unwrap(); + + // Get hash of second commit + let log = Command::new("git") + .args(["log", "--format=%H", "-1"]) + .current_dir(vault) + .output() + .unwrap(); + let hash = String::from_utf8_lossy(&log.stdout).trim().to_string(); + + let diff = get_file_diff_at_commit( + vault.to_str().unwrap(), + file.to_str().unwrap(), + &hash, + ) + .unwrap(); + + assert!(!diff.is_empty()); + assert!(diff.contains("-Original content.")); + assert!(diff.contains("+Modified content.")); + } + + #[test] + fn test_get_file_diff_at_initial_commit() { + let dir = setup_git_repo(); + let vault = dir.path(); + let file = vault.join("initial.md"); + + fs::write(&file, "# Initial\n\nHello world.").unwrap(); + Command::new("git") + .args(["add", "initial.md"]) + .current_dir(vault) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "Initial commit"]) + .current_dir(vault) + .output() + .unwrap(); + + let log = Command::new("git") + .args(["log", "--format=%H", "-1"]) + .current_dir(vault) + .output() + .unwrap(); + let hash = String::from_utf8_lossy(&log.stdout).trim().to_string(); + + let diff = get_file_diff_at_commit( + vault.to_str().unwrap(), + file.to_str().unwrap(), + &hash, + ) + .unwrap(); + + assert!(!diff.is_empty()); + assert!(diff.contains("+# Initial")); + assert!(diff.contains("+Hello world.")); + } + #[test] fn test_git_commit() { let dir = setup_git_repo(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 54164800..acaaee6d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -43,6 +43,11 @@ fn get_file_diff(vault_path: String, path: String) -> Result { git::get_file_diff(&vault_path, &path) } +#[tauri::command] +fn get_file_diff_at_commit(vault_path: String, path: String, commit_hash: String) -> Result { + git::get_file_diff_at_commit(&vault_path, &path, &commit_hash) +} + #[tauri::command] fn git_commit(vault_path: String, message: String) -> Result { git::git_commit(&vault_path, &message) @@ -106,6 +111,7 @@ pub fn run() { get_file_history, get_modified_files, get_file_diff, + get_file_diff_at_commit, git_commit, git_push, ai_chat, diff --git a/src/App.tsx b/src/App.tsx index 4aa7a017..338ec422 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -203,6 +203,7 @@ function App() { onReorderTabs={notes.handleReorderTabs} onNavigateWikilink={notes.handleNavigateWikilink} onLoadDiff={vault.loadDiff} + onLoadDiffAtCommit={vault.loadDiffAtCommit} isModified={vault.isFileModified} onCreateNote={openCreateDialog} inspectorCollapsed={inspectorCollapsed} diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 5c23727c..565a6f6a 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -32,6 +32,7 @@ interface EditorProps { onReorderTabs?: (fromIndex: number, toIndex: number) => void onNavigateWikilink: (target: string) => void onLoadDiff?: (path: string) => Promise + onLoadDiffAtCommit?: (path: string, commitHash: string) => Promise isModified?: (path: string) => boolean onCreateNote?: () => void // Inspector props @@ -188,7 +189,7 @@ function SingleEditorView({ editor, entries, onNavigateWikilink }: { editor: Ret } export const Editor = memo(function Editor({ - tabs, activeTabPath, entries, onSwitchTab, onCloseTab, onReorderTabs, onNavigateWikilink, onLoadDiff, isModified, onCreateNote, + tabs, activeTabPath, entries, onSwitchTab, onCloseTab, onReorderTabs, onNavigateWikilink, onLoadDiff, onLoadDiffAtCommit, isModified, onCreateNote, inspectorCollapsed, onToggleInspector, inspectorWidth, onInspectorResize, inspectorEntry, inspectorContent, allContent, gitHistory, onUpdateFrontmatter, onDeleteProperty, onAddProperty, @@ -381,6 +382,20 @@ export const Editor = memo(function Editor({ } }, [diffMode, activeTabPath, onLoadDiff]) + const handleViewCommitDiff = useCallback(async (commitHash: string) => { + if (!activeTabPath || !onLoadDiffAtCommit) return + setDiffLoading(true) + try { + const diff = await onLoadDiffAtCommit(activeTabPath, commitHash) + setDiffContent(diff) + setDiffMode(true) + } catch (err) { + console.warn('Failed to load commit diff:', err) + } finally { + setDiffLoading(false) + } + }, [activeTabPath, onLoadDiffAtCommit]) + const activeModified = activeTab ? isModified?.(activeTab.entry.path) ?? false : false const wordCount = activeTab ? countWords(activeTab.content) : 0 @@ -439,6 +454,7 @@ export const Editor = memo(function Editor({ allContent={allContent} gitHistory={gitHistory} onNavigate={onNavigateWikilink} + onViewCommitDiff={handleViewCommitDiff} onUpdateFrontmatter={onUpdateFrontmatter} onDeleteProperty={onDeleteProperty} onAddProperty={onAddProperty} diff --git a/src/components/Inspector.test.tsx b/src/components/Inspector.test.tsx index 615ac1fd..d7e31820 100644 --- a/src/components/Inspector.test.tsx +++ b/src/components/Inspector.test.tsx @@ -238,7 +238,24 @@ This is a test note with some words to count. expect(screen.getByText('i7j8k9l')).toBeInTheDocument() }) - it('shows "View all revisions" placeholder button', () => { + it('renders commit hashes as clickable buttons', () => { + const onViewCommitDiff = vi.fn() + render( + + ) + const hashBtn = screen.getByText('a1b2c3d') + expect(hashBtn.tagName).toBe('BUTTON') + hashBtn.click() + expect(onViewCommitDiff).toHaveBeenCalledWith('a1b2c3d4e5f6a7b8') + }) + + it('shows author name in commit rows', () => { render( ) - const btn = screen.getByText('View all revisions') - expect(btn).toBeDisabled() + const authors = screen.getAllByText('Luca Rossi') + expect(authors.length).toBeGreaterThan(0) }) it('shows "No revision history" when no commits', () => { diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index 86295a14..26724061 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -34,6 +34,7 @@ interface InspectorProps { allContent: Record gitHistory: GitCommit[] onNavigate: (target: string) => void + onViewCommitDiff?: (commitHash: string) => void onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise onDeleteProperty?: (path: string, key: string) => Promise onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise @@ -297,29 +298,34 @@ function formatRelativeDate(timestamp: number): string { return `${months}mo ago` } -function GitHistoryPanel({ commits }: { commits: GitCommit[] }) { +function GitHistoryPanel({ commits, onViewCommitDiff }: { commits: GitCommit[]; onViewCommitDiff?: (commitHash: string) => void }) { return (

History

{commits.length === 0 ? (

No revision history

) : ( - <> -
- {commits.map((c) => ( -
-
- {c.shortHash} - {formatRelativeDate(c.date)} -
-
{c.message}
+
+ {commits.map((c) => ( +
+
+ + {formatRelativeDate(c.date)}
- ))} -
- - +
{c.message}
+ {c.author && ( +
{c.author}
+ )} +
+ ))} +
)}
) @@ -348,7 +354,7 @@ function EmptyInspector() { export function Inspector({ collapsed, onToggle, entry, content, entries, allContent, gitHistory, onNavigate, - onUpdateFrontmatter, onDeleteProperty, onAddProperty, + onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty, }: InspectorProps) { const backlinks = useBacklinks(entry, entries, allContent) const frontmatter = useMemo(() => parseFrontmatter(content), [content]) @@ -398,7 +404,7 @@ export function Inspector({ /> - + ) : ( diff --git a/src/hooks/useVaultLoader.ts b/src/hooks/useVaultLoader.ts index fee235a8..55a52a0a 100644 --- a/src/hooks/useVaultLoader.ts +++ b/src/hooks/useVaultLoader.ts @@ -84,6 +84,14 @@ export function useVaultLoader(vaultPath: string) { } }, [vaultPath]) + const loadDiffAtCommit = useCallback(async (path: string, commitHash: string): Promise => { + if (isTauri()) { + return invoke('get_file_diff_at_commit', { vaultPath, path, commitHash }) + } else { + return mockInvoke('get_file_diff_at_commit', { path, commitHash }) + } + }, [vaultPath]) + const loadDiff = useCallback(async (path: string): Promise => { if (isTauri()) { return invoke('get_file_diff', { vaultPath, path }) @@ -122,6 +130,7 @@ export function useVaultLoader(vaultPath: string) { loadModifiedFiles, loadGitHistory, loadDiff, + loadDiffAtCommit, isFileModified, commitAndPush, } diff --git a/src/mock-tauri.ts b/src/mock-tauri.ts index 385abd08..ba6640d0 100644 --- a/src/mock-tauri.ts +++ b/src/mock-tauri.ts @@ -1438,6 +1438,7 @@ const MOCK_ENTRIES: VaultEntry[] = [ }, icon: null, color: null, + order: null, }, { path: '/Users/luca/Laputa/note/deprecated-api-notes.md', @@ -1462,6 +1463,7 @@ const MOCK_ENTRIES: VaultEntry[] = [ }, icon: null, color: null, + order: null, }, { path: '/Users/luca/Laputa/experiment/failed-seo-experiment.md', @@ -1487,6 +1489,7 @@ const MOCK_ENTRIES: VaultEntry[] = [ }, icon: null, color: null, + order: null, }, // --- Archived entries --- { @@ -1619,6 +1622,23 @@ index abc1234..def5678 100644 +A new paragraph has been added.` } +function mockFileDiffAtCommit(path: string, commitHash: string): string { + const filename = path.split('/').pop() ?? 'unknown' + const shortHash = commitHash.slice(0, 7) + return `diff --git a/${filename} b/${filename} +index abc1234..${shortHash} 100644 +--- a/${filename} ++++ b/${filename} +@@ -5,3 +5,5 @@ + --- + + # Example Note +-Old paragraph from before ${shortHash}. ++Updated paragraph at commit ${shortHash}. ++ ++New content added in this commit.` +} + let mockHasChanges = true const mockHandlers: Record any> = { @@ -1628,6 +1648,7 @@ const mockHandlers: Record any> = { get_file_history: (args: { path: string }) => mockFileHistory(args.path), get_modified_files: () => mockHasChanges ? mockModifiedFiles() : [], get_file_diff: (args: { path: string }) => mockFileDiff(args.path), + get_file_diff_at_commit: (args: { path: string; commitHash: string }) => mockFileDiffAtCommit(args.path, args.commitHash), git_commit: (args: { message: string }) => { mockHasChanges = false return `[main abc1234] ${args.message}\n 3 files changed`