From 2c981a0a30c8a9b5fed5cb4f3ed6c90556003087 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 14 Feb 2026 20:53:52 +0100 Subject: [PATCH] Add git revision history panel to inspector (M4 Task 4) Show mock git history per file: commit hash (monospace), relative date, commit message, author. Added GitCommit type, mock git history generator, and 'View all revisions' placeholder. Co-Authored-By: Claude Opus 4.6 --- src/App.tsx | 27 +++++++++++++++- src/components/Inspector.css | 52 +++++++++++++++++++++++++++++++ src/components/Inspector.test.tsx | 51 +++++++++++++++++++++++++++++- src/components/Inspector.tsx | 50 +++++++++++++++++++++++++++-- src/mock-tauri.ts | 34 +++++++++++++++++++- src/types.ts | 7 +++++ 6 files changed, 216 insertions(+), 5 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 2364065f..0273b0de 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,7 +6,7 @@ import { Editor } from './components/Editor' import { Inspector } from './components/Inspector' import { ResizeHandle } from './components/ResizeHandle' import { isTauri, mockInvoke } from './mock-tauri' -import type { VaultEntry, SidebarSelection } from './types' +import type { VaultEntry, SidebarSelection, GitCommit } from './types' import './App.css' // TODO: Make vault path configurable via settings @@ -24,6 +24,7 @@ function App() { const [inspectorWidth, setInspectorWidth] = useState(280) const [inspectorCollapsed, setInspectorCollapsed] = useState(false) const [allContent, setAllContent] = useState>({}) + const [gitHistory, setGitHistory] = useState([]) useEffect(() => { const loadVault = async () => { @@ -56,6 +57,29 @@ function App() { loadVault() }, []) + // Load git history when active tab changes + useEffect(() => { + if (!activeTabPath) { + setGitHistory([]) + return + } + const loadHistory = async () => { + try { + let history: GitCommit[] + if (isTauri()) { + history = await invoke('get_git_history', { path: activeTabPath }) + } else { + history = await mockInvoke('get_git_history', { path: activeTabPath }) + } + setGitHistory(history) + } catch (err) { + console.warn('Failed to load git history:', err) + setGitHistory([]) + } + } + loadHistory() + }, [activeTabPath]) + const handleSelectNote = useCallback(async (entry: VaultEntry) => { // If tab already open, just switch to it setTabs((prev) => { @@ -174,6 +198,7 @@ function App() { content={activeTab?.content ?? null} entries={entries} allContent={allContent} + gitHistory={gitHistory} onNavigate={handleNavigateWikilink} /> diff --git a/src/components/Inspector.css b/src/components/Inspector.css index 228503f7..17a60b9d 100644 --- a/src/components/Inspector.css +++ b/src/components/Inspector.css @@ -189,3 +189,55 @@ color: #555; flex-shrink: 0; } + +/* Git History */ +.inspector__commits { + display: flex; + flex-direction: column; + gap: 10px; +} + +.inspector__commit { + border-left: 2px solid #333; + padding-left: 10px; +} + +.inspector__commit-top { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2px; +} + +.inspector__commit-hash { + font-family: 'SF Mono', 'Menlo', monospace; + font-size: 11px; + color: #7eb8da; +} + +.inspector__commit-date { + font-size: 11px; + color: #666; +} + +.inspector__commit-msg { + font-size: 12px; + color: #aaa; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inspector__view-all { + margin-top: 10px; + background: none; + border: none; + color: #666; + font-size: 12px; + padding: 4px 0; + cursor: not-allowed; +} + +.inspector__view-all:hover { + color: #888; +} diff --git a/src/components/Inspector.test.tsx b/src/components/Inspector.test.tsx index 38529a21..1ffd4761 100644 --- a/src/components/Inspector.test.tsx +++ b/src/components/Inspector.test.tsx @@ -1,7 +1,7 @@ import { render, screen, fireEvent } from '@testing-library/react' import { describe, it, expect, vi } from 'vitest' import { Inspector } from './Inspector' -import type { VaultEntry } from '../types' +import type { VaultEntry, GitCommit } from '../types' const mockEntry: VaultEntry = { path: '/vault/project/test.md', @@ -49,6 +49,13 @@ const allContent: Record = { '/vault/project/test.md': '# Test Project\n\nSome content.', } +const now = Math.floor(Date.now() / 1000) +const mockGitHistory: GitCommit[] = [ + { hash: 'a1b2c3d', message: 'Update test with latest changes', author: 'Luca Rossi', date: now - 86400 * 2 }, + { hash: 'e4f5g6h', message: 'Add new section to test', author: 'Luca Rossi', date: now - 86400 * 5 }, + { hash: 'i7j8k9l', message: 'Create test', author: 'Luca Rossi', date: now - 86400 * 12 }, +] + const defaultProps = { collapsed: false, onToggle: () => {}, @@ -56,6 +63,7 @@ const defaultProps = { content: null as string | null, entries: [] as VaultEntry[], allContent: {} as Record, + gitHistory: [] as GitCommit[], onNavigate: () => {}, } @@ -180,4 +188,45 @@ describe('Inspector', () => { fireEvent.click(screen.getByText('Referrer Note')) expect(onNavigate).toHaveBeenCalledWith('Referrer Note') }) + + it('shows git history with commit hashes and messages', () => { + render( + + ) + expect(screen.getByText('History')).toBeInTheDocument() + expect(screen.getByText('a1b2c3d')).toBeInTheDocument() + expect(screen.getByText('Update test with latest changes')).toBeInTheDocument() + expect(screen.getByText('e4f5g6h')).toBeInTheDocument() + expect(screen.getByText('i7j8k9l')).toBeInTheDocument() + }) + + it('shows "View all revisions" placeholder button', () => { + render( + + ) + const btn = screen.getByText('View all revisions') + expect(btn).toBeDisabled() + }) + + it('shows "No revision history" when no commits', () => { + render( + + ) + expect(screen.getByText('No revision history')).toBeInTheDocument() + }) }) diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index 537c4537..956d76ed 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import type { VaultEntry } from '../types' +import type { VaultEntry, GitCommit } from '../types' import './Inspector.css' interface InspectorProps { @@ -9,6 +9,7 @@ interface InspectorProps { content: string | null entries: VaultEntry[] allContent: Record + gitHistory: GitCommit[] onNavigate: (target: string) => void } @@ -196,7 +197,47 @@ function BacklinksPanel({ backlinks, onNavigate }: { backlinks: VaultEntry[]; on ) } -export function Inspector({ collapsed, onToggle, entry, content, entries, allContent, onNavigate }: InspectorProps) { +function formatRelativeDate(timestamp: number): string { + const now = Math.floor(Date.now() / 1000) + const diff = now - timestamp + if (diff < 86400) return 'today' + const days = Math.floor(diff / 86400) + if (days === 1) return 'yesterday' + if (days < 30) return `${days}d ago` + const months = Math.floor(days / 30) + if (months === 1) return '1mo ago' + return `${months}mo ago` +} + +function GitHistoryPanel({ commits }: { commits: GitCommit[] }) { + return ( +
+

