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 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-15 12:54:11 +01:00
parent 023a78ba14
commit 9a15755b53
7 changed files with 775 additions and 25 deletions

423
src-tauri/src/git.rs Normal file
View File

@@ -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<Vec<GitCommit>, 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<Vec<ModifiedFile>, 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<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())?;
// 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<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);
// 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<String, String> {
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"));
}
}

View File

@@ -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<Vec<VaultEntry>, String> {
vault::scan_vault(&path)
}
#[tauri::command]
fn get_note_content(path: String) -> Result<String, String> {
vault::get_note_content(&path)
}
#[tauri::command]
fn update_frontmatter(path: String, key: String, value: FrontmatterValue) -> Result<String, String> {
vault::update_frontmatter(&path, &key, value)
}
#[tauri::command]
fn delete_frontmatter_property(path: String, key: String) -> Result<String, String> {
vault::delete_frontmatter_property(&path, &key)
}
#[tauri::command]
fn get_file_history(vault_path: String, path: String) -> Result<Vec<GitCommit>, String> {
git::get_file_history(&vault_path, &path)
}
#[tauri::command]
fn get_modified_files(vault_path: String) -> Result<Vec<ModifiedFile>, String> {
git::get_modified_files(&vault_path)
}
#[tauri::command]
fn get_file_diff(vault_path: String, path: String) -> Result<String, String> {
git::get_file_diff(&vault_path, &path)
}
#[tauri::command]
fn git_commit(vault_path: String, message: String) -> Result<String, String> {
git::git_commit(&vault_path, &message)
}
#[tauri::command]
fn git_push(vault_path: String) -> Result<String, String> {
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");
}

View File

@@ -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<string, string>
}
}
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<GitCommit[]>('get_git_history', { path: activeTabPath })
const vaultPath = TEST_VAULT_PATH.replace('~', '/Users/luca')
history = await invoke<GitCommit[]>('get_file_history', { vaultPath, path: activeTabPath })
} else {
history = await mockInvoke<GitCommit[]>('get_git_history', { path: activeTabPath })
history = await mockInvoke<GitCommit[]>('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<string>('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<string>('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 (
<div className="app">
<div className="app__sidebar" style={{ width: sidebarWidth }}>
<Sidebar entries={entries} selection={selection} onSelect={setSelection} />
<Sidebar entries={entries} selection={selection} onSelect={setSelection} onSelectNote={handleSelectNote} />
</div>
<ResizeHandle onResize={handleSidebarResize} />
<div className="app__note-list" style={{ width: noteListWidth }}>
@@ -289,6 +497,9 @@ function App() {
allContent={allContent}
gitHistory={gitHistory}
onNavigate={handleNavigateWikilink}
onUpdateFrontmatter={handleUpdateFrontmatter}
onDeleteProperty={handleDeleteProperty}
onAddProperty={handleAddProperty}
/>
</div>
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />

View File

@@ -51,9 +51,9 @@ const allContent: Record<string, string> = {
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 = {

View File

@@ -772,7 +772,7 @@ function GitHistoryPanel({ commits }: { commits: GitCommit[] }) {
{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-hash">{c.shortHash}</span>
<span className="inspector__commit-date">{formatRelativeDate(c.date)}</span>
</div>
<div className="inspector__commit-msg">{c.message}</div>

View File

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

View File

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