From 9a15755b53339dbaec4b29ea111cdc5f118fb40d Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 15 Feb 2026 12:54:11 +0100 Subject: [PATCH] Add real git revision history to Inspector panel Replace mock git history with real `git log` data via new `get_file_history` Tauri command. Adds git.rs module with commands for file history, modified files, file diff, commit, and push (all registered, later tasks wire up the UI). Updates GitCommit type to include shortHash field. Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/git.rs | 423 ++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 56 +++- src/App.tsx | 233 +++++++++++++++- src/components/Inspector.test.tsx | 6 +- src/components/Inspector.tsx | 2 +- src/mock-tauri.ts | 70 ++++- src/types.ts | 10 +- 7 files changed, 775 insertions(+), 25 deletions(-) create mode 100644 src-tauri/src/git.rs diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs new file mode 100644 index 00000000..f9441427 --- /dev/null +++ b/src-tauri/src/git.rs @@ -0,0 +1,423 @@ +use serde::Serialize; +use std::path::Path; +use std::process::Command; + +#[derive(Debug, Serialize, Clone)] +pub struct GitCommit { + pub hash: String, + #[serde(rename = "shortHash")] + pub short_hash: String, + pub message: String, + pub author: String, + pub date: i64, +} + +/// Get git log history for a specific file in the vault. +pub fn get_file_history(vault_path: &str, file_path: &str) -> Result, 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())?; + + let output = Command::new("git") + .args([ + "log", + "--format=%H|%h|%an|%aI|%s", + "-n", + "20", + "--", + relative_str, + ]) + .current_dir(vault) + .output() + .map_err(|e| format!("Failed to run git log: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // No commits yet is not an error - just return empty history + if stderr.contains("does not have any commits yet") { + return Ok(Vec::new()); + } + return Err(format!("git log failed: {}", stderr)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let commits = stdout + .lines() + .filter(|line| !line.is_empty()) + .filter_map(|line| { + // Format: hash|short_hash|author|date|message + // Use splitn(5) so message (last) can contain '|' + let parts: Vec<&str> = line.splitn(5, '|').collect(); + if parts.len() != 5 { + return None; + } + let date = chrono::DateTime::parse_from_rfc3339(parts[3]) + .map(|dt| dt.timestamp()) + .unwrap_or(0); + + Some(GitCommit { + hash: parts[0].to_string(), + short_hash: parts[1].to_string(), + author: parts[2].to_string(), + date, + message: parts[4].to_string(), + }) + }) + .collect(); + + Ok(commits) +} + +/// Get list of modified/added/deleted files in the vault (uncommitted changes). +pub fn get_modified_files(vault_path: &str) -> Result, String> { + let vault = Path::new(vault_path); + + let output = Command::new("git") + .args(["status", "--porcelain"]) + .current_dir(vault) + .output() + .map_err(|e| format!("Failed to run git status: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git status failed: {}", stderr)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let files = stdout + .lines() + .filter(|line| !line.is_empty()) + .filter_map(|line| { + if line.len() < 4 { + return None; + } + let status_code = &line[..2]; + let path = line[3..].trim().to_string(); + + // Only include markdown files + if !path.ends_with(".md") { + return None; + } + + let status = match status_code.trim() { + "M" | "MM" | "AM" => "modified", + "A" => "added", + "D" => "deleted", + "??" => "untracked", + "R" | "RM" => "renamed", + _ => "modified", + }; + + let full_path = vault.join(&path).to_string_lossy().to_string(); + + Some(ModifiedFile { + path: full_path, + relative_path: path, + status: status.to_string(), + }) + }) + .collect(); + + Ok(files) +} + +#[derive(Debug, Serialize, Clone)] +pub struct ModifiedFile { + pub path: String, + #[serde(rename = "relativePath")] + pub relative_path: String, + pub status: String, +} + +/// Get git diff for a specific file. +pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result { + 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())?; + + // First try tracked file diff + let output = Command::new("git") + .args(["diff", "--", 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 no diff (maybe staged or untracked), try diff --cached + if stdout.is_empty() { + let cached = Command::new("git") + .args(["diff", "--cached", "--", relative_str]) + .current_dir(vault) + .output() + .map_err(|e| format!("Failed to run git diff --cached: {}", e))?; + + let cached_stdout = String::from_utf8_lossy(&cached.stdout).to_string(); + if !cached_stdout.is_empty() { + return Ok(cached_stdout); + } + + // Try showing untracked file as all-new + let status = Command::new("git") + .args(["status", "--porcelain", "--", relative_str]) + .current_dir(vault) + .output() + .map_err(|e| format!("Failed to run git status: {}", e))?; + + let status_out = String::from_utf8_lossy(&status.stdout); + if status_out.starts_with("??") { + // Untracked file: show entire content as added + let content = std::fs::read_to_string(file) + .map_err(|e| format!("Failed to read file: {}", e))?; + let lines: Vec = 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 { + let vault = Path::new(vault_path); + + // Stage all changes + let add = Command::new("git") + .args(["add", "-A"]) + .current_dir(vault) + .output() + .map_err(|e| format!("Failed to run git add: {}", e))?; + + if !add.status.success() { + let stderr = String::from_utf8_lossy(&add.stderr); + return Err(format!("git add failed: {}", stderr)); + } + + // Commit + let commit = Command::new("git") + .args(["commit", "-m", message]) + .current_dir(vault) + .output() + .map_err(|e| format!("Failed to run git commit: {}", e))?; + + if !commit.status.success() { + let stderr = String::from_utf8_lossy(&commit.stderr); + return Err(format!("git commit failed: {}", stderr)); + } + + Ok(String::from_utf8_lossy(&commit.stdout).to_string()) +} + +/// Push to remote. +pub fn git_push(vault_path: &str) -> Result { + let vault = Path::new(vault_path); + + let output = Command::new("git") + .args(["push"]) + .current_dir(vault) + .output() + .map_err(|e| format!("Failed to run git push: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git push failed: {}", stderr)); + } + + // git push often writes to stderr even on success + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + Ok(format!("{}{}", stdout, stderr)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::process::Command; + use tempfile::TempDir; + + fn setup_git_repo() -> TempDir { + let dir = TempDir::new().unwrap(); + let path = dir.path(); + + Command::new("git") + .args(["init"]) + .current_dir(path) + .output() + .unwrap(); + + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(path) + .output() + .unwrap(); + + Command::new("git") + .args(["config", "user.name", "Test User"]) + .current_dir(path) + .output() + .unwrap(); + + dir + } + + #[test] + fn test_get_file_history_with_commits() { + let dir = setup_git_repo(); + let vault = dir.path(); + let file = vault.join("test.md"); + + fs::write(&file, "# Initial\n").unwrap(); + Command::new("git") + .args(["add", "test.md"]) + .current_dir(vault) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "Initial commit"]) + .current_dir(vault) + .output() + .unwrap(); + + fs::write(&file, "# Updated\n\nNew content.").unwrap(); + Command::new("git") + .args(["add", "test.md"]) + .current_dir(vault) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "Update test"]) + .current_dir(vault) + .output() + .unwrap(); + + let history = get_file_history( + vault.to_str().unwrap(), + file.to_str().unwrap(), + ) + .unwrap(); + + assert_eq!(history.len(), 2); + assert_eq!(history[0].message, "Update test"); + assert_eq!(history[1].message, "Initial commit"); + assert_eq!(history[0].author, "Test User"); + assert!(!history[0].hash.is_empty()); + assert!(!history[0].short_hash.is_empty()); + } + + #[test] + fn test_get_file_history_no_commits() { + let dir = setup_git_repo(); + let vault = dir.path(); + let file = vault.join("new.md"); + fs::write(&file, "# New\n").unwrap(); + + let history = get_file_history( + vault.to_str().unwrap(), + file.to_str().unwrap(), + ) + .unwrap(); + + assert!(history.is_empty()); + } + + #[test] + fn test_get_modified_files() { + let dir = setup_git_repo(); + let vault = dir.path(); + + // Create and commit a file + fs::write(vault.join("note.md"), "# Note\n").unwrap(); + Command::new("git") + .args(["add", "note.md"]) + .current_dir(vault) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "Add note"]) + .current_dir(vault) + .output() + .unwrap(); + + // Modify it + fs::write(vault.join("note.md"), "# Note\n\nUpdated.").unwrap(); + // Add an untracked file + fs::write(vault.join("new.md"), "# New\n").unwrap(); + + let modified = get_modified_files(vault.to_str().unwrap()).unwrap(); + + assert!(modified.len() >= 2); + let statuses: Vec<&str> = modified.iter().map(|f| f.status.as_str()).collect(); + assert!(statuses.contains(&"modified") || statuses.contains(&"untracked")); + } + + #[test] + fn test_get_file_diff() { + let dir = setup_git_repo(); + let vault = dir.path(); + let file = vault.join("diff-test.md"); + + fs::write(&file, "# Test\n\nOriginal content.").unwrap(); + Command::new("git") + .args(["add", "diff-test.md"]) + .current_dir(vault) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "Add diff-test"]) + .current_dir(vault) + .output() + .unwrap(); + + fs::write(&file, "# Test\n\nModified content.").unwrap(); + + let diff = get_file_diff( + vault.to_str().unwrap(), + file.to_str().unwrap(), + ) + .unwrap(); + + assert!(!diff.is_empty()); + assert!(diff.contains("-Original content.")); + assert!(diff.contains("+Modified content.")); + } + + #[test] + fn test_git_commit() { + let dir = setup_git_repo(); + let vault = dir.path(); + + fs::write(vault.join("commit-test.md"), "# Test\n").unwrap(); + + let result = git_commit(vault.to_str().unwrap(), "Test commit"); + assert!(result.is_ok()); + + // Verify the commit exists + let log = Command::new("git") + .args(["log", "--oneline", "-1"]) + .current_dir(vault) + .output() + .unwrap(); + let log_str = String::from_utf8_lossy(&log.stdout); + assert!(log_str.contains("Test commit")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 30af0cc5..d9ef0d14 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,12 +1,54 @@ +pub mod git; pub mod vault; -use vault::VaultEntry; +use git::{GitCommit, ModifiedFile}; +use vault::{VaultEntry, FrontmatterValue}; #[tauri::command] fn list_vault(path: String) -> Result, String> { vault::scan_vault(&path) } +#[tauri::command] +fn get_note_content(path: String) -> Result { + vault::get_note_content(&path) +} + +#[tauri::command] +fn update_frontmatter(path: String, key: String, value: FrontmatterValue) -> Result { + vault::update_frontmatter(&path, &key, value) +} + +#[tauri::command] +fn delete_frontmatter_property(path: String, key: String) -> Result { + vault::delete_frontmatter_property(&path, &key) +} + +#[tauri::command] +fn get_file_history(vault_path: String, path: String) -> Result, String> { + git::get_file_history(&vault_path, &path) +} + +#[tauri::command] +fn get_modified_files(vault_path: String) -> Result, String> { + git::get_modified_files(&vault_path) +} + +#[tauri::command] +fn get_file_diff(vault_path: String, path: String) -> Result { + git::get_file_diff(&vault_path, &path) +} + +#[tauri::command] +fn git_commit(vault_path: String, message: String) -> Result { + git::git_commit(&vault_path, &message) +} + +#[tauri::command] +fn git_push(vault_path: String) -> Result { + git::git_push(&vault_path) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -20,7 +62,17 @@ pub fn run() { } Ok(()) }) - .invoke_handler(tauri::generate_handler![list_vault]) + .invoke_handler(tauri::generate_handler![ + list_vault, + get_note_content, + update_frontmatter, + delete_frontmatter_property, + get_file_history, + get_modified_files, + get_file_diff, + git_commit, + git_push + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src/App.tsx b/src/App.tsx index d616289f..1e8132c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,18 +3,127 @@ import { invoke } from '@tauri-apps/api/core' import { Sidebar } from './components/Sidebar' import { NoteList } from './components/NoteList' import { Editor } from './components/Editor' -import { Inspector } from './components/Inspector' +import { Inspector, type FrontmatterValue } from './components/Inspector' import { ResizeHandle } from './components/ResizeHandle' import { CreateNoteDialog, type NoteType } from './components/CreateNoteDialog' import { QuickOpenPalette } from './components/QuickOpenPalette' import { Toast } from './components/Toast' -import { isTauri, mockInvoke, addMockEntry } from './mock-tauri' +import { isTauri, mockInvoke, addMockEntry, updateMockContent } from './mock-tauri' import type { VaultEntry, SidebarSelection, GitCommit } from './types' import './App.css' // TODO: Make vault path configurable via settings const TEST_VAULT_PATH = '~/Laputa' +// Mock frontmatter update for browser testing +function updateMockFrontmatter(path: string, key: string, value: FrontmatterValue): string { + // This is a simplified mock - in reality the Rust backend handles this + const content = window.__mockContent?.[path] || '' + + // Format the key (quote if has spaces) + const yamlKey = key.includes(' ') ? `"${key}"` : key + + // Format the value + let yamlValue: string + if (Array.isArray(value)) { + yamlValue = '\n' + value.map(v => ` - "${v}"`).join('\n') + } else if (typeof value === 'boolean') { + yamlValue = value ? 'true' : 'false' + } else if (value === null) { + yamlValue = 'null' + } else { + yamlValue = String(value) + } + + // Check if content has frontmatter + if (!content.startsWith('---\n')) { + // Add frontmatter + return `---\n${yamlKey}: ${yamlValue}\n---\n${content}` + } + + // Find frontmatter end + const fmEnd = content.indexOf('\n---', 4) + if (fmEnd === -1) return content + + const fm = content.slice(4, fmEnd) + const rest = content.slice(fmEnd + 4) + + // Check if key exists + const keyPattern = new RegExp(`^["']?${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']?\\s*:`, 'm') + + if (keyPattern.test(fm)) { + // Replace existing key - need to handle multiline values + const lines = fm.split('\n') + const newLines: string[] = [] + let i = 0 + while (i < lines.length) { + if (keyPattern.test(lines[i])) { + // Skip this key and any list items + i++ + while (i < lines.length && lines[i].startsWith(' - ')) { + i++ + } + // Add new value + if (Array.isArray(value)) { + newLines.push(`${yamlKey}:${yamlValue}`) + } else { + newLines.push(`${yamlKey}: ${yamlValue}`) + } + continue + } + newLines.push(lines[i]) + i++ + } + return `---\n${newLines.join('\n')}\n---${rest}` + } else { + // Add new key + if (Array.isArray(value)) { + return `---\n${fm}\n${yamlKey}:${yamlValue}\n---${rest}` + } else { + return `---\n${fm}\n${yamlKey}: ${yamlValue}\n---${rest}` + } + } +} + +function deleteMockFrontmatterProperty(path: string, key: string): string { + const content = window.__mockContent?.[path] || '' + + if (!content.startsWith('---\n')) return content + + const fmEnd = content.indexOf('\n---', 4) + if (fmEnd === -1) return content + + const fm = content.slice(4, fmEnd) + const rest = content.slice(fmEnd + 4) + + const keyPattern = new RegExp(`^["']?${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']?\\s*:`, 'm') + + const lines = fm.split('\n') + const newLines: string[] = [] + let i = 0 + while (i < lines.length) { + if (keyPattern.test(lines[i])) { + // Skip this key and any list items + i++ + while (i < lines.length && lines[i].startsWith(' - ')) { + i++ + } + continue + } + newLines.push(lines[i]) + i++ + } + + return `---\n${newLines.join('\n')}\n---${rest}` +} + +// Type declaration for mock content storage +declare global { + interface Window { + __mockContent?: Record + } +} + const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' } function App() { @@ -78,9 +187,10 @@ function App() { try { let history: GitCommit[] if (isTauri()) { - history = await invoke('get_git_history', { path: activeTabPath }) + const vaultPath = TEST_VAULT_PATH.replace('~', '/Users/luca') + history = await invoke('get_file_history', { vaultPath, path: activeTabPath }) } else { - history = await mockInvoke('get_git_history', { path: activeTabPath }) + history = await mockInvoke('get_file_history', { path: activeTabPath }) } setGitHistory(history) } catch (err) { @@ -176,14 +286,43 @@ function App() { }, []) const handleNavigateWikilink = useCallback((target: string) => { - // Find entry by title (case-insensitive) or alias - const found = entries.find( - (e) => - e.title.toLowerCase() === target.toLowerCase() || - e.aliases.some((a) => a.toLowerCase() === target.toLowerCase()) - ) + // Find entry by various matching strategies: + // 1. Exact title match (case-insensitive) + // 2. Alias match + // 3. Path-based match (e.g., "responsibility/grow-newsletter" matches path ending with that) + // 4. Slug-to-title match (e.g., "grow-newsletter" → "Grow Newsletter") + + const targetLower = target.toLowerCase() + + // Convert slug to title case for comparison (e.g., "grow-newsletter" → "grow newsletter") + const slugToWords = (s: string) => s.replace(/-/g, ' ').toLowerCase() + const targetAsWords = slugToWords(target.split('/').pop() ?? target) + + const found = entries.find((e) => { + // 1. Exact title match + if (e.title.toLowerCase() === targetLower) return true + + // 2. Alias match + if (e.aliases.some((a) => a.toLowerCase() === targetLower)) return true + + // 3. Path-based match: target like "responsibility/grow-newsletter" + const pathStem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '') + if (pathStem.toLowerCase() === targetLower) return true + + // 4. Match just the filename stem + const fileStem = e.filename.replace(/\.md$/, '') + if (fileStem.toLowerCase() === targetLower.split('/').pop()) return true + + // 5. Slug-to-title match: "grow-newsletter" matches "Grow Newsletter" + if (e.title.toLowerCase() === targetAsWords) return true + + return false + }) + if (found) { handleSelectNote(found) + } else { + console.warn(`Navigation target not found: ${target}`) } }, [entries, handleSelectNote]) @@ -256,10 +395,79 @@ function App() { const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null + // Frontmatter update handlers + const handleUpdateFrontmatter = useCallback(async (path: string, key: string, value: FrontmatterValue) => { + try { + let newContent: string + if (isTauri()) { + // Convert value to the format expected by Rust + let rustValue: unknown = value + if (Array.isArray(value)) { + rustValue = value + } else if (typeof value === 'boolean') { + rustValue = value + } else if (typeof value === 'number') { + rustValue = value + } else if (value === null) { + rustValue = null + } else { + rustValue = String(value) + } + newContent = await invoke('update_frontmatter', { path, key, value: rustValue }) + } else { + // Mock implementation for browser testing + newContent = updateMockFrontmatter(path, key, value) + updateMockContent(path, newContent) + } + + // Update the tab content + setTabs((prev) => prev.map((t) => + t.entry.path === path ? { ...t, content: newContent } : t + )) + + // Update allContent for backlinks + setAllContent((prev) => ({ ...prev, [path]: newContent })) + + setToastMessage('Property updated') + } catch (err) { + console.error('Failed to update frontmatter:', err) + setToastMessage('Failed to update property') + } + }, []) + + const handleDeleteProperty = useCallback(async (path: string, key: string) => { + try { + let newContent: string + if (isTauri()) { + newContent = await invoke('delete_frontmatter_property', { path, key }) + } else { + // Mock implementation + newContent = deleteMockFrontmatterProperty(path, key) + updateMockContent(path, newContent) + } + + setTabs((prev) => prev.map((t) => + t.entry.path === path ? { ...t, content: newContent } : t + )) + + setAllContent((prev) => ({ ...prev, [path]: newContent })) + + setToastMessage('Property deleted') + } catch (err) { + console.error('Failed to delete property:', err) + setToastMessage('Failed to delete property') + } + }, []) + + const handleAddProperty = useCallback(async (path: string, key: string, value: FrontmatterValue) => { + // Adding is the same as updating for new keys + return handleUpdateFrontmatter(path, key, value) + }, [handleUpdateFrontmatter]) + return (
- +
@@ -289,6 +497,9 @@ function App() { allContent={allContent} gitHistory={gitHistory} onNavigate={handleNavigateWikilink} + onUpdateFrontmatter={handleUpdateFrontmatter} + onDeleteProperty={handleDeleteProperty} + onAddProperty={handleAddProperty} />
setToastMessage(null)} /> diff --git a/src/components/Inspector.test.tsx b/src/components/Inspector.test.tsx index 1ffd4761..a185d759 100644 --- a/src/components/Inspector.test.tsx +++ b/src/components/Inspector.test.tsx @@ -51,9 +51,9 @@ const allContent: Record = { 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 }, + { hash: 'a1b2c3d4e5f6a7b8', shortHash: 'a1b2c3d', message: 'Update test with latest changes', author: 'Luca Rossi', date: now - 86400 * 2 }, + { hash: 'e4f5g6h7i8j9k0l1', shortHash: 'e4f5g6h', message: 'Add new section to test', author: 'Luca Rossi', date: now - 86400 * 5 }, + { hash: 'i7j8k9l0m1n2o3p4', shortHash: 'i7j8k9l', message: 'Create test', author: 'Luca Rossi', date: now - 86400 * 12 }, ] const defaultProps = { diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index e4e441dc..76ef6ca1 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -772,7 +772,7 @@ function GitHistoryPanel({ commits }: { commits: GitCommit[] }) { {commits.map((c) => (
- {c.hash} + {c.shortHash} {formatRelativeDate(c.date)}
{c.message}
diff --git a/src/mock-tauri.ts b/src/mock-tauri.ts index e3a85a5e..f5a22e91 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, GitCommit } from './types' +import type { VaultEntry, GitCommit, ModifiedFile } from './types' const MOCK_CONTENT: Record = { '/Users/luca/Laputa/project/26q1-laputa-app.md': `--- @@ -522,30 +522,34 @@ const MOCK_ENTRIES: VaultEntry[] = [ }, ] -function mockGitHistory(path: string): GitCommit[] { +function mockFileHistory(path: string): GitCommit[] { const filename = path.split('/').pop()?.replace('.md', '') ?? 'unknown' const now = Math.floor(Date.now() / 1000) return [ { - hash: 'a1b2c3d', + hash: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0', + shortHash: 'a1b2c3d', message: `Update ${filename} with latest changes`, author: 'Luca Rossi', date: now - 86400 * 2, }, { - hash: 'e4f5g6h', + hash: 'e4f5g6h7i8j9k0l1m2n3o4p5q6r7s8t9u0v1w2x3', + shortHash: 'e4f5g6h', message: `Add new section to ${filename}`, author: 'Luca Rossi', date: now - 86400 * 5, }, { - hash: 'i7j8k9l', + hash: 'i7j8k9l0m1n2o3p4q5r6s7t8u9v0w1x2y3z4a5b6', + shortHash: 'i7j8k9l', message: `Fix formatting in ${filename}`, author: 'Luca Rossi', date: now - 86400 * 12, }, { - hash: 'm0n1o2p', + hash: 'm0n1o2p3q4r5s6t7u8v9w0x1y2z3a4b5c6d7e8f9', + shortHash: 'm0n1o2p', message: `Create ${filename}`, author: 'Luca Rossi', date: now - 86400 * 30, @@ -553,11 +557,63 @@ function mockGitHistory(path: string): GitCommit[] { ] } +function mockModifiedFiles(): ModifiedFile[] { + return [ + { + path: '/Users/luca/Laputa/project/26q1-laputa-app.md', + relativePath: 'project/26q1-laputa-app.md', + status: 'modified', + }, + { + path: '/Users/luca/Laputa/note/facebook-ads-strategy.md', + relativePath: 'note/facebook-ads-strategy.md', + status: 'modified', + }, + { + path: '/Users/luca/Laputa/note/new-draft.md', + relativePath: 'note/new-draft.md', + status: 'untracked', + }, + ] +} + +function mockFileDiff(path: string): string { + const filename = path.split('/').pop() ?? 'unknown' + return `diff --git a/${filename} b/${filename} +index abc1234..def5678 100644 +--- a/${filename} ++++ b/${filename} +@@ -1,8 +1,10 @@ + --- + title: Example Note + is_a: Note ++status: Active + --- + + # Example Note + +-This is the original content. ++This is the updated content. ++ ++A new paragraph has been added.` +} + +let mockHasChanges = true + 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), + get_file_history: (args: { path: string }) => mockFileHistory(args.path), + get_modified_files: () => mockHasChanges ? mockModifiedFiles() : [], + get_file_diff: (args: { path: string }) => mockFileDiff(args.path), + git_commit: (args: { message: string }) => { + mockHasChanges = false + return `[main abc1234] ${args.message}\n 3 files changed` + }, + git_push: () => { + return 'Everything up-to-date' + }, } export function isTauri(): boolean { diff --git a/src/types.ts b/src/types.ts index 9f44d763..f79d2328 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,18 +10,26 @@ export interface VaultEntry { owner: string | null cadence: string | null modifiedAt: number | null + createdAt: number | null fileSize: number } export interface GitCommit { hash: string + shortHash: string message: string author: string date: number // unix timestamp } +export interface ModifiedFile { + path: string + relativePath: string + status: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed' +} + export type SidebarSelection = - | { kind: 'filter'; filter: 'all' | 'people' | 'events' | 'favorites' | 'trash' } + | { kind: 'filter'; filter: 'all' | 'people' | 'events' | 'favorites' | 'trash' | 'changes' } | { kind: 'sectionGroup'; type: string } | { kind: 'entity'; entry: VaultEntry } | { kind: 'topic'; entry: VaultEntry }