feat: clickable commit hashes in git history panel open diff view

- 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 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-21 22:27:18 +01:00
parent 7effa256b4
commit d23ce5a0b5
8 changed files with 231 additions and 22 deletions

View File

@@ -196,6 +196,52 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String
Ok(stdout)
}
/// Get git diff for a specific file at a given commit (compared to its parent).
pub fn get_file_diff_at_commit(vault_path: &str, file_path: &str, commit_hash: &str) -> Result<String, String> {
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<String> = 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<String, String> {
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();

View File

@@ -43,6 +43,11 @@ fn get_file_diff(vault_path: String, path: String) -> Result<String, String> {
git::get_file_diff(&vault_path, &path)
}
#[tauri::command]
fn get_file_diff_at_commit(vault_path: String, path: String, commit_hash: String) -> Result<String, String> {
git::get_file_diff_at_commit(&vault_path, &path, &commit_hash)
}
#[tauri::command]
fn git_commit(vault_path: String, message: String) -> Result<String, String> {
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,

View File

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

View File

@@ -32,6 +32,7 @@ interface EditorProps {
onReorderTabs?: (fromIndex: number, toIndex: number) => void
onNavigateWikilink: (target: string) => void
onLoadDiff?: (path: string) => Promise<string>
onLoadDiffAtCommit?: (path: string, commitHash: string) => Promise<string>
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}

View File

@@ -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(
<Inspector
{...defaultProps}
entry={mockEntry}
content={mockContent}
gitHistory={mockGitHistory}
onViewCommitDiff={onViewCommitDiff}
/>
)
const hashBtn = screen.getByText('a1b2c3d')
expect(hashBtn.tagName).toBe('BUTTON')
hashBtn.click()
expect(onViewCommitDiff).toHaveBeenCalledWith('a1b2c3d4e5f6a7b8')
})
it('shows author name in commit rows', () => {
render(
<Inspector
{...defaultProps}
@@ -247,8 +264,8 @@ This is a test note with some words to count.
gitHistory={mockGitHistory}
/>
)
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', () => {

View File

@@ -34,6 +34,7 @@ interface InspectorProps {
allContent: Record<string, string>
gitHistory: GitCommit[]
onNavigate: (target: string) => void
onViewCommitDiff?: (commitHash: string) => void
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
@@ -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 (
<div>
<h4 className="font-mono-overline mb-2 text-muted-foreground">History</h4>
{commits.length === 0 ? (
<p className="m-0 text-[13px] text-muted-foreground">No revision history</p>
) : (
<>
<div className="flex flex-col gap-2.5">
{commits.map((c) => (
<div key={c.hash} style={{ borderLeft: '2px solid var(--border)', paddingLeft: 10 }}>
<div className="mb-0.5 flex items-center justify-between">
<span className="font-mono text-foreground" style={{ fontSize: 11 }}>{c.shortHash}</span>
<span className="text-muted-foreground" style={{ fontSize: 10 }}>{formatRelativeDate(c.date)}</span>
</div>
<div className="truncate text-xs text-secondary-foreground">{c.message}</div>
<div className="flex flex-col gap-2.5">
{commits.map((c) => (
<div key={c.hash} style={{ borderLeft: '2px solid var(--border)', paddingLeft: 10 }}>
<div className="mb-0.5 flex items-center justify-between">
<button
className="border-none bg-transparent p-0 font-mono text-primary cursor-pointer hover:underline"
style={{ fontSize: 11 }}
onClick={() => onViewCommitDiff?.(c.hash)}
title={`View diff for ${c.shortHash}`}
>
{c.shortHash}
</button>
<span className="text-muted-foreground" style={{ fontSize: 10 }}>{formatRelativeDate(c.date)}</span>
</div>
))}
</div>
<button className="mt-2.5 cursor-not-allowed border-none bg-transparent p-0 py-1 text-xs text-muted-foreground" disabled>
View all revisions
</button>
</>
<div className="truncate text-xs text-secondary-foreground">{c.message}</div>
{c.author && (
<div className="truncate text-muted-foreground" style={{ fontSize: 10, marginTop: 1 }}>{c.author}</div>
)}
</div>
))}
</div>
)}
</div>
)
@@ -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({
/>
<DynamicRelationshipsPanel frontmatter={frontmatter} entries={entries} onNavigate={onNavigate} onAddProperty={onAddProperty ? handleAddProperty : undefined} />
<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />
<GitHistoryPanel commits={gitHistory} />
<GitHistoryPanel commits={gitHistory} onViewCommitDiff={onViewCommitDiff} />
</>
) : (
<EmptyInspector />

View File

@@ -84,6 +84,14 @@ export function useVaultLoader(vaultPath: string) {
}
}, [vaultPath])
const loadDiffAtCommit = useCallback(async (path: string, commitHash: string): Promise<string> => {
if (isTauri()) {
return invoke<string>('get_file_diff_at_commit', { vaultPath, path, commitHash })
} else {
return mockInvoke<string>('get_file_diff_at_commit', { path, commitHash })
}
}, [vaultPath])
const loadDiff = useCallback(async (path: string): Promise<string> => {
if (isTauri()) {
return invoke<string>('get_file_diff', { vaultPath, path })
@@ -122,6 +130,7 @@ export function useVaultLoader(vaultPath: string) {
loadModifiedFiles,
loadGitHistory,
loadDiff,
loadDiffAtCommit,
isFileModified,
commitAndPush,
}

View File

@@ -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<string, (args: any) => any> = {
@@ -1628,6 +1648,7 @@ const mockHandlers: Record<string, (args: any) => 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`