feat: git history — clickable commit hashes with diff view
This commit is contained in:
9870
design/git-history.pen
Normal file
9870
design/git-history.pen
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }}>←</span>
|
||||
Back to editor
|
||||
</button>
|
||||
<DiffView diff={diffContent ?? ''} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user