feat: git history — clickable commit hashes with diff view

This commit is contained in:
lucaronin
2026-02-22 10:02:35 +01:00
9 changed files with 10110 additions and 23 deletions

9870
design/git-history.pen Normal file

File diff suppressed because it is too large Load Diff

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

@@ -216,6 +216,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
@@ -190,7 +191,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,
@@ -358,7 +359,7 @@ export const Editor = memo(function Editor({
const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null
const isLoadingNewTab = activeTabPath !== null && !activeTab
const showDiffToggle = activeTab && isModified?.(activeTab.entry.path)
const showDiffToggle = activeTab && (diffMode || isModified?.(activeTab.entry.path))
useEffect(() => {
setDiffMode(false)
@@ -384,6 +385,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
@@ -444,6 +459,7 @@ export const Editor = memo(function Editor({
allContent={allContent}
gitHistory={gitHistory}
onNavigate={onNavigateWikilink}
onViewCommitDiff={handleViewCommitDiff}
onUpdateFrontmatter={onUpdateFrontmatter}
onDeleteProperty={onDeleteProperty}
onAddProperty={onAddProperty}
@@ -475,6 +491,14 @@ export const Editor = memo(function Editor({
{breadcrumbBar}
{diffMode && (
<div className="flex-1 overflow-auto">
<button
className="flex items-center gap-1.5 px-4 py-2 text-xs text-primary bg-muted border-b border-border cursor-pointer hover:bg-accent transition-colors w-full border-t-0 border-l-0 border-r-0"
onClick={handleToggleDiff}
title="Back to editor"
>
<span style={{ fontSize: 14, lineHeight: 1 }}>&larr;</span>
Back to editor
</button>
<DiffView diff={diffContent ?? ''} />
</div>
)}

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`