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 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-14 20:53:52 +01:00
parent 0d03c6a2ae
commit 2c981a0a30
6 changed files with 216 additions and 5 deletions

View File

@@ -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<Record<string, string>>({})
const [gitHistory, setGitHistory] = useState<GitCommit[]>([])
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<GitCommit[]>('get_git_history', { path: activeTabPath })
} else {
history = await mockInvoke<GitCommit[]>('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}
/>
</div>

View File

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

View File

@@ -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<string, string> = {
'/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<string, string>,
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(
<Inspector
{...defaultProps}
entry={mockEntry}
content={mockContent}
gitHistory={mockGitHistory}
/>
)
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(
<Inspector
{...defaultProps}
entry={mockEntry}
content={mockContent}
gitHistory={mockGitHistory}
/>
)
const btn = screen.getByText('View all revisions')
expect(btn).toBeDisabled()
})
it('shows "No revision history" when no commits', () => {
render(
<Inspector
{...defaultProps}
entry={mockEntry}
content={mockContent}
gitHistory={[]}
/>
)
expect(screen.getByText('No revision history')).toBeInTheDocument()
})
})

View File

@@ -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<string, string>
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 (
<div className="inspector__section">
<h4>History</h4>
{commits.length === 0 ? (
<p className="inspector__empty">No revision history</p>
) : (
<>
<div className="inspector__commits">
{commits.map((c) => (
<div key={c.hash} className="inspector__commit">
<div className="inspector__commit-top">
<span className="inspector__commit-hash">{c.hash}</span>
<span className="inspector__commit-date">{formatRelativeDate(c.date)}</span>
</div>
<div className="inspector__commit-msg">{c.message}</div>
</div>
))}
</div>
<button className="inspector__view-all" disabled>
View all revisions
</button>
</>
)}
</div>
)
}
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
<PropertiesPanel entry={entry} content={content} />
<RelationshipsPanel entry={entry} onNavigate={onNavigate} />
<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />
<GitHistoryPanel commits={gitHistory} />
</>
) : (
<>
@@ -229,6 +271,10 @@ export function Inspector({ collapsed, onToggle, entry, content, entries, allCon
<h4>Backlinks</h4>
<p className="inspector__empty">No backlinks</p>
</div>
<div className="inspector__section">
<h4>History</h4>
<p className="inspector__empty">No revision history</p>
</div>
</>
)}
</div>

View File

@@ -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<string, string> = {
'/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<string, (args: any) => 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 {

View File

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