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 }