History

+ {commits.length === 0 ? ( +

No revision history

+ ) : ( + <> +
+ {commits.map((c) => ( +
+
+ {c.hash} + {formatRelativeDate(c.date)} +
+
{c.message}
+
+ ))} +
+ + + )} +
+ ) +} + +export function Inspector({ collapsed, onToggle, entry, content, entries, allContent, gitHistory, onNavigate }: InspectorProps) { const backlinks = useBacklinks(entry, entries, allContent) return ( @@ -214,6 +255,7 @@ export function Inspector({ collapsed, onToggle, entry, content, entries, allCon + ) : ( <> @@ -229,6 +271,10 @@ export function Inspector({ collapsed, onToggle, entry, content, entries, allCon

Backlinks

No backlinks

+
+

History

+

No revision history

+
)} diff --git a/src/mock-tauri.ts b/src/mock-tauri.ts index 9ad958a9..d8f46672 100644 --- a/src/mock-tauri.ts +++ b/src/mock-tauri.ts @@ -4,7 +4,7 @@ * this provides realistic test data so the UI can be verified visually. */ -import type { VaultEntry } from './types' +import type { VaultEntry, GitCommit } from './types' const MOCK_CONTENT: Record = { '/Users/luca/Laputa/project/26q1-laputa-app.md': `--- @@ -490,10 +490,42 @@ const MOCK_ENTRIES: VaultEntry[] = [ }, ] +function mockGitHistory(path: string): GitCommit[] { + const filename = path.split('/').pop()?.replace('.md', '') ?? 'unknown' + const now = Math.floor(Date.now() / 1000) + return [ + { + hash: 'a1b2c3d', + message: `Update ${filename} with latest changes`, + author: 'Luca Rossi', + date: now - 86400 * 2, + }, + { + hash: 'e4f5g6h', + message: `Add new section to ${filename}`, + author: 'Luca Rossi', + date: now - 86400 * 5, + }, + { + hash: 'i7j8k9l', + message: `Fix formatting in ${filename}`, + author: 'Luca Rossi', + date: now - 86400 * 12, + }, + { + hash: 'm0n1o2p', + message: `Create ${filename}`, + author: 'Luca Rossi', + date: now - 86400 * 30, + }, + ] +} + const mockHandlers: Record any> = { list_vault: () => MOCK_ENTRIES, get_note_content: (args: { path: string }) => MOCK_CONTENT[args.path] ?? '', get_all_content: () => MOCK_CONTENT, + get_git_history: (args: { path: string }) => mockGitHistory(args.path), } export function isTauri(): boolean { diff --git a/src/types.ts b/src/types.ts index bce1a16f..9f44d763 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,13 @@ export interface VaultEntry { fileSize: number } +export interface GitCommit { + hash: string + message: string + author: string + date: number // unix timestamp +} + export type SidebarSelection = | { kind: 'filter'; filter: 'all' | 'people' | 'events' | 'favorites' | 'trash' } | { kind: 'sectionGroup'; type: string }