From 08f4d62fe92761a9f21a9a23f85a955c7a561f03 Mon Sep 17 00:00:00 2001 From: iptoux Date: Sat, 23 May 2026 11:29:09 +0200 Subject: [PATCH 1/3] feat(diff): implement file diff feature with sidebar integration Added a new file diff section to the sidebar, allowing users to view changes in files. Introduced new components for handling file diffs, including CSS for styling. Updated the workspace state to manage the visibility of the file diff section and integrated it with the existing sidebar functionality. Enhanced internationalization support by adding relevant keys for file diff actions and messages in multiple languages. --- index.html | 2 + src-tauri/Cargo.toml | 1 + src-tauri/src/git_status.rs | 524 ++++++++++++++++++ src-tauri/src/lib.rs | 8 + src/config/app.config.rs | 16 +- src/i18n/keys.rs | 18 + src/i18n/locales/de_de.rs | 18 + src/i18n/locales/en_us.rs | 18 + src/i18n/locales/es_es.rs | 18 + src/i18n/locales/fr_fr.rs | 18 + src/i18n/locales/hu_hu.rs | 18 + src/i18n/locales/it_it.rs | 18 + src/i18n/locales/ja_jp.rs | 18 + src/i18n/locales/ko_kr.rs | 18 + src/i18n/locales/pl_pl.rs | 18 + src/i18n/locales/pt_br.rs | 18 + src/i18n/locales/ru_ru.rs | 18 + src/i18n/locales/zh_cn.rs | 18 + src/i18n/locales/zh_tw.rs | 18 + src/tauri_bridge.rs | 162 ++++++ src/workbench/file_diff/file-diff.css | 107 ++++ src/workbench/file_diff/mod.rs | 146 +++++ .../file_diff_section/file-diff-section.css | 160 ++++++ src/workbench/file_diff_section/mod.rs | 407 ++++++++++++++ src/workbench/git_graph/mod.rs | 43 +- src/workbench/mod.rs | 2 + src/workbench/sidebar.rs | 91 ++- src/workbench/sidebar_resizer/mod.rs | 7 +- src/workbench/state.rs | 69 +++ src/workbench/workspace_panel.rs | 14 + 30 files changed, 1994 insertions(+), 17 deletions(-) create mode 100644 src-tauri/src/git_status.rs create mode 100644 src/workbench/file_diff/file-diff.css create mode 100644 src/workbench/file_diff/mod.rs create mode 100644 src/workbench/file_diff_section/file-diff-section.css create mode 100644 src/workbench/file_diff_section/mod.rs diff --git a/index.html b/index.html index 6605f87..2e1a708 100644 --- a/index.html +++ b/index.html @@ -50,6 +50,8 @@ + + Result, String> { + if !git_cli_available() { + return Err(GIT_MISSING_CODE.into()); + } + let trimmed = cwd.trim(); + if trimmed.is_empty() { + return Err("cwd is empty".into()); + } + let start = Path::new(trimmed); + let git_dir = find_git_dir(start).ok_or_else(|| "not a git repository".to_string())?; + let work_tree = git_dir + .parent() + .ok_or_else(|| "invalid git dir".to_string())? + .to_path_buf(); + + let porcelain = run_git(&work_tree, &["status", "--porcelain=v1", "-z"])?; + let mut entries = parse_porcelain(&porcelain); + + let unstaged = run_git(&work_tree, &["diff", "--numstat", "-z"])?; + let staged = run_git(&work_tree, &["diff", "--cached", "--numstat", "-z"])?; + let unstaged_counts = parse_numstat(&unstaged); + let staged_counts = parse_numstat(&staged); + + for entry in &mut entries { + let path = entry.rel_path.clone(); + let mut added = 0u32; + let mut removed = 0u32; + if let Some((a, r)) = unstaged_counts.get(&path) { + added = added.saturating_add(*a); + removed = removed.saturating_add(*r); + } + if let Some((a, r)) = staged_counts.get(&path) { + added = added.saturating_add(*a); + removed = removed.saturating_add(*r); + } + if entry.status == "untracked" && (added == 0 && removed == 0) { + added = count_file_lines(&work_tree.join(&path)).unwrap_or(0); + } + entry.added_lines = added; + entry.removed_lines = removed; + } + + entries.sort_by(|a, b| a.rel_path.cmp(&b.rel_path)); + Ok(entries) +} + +#[tauri::command] +pub fn git_file_diff(cwd: String, rel_path: String, staged: bool) -> Result { + if !git_cli_available() { + return Err(GIT_MISSING_CODE.into()); + } + let trimmed = cwd.trim(); + if trimmed.is_empty() { + return Err("cwd is empty".into()); + } + let rel = rel_path.trim(); + if rel.is_empty() { + return Err("rel_path is empty".into()); + } + let start = Path::new(trimmed); + let git_dir = find_git_dir(start).ok_or_else(|| "not a git repository".to_string())?; + let work_tree = git_dir + .parent() + .ok_or_else(|| "invalid git dir".to_string())? + .to_path_buf(); + + let mut args: Vec<&str> = vec!["diff"]; + if staged { + args.push("--cached"); + } + args.push("--no-color"); + args.push("--"); + args.push(rel); + let output = run_git(&work_tree, &args)?; + if !output.is_empty() { + return Ok(output); + } + let synth = synthesize_untracked_diff(&work_tree, rel); + Ok(synth.unwrap_or_default()) +} + +#[tauri::command] +pub fn git_stage_file(cwd: String, rel_path: String) -> Result<(), String> { + if !git_cli_available() { + return Err(GIT_MISSING_CODE.into()); + } + let work_tree = resolve_work_tree(&cwd)?; + let rel = rel_path.trim(); + if rel.is_empty() { + return Err("rel_path is empty".into()); + } + run_git(&work_tree, &["add", "--", rel]).map(|_| ()) +} + +#[tauri::command] +pub fn git_unstage_file(cwd: String, rel_path: String) -> Result<(), String> { + if !git_cli_available() { + return Err(GIT_MISSING_CODE.into()); + } + let work_tree = resolve_work_tree(&cwd)?; + let rel = rel_path.trim(); + if rel.is_empty() { + return Err("rel_path is empty".into()); + } + run_git(&work_tree, &["restore", "--staged", "--", rel]).map(|_| ()) +} + +fn resolve_work_tree(cwd: &str) -> Result { + let trimmed = cwd.trim(); + if trimmed.is_empty() { + return Err("cwd is empty".into()); + } + let start = Path::new(trimmed); + let git_dir = find_git_dir(start).ok_or_else(|| "not a git repository".to_string())?; + git_dir + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| "invalid git dir".to_string()) +} + +fn run_git(work_tree: &Path, args: &[&str]) -> Result { + let out = Command::new("git") + .arg("-C") + .arg(work_tree) + .args(args) + .output() + .map_err(|e| format!("git {}: {e}", args.first().copied().unwrap_or("?")))?; + if !out.status.success() { + return Err(format!( + "git {}: {}", + args.first().copied().unwrap_or("?"), + String::from_utf8_lossy(&out.stderr) + )); + } + Ok(String::from_utf8_lossy(&out.stdout).to_string()) +} + +/// Parse `git status --porcelain=v1 -z` (NUL-separated, 2-byte XY status, +/// space, path; rename entries carry the source path in the next NUL-record). +fn parse_porcelain(text: &str) -> Vec { + let mut out = Vec::new(); + let bytes = text.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + if bytes.len().saturating_sub(i) < 3 { + break; + } + let x = bytes[i] as char; + let y = bytes[i + 1] as char; + // bytes[i + 2] is a single space separator, then path until NUL. + let mut j = i + 3; + while j < bytes.len() && bytes[j] != 0 { + j += 1; + } + let path = String::from_utf8_lossy(&bytes[i + 3..j]).to_string(); + let mut renamed_consumes_extra = false; + if x == 'R' || y == 'R' { + // Skip the source path of a rename (next NUL-record). + let mut k = j + 1; + while k < bytes.len() && bytes[k] != 0 { + k += 1; + } + j = k; + renamed_consumes_extra = true; + } + let staged = x != ' ' && x != '?'; + let unstaged = y != ' ' && y != '?'; + let status = match (x, y, renamed_consumes_extra) { + ('?', '?', _) => "untracked", + ('U', _, _) | (_, 'U', _) | ('A', 'A', _) | ('D', 'D', _) => "conflicted", + (_, _, true) => "renamed", + ('A', _, _) | (_, 'A', _) => "added", + ('D', _, _) | (_, 'D', _) => "deleted", + _ => "modified", + }; + out.push(ChangedFile { + rel_path: path, + status: status.to_string(), + staged, + unstaged: unstaged || (x == '?' && y == '?'), + added_lines: 0, + removed_lines: 0, + }); + i = j + 1; + } + out +} + +/// Parse `git diff --numstat -z` output. Format per record: +/// `added\tdeleted\toldnamenewname` for renames, otherwise +/// `added\tdeleted\tpath`. Binary files use `-` for the counts. +fn parse_numstat(text: &str) -> HashMap { + let mut out = HashMap::new(); + let bytes = text.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + // Read header up to NUL. + let mut j = i; + while j < bytes.len() && bytes[j] != 0 { + j += 1; + } + let header = String::from_utf8_lossy(&bytes[i..j]).to_string(); + let mut parts = header.splitn(3, '\t'); + let added = parts + .next() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let removed = parts + .next() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let path_field = parts.next().unwrap_or(""); + i = j + 1; + let path = if path_field.is_empty() { + // Rename: read sourcedest; we only care about the new name. + let mut k = i; + while k < bytes.len() && bytes[k] != 0 { + k += 1; + } + i = k + 1; + let mut m = i; + while m < bytes.len() && bytes[m] != 0 { + m += 1; + } + let new_name = String::from_utf8_lossy(&bytes[i..m]).to_string(); + i = m + 1; + new_name + } else { + path_field.to_string() + }; + if !path.is_empty() { + out.insert(path, (added, removed)); + } + } + out +} + +fn count_file_lines(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + if text.is_empty() { + return Some(0); + } + let mut lines = text.lines().count() as u32; + if !text.ends_with('\n') { + lines = lines.saturating_add(1); + } + Some(lines) +} + +/// Build a `+++` only diff for files git does not yet know about so the +/// frontend can render them in the same viewer as tracked changes. +fn synthesize_untracked_diff(work_tree: &Path, rel: &str) -> Option { + let abs = work_tree.join(rel); + let text = std::fs::read_to_string(&abs).ok()?; + let lines: Vec<&str> = text.lines().collect(); + let total = lines.len(); + let mut out = String::new(); + out.push_str(&format!("diff --git a/{rel} b/{rel}\n")); + out.push_str("new file mode 100644\n"); + out.push_str("--- /dev/null\n"); + out.push_str(&format!("+++ b/{rel}\n")); + if total > 0 { + out.push_str(&format!("@@ -0,0 +1,{total} @@\n")); + for l in lines { + out.push('+'); + out.push_str(l); + out.push('\n'); + } + } + Some(out) +} + +// --------------------------------------------------------------------------- +// Filesystem watcher +// --------------------------------------------------------------------------- + +/// Per-watcher record. Holding the `RecommendedWatcher` keeps inotify / +/// FSEvents / ReadDirectoryChangesW handles alive; dropping it stops events. +struct WatchHandle { + /// Owned watcher; kept alive in the `HashMap` until the token is freed. + _watcher: RecommendedWatcher, + /// Set to `false` from `git_status_watch_stop` so the debounce thread + /// exits cleanly on the next tick. + active: Arc, +} + +#[derive(Default)] +pub struct GitWatcherState { + next_token: AtomicU64, + handles: Mutex>, +} + +const DEBOUNCE_WINDOW: Duration = Duration::from_millis(300); + +#[tauri::command] +pub fn git_status_watch_start(app: AppHandle, cwd: String) -> Result { + let work_tree = resolve_work_tree(&cwd)?; + let state = app.state::(); + let token = state.next_token.fetch_add(1, Ordering::Relaxed) + 1; + + let active = Arc::new(AtomicBool::new(true)); + let active_for_thread = active.clone(); + let cwd_for_thread = cwd.clone(); + let app_for_thread = app.clone(); + let work_tree_for_filter = work_tree.clone(); + let last_dirty = Arc::new(Mutex::new(None::)); + let last_dirty_for_thread = last_dirty.clone(); + + let mut watcher = notify::recommended_watcher(move |res: notify::Result| { + let Ok(event) = res else { + return; + }; + if !relevant_event(&event, &work_tree_for_filter) { + return; + } + let mut guard = match last_dirty_for_thread.lock() { + Ok(g) => g, + Err(_) => return, + }; + *guard = Some(Instant::now()); + }) + .map_err(|e| format!("notify init: {e}"))?; + + watcher + .watch(&work_tree, RecursiveMode::Recursive) + .map_err(|e| format!("notify watch root: {e}"))?; + + // Watch the resolved `.git` dir as well — submodule-style worktrees + // place it outside `work_tree`, so the recursive watch above misses it. + if let Some(git_dir) = find_git_dir(Path::new(work_tree.as_path())) { + if !git_dir.starts_with(&work_tree) { + let _ = watcher.watch(&git_dir, RecursiveMode::Recursive); + } + } + + std::thread::spawn(move || { + let mut last_emitted: Option = None; + while active_for_thread.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(120)); + let pending = { + match last_dirty.lock() { + Ok(g) => *g, + Err(_) => break, + } + }; + let Some(stamp) = pending else { continue }; + if stamp.elapsed() < DEBOUNCE_WINDOW { + continue; + } + // Skip if we already emitted for this exact stamp. + if last_emitted == Some(stamp) { + continue; + } + last_emitted = Some(stamp); + let payload = GitStatusDirtyPayload { + cwd: cwd_for_thread.clone(), + }; + let _ = app_for_thread.emit("git_status_dirty", payload); + } + }); + + state + .handles + .lock() + .map_err(|_| "watcher state poisoned".to_string())? + .insert( + token, + WatchHandle { + _watcher: watcher, + active, + }, + ); + Ok(token) +} + +#[tauri::command] +pub fn git_status_watch_stop(app: AppHandle, token: u64) -> Result<(), String> { + let state = app.state::(); + let removed = state + .handles + .lock() + .map_err(|_| "watcher state poisoned".to_string())? + .remove(&token); + if let Some(handle) = removed { + handle.active.store(false, Ordering::Relaxed); + } + Ok(()) +} + +/// Filter out noisy events that don't affect index/working-tree state. +/// `.git/objects/` rewrites every commit, packfile churns even on `git gc`; +/// status output is unchanged so suppressing them keeps the debounce sane. +fn relevant_event(event: ¬ify::Event, work_tree: &Path) -> bool { + match event.kind { + EventKind::Access(AccessKind::Read) | EventKind::Access(AccessKind::Open(_)) => { + return false + } + _ => {} + } + if event.paths.is_empty() { + return true; + } + event + .paths + .iter() + .any(|p| !is_ignored_subpath(p, work_tree)) +} + +fn is_ignored_subpath(path: &Path, work_tree: &Path) -> bool { + let Ok(rel) = path.strip_prefix(work_tree) else { + return false; + }; + let mut comps = rel.components(); + let first = comps.next().and_then(|c| c.as_os_str().to_str()); + let second = comps.next().and_then(|c| c.as_os_str().to_str()); + matches!( + (first, second), + (Some(".git"), Some("objects")) | (Some(".git"), Some("logs")) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_porcelain_basic() { + // " M src/foo.rs\0?? notes.txt\0" + let raw = " M src/foo.rs\0?? notes.txt\0"; + let v = parse_porcelain(raw); + assert_eq!(v.len(), 2); + assert_eq!(v[0].rel_path, "src/foo.rs"); + assert!(v[0].unstaged); + assert!(!v[0].staged); + assert_eq!(v[0].status, "modified"); + assert_eq!(v[1].rel_path, "notes.txt"); + assert_eq!(v[1].status, "untracked"); + assert!(v[1].unstaged); + } + + #[test] + fn parse_porcelain_rename_skips_source() { + // "R new\0old\0 M other\0" + let raw = "R new\0old\0 M other\0"; + let v = parse_porcelain(raw); + assert_eq!(v.len(), 2); + assert_eq!(v[0].rel_path, "new"); + assert_eq!(v[0].status, "renamed"); + assert_eq!(v[1].rel_path, "other"); + assert_eq!(v[1].status, "modified"); + } + + #[test] + fn parse_numstat_simple() { + let raw = "3\t2\tsrc/a.rs\09\t0\tnotes.md\0"; + let m = parse_numstat(raw); + assert_eq!(m.get("src/a.rs"), Some(&(3, 2))); + assert_eq!(m.get("notes.md"), Some(&(9, 0))); + } + + #[test] + fn synthesize_untracked_diff_emits_plus_only_block() { + let tmp = std::env::temp_dir().join(format!("blx_diff_synth_{}", std::process::id())); + let _ = std::fs::create_dir_all(&tmp); + let target = tmp.join("hi.txt"); + std::fs::write(&target, "alpha\nbeta\n").unwrap(); + let diff = synthesize_untracked_diff(&tmp, "hi.txt").unwrap(); + assert!(diff.contains("+++ b/hi.txt")); + assert!(diff.contains("+alpha")); + assert!(diff.contains("@@ -0,0 +1,2 @@")); + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn ignored_subpath_filters_objects_dir() { + let root = Path::new("/tmp/blx-test"); + let p = root.join(".git").join("objects").join("pack").join("x"); + assert!(is_ignored_subpath(&p, root)); + let p = root.join(".git").join("HEAD"); + assert!(!is_ignored_subpath(&p, root)); + let p = root.join("src").join("foo.rs"); + assert!(!is_ignored_subpath(&p, root)); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bac0a99..1719e55 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ mod commands; mod fs_entries; mod git_graph; mod git_info; +mod git_status; mod gitignore; mod image; mod media_keys; @@ -103,6 +104,7 @@ pub fn run() { .manage(AgentEngineState::new()) .manage(BlxUpdaterState::default()) .manage(BrowserHost::default()) + .manage(git_status::GitWatcherState::default()) .manage(PtyManager::default()) .manage(VoiceRecorderState::new()) .manage(WorkbenchSessionsFileLock::default()) @@ -150,6 +152,12 @@ pub fn run() { git_branch, git_graph::git_is_repository, git_graph::git_commit_graph, + git_status::git_status_changes, + git_status::git_file_diff, + git_status::git_stage_file, + git_status::git_unstage_file, + git_status::git_status_watch_start, + git_status::git_status_watch_stop, fs_entries::list_path_entries, fs_entries::read_workspace_text_file, fs_entries::stat_workspace_file, diff --git a/src/config/app.config.rs b/src/config/app.config.rs index 0191e40..e9917bd 100644 --- a/src/config/app.config.rs +++ b/src/config/app.config.rs @@ -72,11 +72,21 @@ pub const SIDEBAR_PANELS_HEIGHT_PCT_MAX: f64 = 75.0; pub const SIDEBAR_EXPLORER_HEIGHT_PCT_KEY: &str = "blxcode_sidebar_explorer_height_pct_v1"; /// Default Project Explorer slot height (percent of the panels block). -pub const SIDEBAR_EXPLORER_HEIGHT_PCT_DEFAULT: f64 = 50.0; +pub const SIDEBAR_EXPLORER_HEIGHT_PCT_DEFAULT: f64 = 40.0; /// Min/max clamp range for the Project Explorer slot height (percent of panels block). -pub const SIDEBAR_EXPLORER_HEIGHT_PCT_MIN: f64 = 15.0; -pub const SIDEBAR_EXPLORER_HEIGHT_PCT_MAX: f64 = 85.0; +pub const SIDEBAR_EXPLORER_HEIGHT_PCT_MIN: f64 = 12.0; +pub const SIDEBAR_EXPLORER_HEIGHT_PCT_MAX: f64 = 76.0; + +/// `localStorage` key for the sidebar File Diff slot height (percent of panels block). +pub const SIDEBAR_DIFF_HEIGHT_PCT_KEY: &str = "blxcode_sidebar_diff_height_pct_v1"; + +/// Default File Diff slot height (percent of the panels block). +pub const SIDEBAR_DIFF_HEIGHT_PCT_DEFAULT: f64 = 30.0; + +/// Min/max clamp range for the File Diff slot height (percent of panels block). +pub const SIDEBAR_DIFF_HEIGHT_PCT_MIN: f64 = 12.0; +pub const SIDEBAR_DIFF_HEIGHT_PCT_MAX: f64 = 76.0; /// `localStorage` key for sidebar width in pixels. pub const SIDEBAR_WIDTH_PX_KEY: &str = "blxcode_sidebar_width_px_v1"; diff --git a/src/i18n/keys.rs b/src/i18n/keys.rs index 2722277..e31cbcc 100644 --- a/src/i18n/keys.rs +++ b/src/i18n/keys.rs @@ -61,6 +61,24 @@ pub enum I18nKey { SbExplorerTauriOnly, SbGraphLoadError, SbGraphGitMissing, + SbDiffTitle, + SbDiffRefresh, + SbDiffResizeAria, + SbDiffLoadError, + SbDiffEmpty, + SbDiffGitMissing, + SbDiffStage, + SbDiffUnstage, + SbDiffStageAriaPrefix, + SbDiffUnstageAriaPrefix, + SbDiffViewerEmpty, + SbDiffViewerLoadError, + SbDiffStatusModified, + SbDiffStatusAdded, + SbDiffStatusDeleted, + SbDiffStatusRenamed, + SbDiffStatusUntracked, + SbDiffStatusConflicted, RpRailAria, RpExpand, diff --git a/src/i18n/locales/de_de.rs b/src/i18n/locales/de_de.rs index 02b4809..2487a03 100644 --- a/src/i18n/locales/de_de.rs +++ b/src/i18n/locales/de_de.rs @@ -60,6 +60,24 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::SbExplorerTauriOnly => "Der Datei-Explorer ist nur in der Desktop-App verfügbar.", I18nKey::SbGraphLoadError => "Commit-Verlauf konnte nicht geladen werden.", I18nKey::SbGraphGitMissing => "Git ist nicht installiert oder nicht im PATH. Installiere git für den Graph.", + I18nKey::SbDiffTitle => "Datei-Diff", + I18nKey::SbDiffRefresh => "Geänderte Dateien neu laden", + I18nKey::SbDiffResizeAria => "Datei-Diff- und Graph-Bereiche skalieren", + I18nKey::SbDiffLoadError => "Working-Tree-Änderungen konnten nicht geladen werden.", + I18nKey::SbDiffEmpty => "Keine Änderungen — Working Tree ist sauber.", + I18nKey::SbDiffGitMissing => "Git ist nicht installiert oder nicht im PATH. Installiere git, um Änderungen zu verfolgen.", + I18nKey::SbDiffStage => "Stagen", + I18nKey::SbDiffUnstage => "Unstagen", + I18nKey::SbDiffStageAriaPrefix => "Stagen", + I18nKey::SbDiffUnstageAriaPrefix => "Unstagen", + I18nKey::SbDiffViewerEmpty => "Kein Diff anzuzeigen.", + I18nKey::SbDiffViewerLoadError => "Diff konnte nicht geladen werden.", + I18nKey::SbDiffStatusModified => "geändert", + I18nKey::SbDiffStatusAdded => "hinzugefügt", + I18nKey::SbDiffStatusDeleted => "gelöscht", + I18nKey::SbDiffStatusRenamed => "umbenannt", + I18nKey::SbDiffStatusUntracked => "untracked", + I18nKey::SbDiffStatusConflicted => "Konflikt", I18nKey::RpRailAria => "Rechtes Panel", I18nKey::RpExpand => "Rechtes Panel einblenden", I18nKey::RpCollapse => "Rechtes Panel ausblenden", diff --git a/src/i18n/locales/en_us.rs b/src/i18n/locales/en_us.rs index edffd09..d9472ee 100644 --- a/src/i18n/locales/en_us.rs +++ b/src/i18n/locales/en_us.rs @@ -66,6 +66,24 @@ Add it to this project's `.gitignore` so it is not committed by mistake?" I18nKey::SbExplorerTauriOnly => "File explorer is only available in the desktop app.", I18nKey::SbGraphLoadError => "Could not load commit history.", I18nKey::SbGraphGitMissing => "Git is not installed or not on PATH. Install git to view the graph.", + I18nKey::SbDiffTitle => "File Diff", + I18nKey::SbDiffRefresh => "Refresh changed files", + I18nKey::SbDiffResizeAria => "Resize file diff and graph panels", + I18nKey::SbDiffLoadError => "Could not load working-tree changes.", + I18nKey::SbDiffEmpty => "No changes — working tree is clean.", + I18nKey::SbDiffGitMissing => "Git is not installed or not on PATH. Install git to track changes.", + I18nKey::SbDiffStage => "Stage", + I18nKey::SbDiffUnstage => "Unstage", + I18nKey::SbDiffStageAriaPrefix => "Stage", + I18nKey::SbDiffUnstageAriaPrefix => "Unstage", + I18nKey::SbDiffViewerEmpty => "No diff to display.", + I18nKey::SbDiffViewerLoadError => "Could not load diff.", + I18nKey::SbDiffStatusModified => "modified", + I18nKey::SbDiffStatusAdded => "added", + I18nKey::SbDiffStatusDeleted => "deleted", + I18nKey::SbDiffStatusRenamed => "renamed", + I18nKey::SbDiffStatusUntracked => "untracked", + I18nKey::SbDiffStatusConflicted => "conflicted", I18nKey::RpRailAria => "Right panel", I18nKey::RpExpand => "Show right panel", diff --git a/src/i18n/locales/es_es.rs b/src/i18n/locales/es_es.rs index ed13951..364716d 100644 --- a/src/i18n/locales/es_es.rs +++ b/src/i18n/locales/es_es.rs @@ -60,6 +60,24 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::SbExplorerTauriOnly => "El explorador de archivos solo está disponible en la aplicación de escritorio.", I18nKey::SbGraphLoadError => "No se pudo cargar el historial de confirmaciones.", I18nKey::SbGraphGitMissing => "Git no está instalado o no está en PATH. Instale git para ver el gráfico.", + I18nKey::SbDiffTitle => "Diff de archivos", + I18nKey::SbDiffRefresh => "Actualizar archivos cambiados", + I18nKey::SbDiffResizeAria => "Redimensionar paneles de diff y grafo", + I18nKey::SbDiffLoadError => "No se pudieron cargar los cambios del árbol de trabajo.", + I18nKey::SbDiffEmpty => "Sin cambios — el árbol de trabajo está limpio.", + I18nKey::SbDiffGitMissing => "Git no está instalado o no está en PATH. Instale git para rastrear cambios.", + I18nKey::SbDiffStage => "Stage", + I18nKey::SbDiffUnstage => "Unstage", + I18nKey::SbDiffStageAriaPrefix => "Stage", + I18nKey::SbDiffUnstageAriaPrefix => "Unstage", + I18nKey::SbDiffViewerEmpty => "No hay diff que mostrar.", + I18nKey::SbDiffViewerLoadError => "No se pudo cargar el diff.", + I18nKey::SbDiffStatusModified => "modificado", + I18nKey::SbDiffStatusAdded => "añadido", + I18nKey::SbDiffStatusDeleted => "eliminado", + I18nKey::SbDiffStatusRenamed => "renombrado", + I18nKey::SbDiffStatusUntracked => "sin seguimiento", + I18nKey::SbDiffStatusConflicted => "en conflicto", I18nKey::RpRailAria => "Panel derecho", I18nKey::RpExpand => "Mostrar panel derecho", I18nKey::RpCollapse => "Ocultar panel derecho", diff --git a/src/i18n/locales/fr_fr.rs b/src/i18n/locales/fr_fr.rs index f5d1b09..3cc3400 100644 --- a/src/i18n/locales/fr_fr.rs +++ b/src/i18n/locales/fr_fr.rs @@ -62,6 +62,24 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::SbExplorerTauriOnly => "L'explorateur de fichiers n'est disponible que dans l'application de bureau.", I18nKey::SbGraphLoadError => "Impossible de charger l'historique des validations.", I18nKey::SbGraphGitMissing => "Git n'est pas installé ou pas sur PATH. Installez git pour afficher le graphique.", + I18nKey::SbDiffTitle => "Diff de fichiers", + I18nKey::SbDiffRefresh => "Actualiser les fichiers modifiés", + I18nKey::SbDiffResizeAria => "Redimensionner les panneaux diff et graphique", + I18nKey::SbDiffLoadError => "Impossible de charger les modifications du working tree.", + I18nKey::SbDiffEmpty => "Aucun changement — working tree propre.", + I18nKey::SbDiffGitMissing => "Git n'est pas installé ou pas sur PATH. Installez git pour suivre les changements.", + I18nKey::SbDiffStage => "Stage", + I18nKey::SbDiffUnstage => "Unstage", + I18nKey::SbDiffStageAriaPrefix => "Stage", + I18nKey::SbDiffUnstageAriaPrefix => "Unstage", + I18nKey::SbDiffViewerEmpty => "Aucun diff à afficher.", + I18nKey::SbDiffViewerLoadError => "Impossible de charger le diff.", + I18nKey::SbDiffStatusModified => "modifié", + I18nKey::SbDiffStatusAdded => "ajouté", + I18nKey::SbDiffStatusDeleted => "supprimé", + I18nKey::SbDiffStatusRenamed => "renommé", + I18nKey::SbDiffStatusUntracked => "non suivi", + I18nKey::SbDiffStatusConflicted => "en conflit", I18nKey::RpRailAria => "Panneau droit", I18nKey::RpExpand => "Afficher le panneau de droite", I18nKey::RpCollapse => "Masquer le panneau droit", diff --git a/src/i18n/locales/hu_hu.rs b/src/i18n/locales/hu_hu.rs index 9a77036..fe9c523 100644 --- a/src/i18n/locales/hu_hu.rs +++ b/src/i18n/locales/hu_hu.rs @@ -60,6 +60,24 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::SbExplorerTauriOnly => "A Fájlkezelő csak az asztali alkalmazásban érhető el.", I18nKey::SbGraphLoadError => "Nem sikerült betölteni a végrehajtási előzményeket.", I18nKey::SbGraphGitMissing => "A Git nincs telepítve, vagy nincs a PATH-on. A grafikon megtekintéséhez telepítse a git-et.", + I18nKey::SbDiffTitle => "Fájl diff", + I18nKey::SbDiffRefresh => "Módosított fájlok frissítése", + I18nKey::SbDiffResizeAria => "Diff és gráf panelek átméretezése", + I18nKey::SbDiffLoadError => "Nem sikerült betölteni a working tree változásait.", + I18nKey::SbDiffEmpty => "Nincs változás — a working tree tiszta.", + I18nKey::SbDiffGitMissing => "A Git nincs telepítve, vagy nincs a PATH-on. Telepítse a git-et a változások követéséhez.", + I18nKey::SbDiffStage => "Stage", + I18nKey::SbDiffUnstage => "Unstage", + I18nKey::SbDiffStageAriaPrefix => "Stage", + I18nKey::SbDiffUnstageAriaPrefix => "Unstage", + I18nKey::SbDiffViewerEmpty => "Nincs megjeleníthető diff.", + I18nKey::SbDiffViewerLoadError => "Nem sikerült betölteni a diffet.", + I18nKey::SbDiffStatusModified => "módosítva", + I18nKey::SbDiffStatusAdded => "hozzáadva", + I18nKey::SbDiffStatusDeleted => "törölve", + I18nKey::SbDiffStatusRenamed => "átnevezve", + I18nKey::SbDiffStatusUntracked => "nem követett", + I18nKey::SbDiffStatusConflicted => "konfliktus", I18nKey::RpRailAria => "Jobb panel", I18nKey::RpExpand => "Jobb oldali panel megjelenítése", I18nKey::RpCollapse => "A jobb oldali panel elrejtése", diff --git a/src/i18n/locales/it_it.rs b/src/i18n/locales/it_it.rs index 67bf939..d01545f 100644 --- a/src/i18n/locales/it_it.rs +++ b/src/i18n/locales/it_it.rs @@ -60,6 +60,24 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::SbExplorerTauriOnly => "Esplora file è disponibile solo nell'app desktop.", I18nKey::SbGraphLoadError => "Impossibile caricare la cronologia dei commit.", I18nKey::SbGraphGitMissing => "Git non è installato o meno su PATH. Installa git per visualizzare il grafico.", + I18nKey::SbDiffTitle => "Diff dei file", + I18nKey::SbDiffRefresh => "Aggiorna file modificati", + I18nKey::SbDiffResizeAria => "Ridimensiona pannelli diff e grafo", + I18nKey::SbDiffLoadError => "Impossibile caricare le modifiche del working tree.", + I18nKey::SbDiffEmpty => "Nessuna modifica — working tree pulito.", + I18nKey::SbDiffGitMissing => "Git non è installato o non è su PATH. Installa git per tracciare le modifiche.", + I18nKey::SbDiffStage => "Stage", + I18nKey::SbDiffUnstage => "Unstage", + I18nKey::SbDiffStageAriaPrefix => "Stage", + I18nKey::SbDiffUnstageAriaPrefix => "Unstage", + I18nKey::SbDiffViewerEmpty => "Nessun diff da mostrare.", + I18nKey::SbDiffViewerLoadError => "Impossibile caricare il diff.", + I18nKey::SbDiffStatusModified => "modificato", + I18nKey::SbDiffStatusAdded => "aggiunto", + I18nKey::SbDiffStatusDeleted => "eliminato", + I18nKey::SbDiffStatusRenamed => "rinominato", + I18nKey::SbDiffStatusUntracked => "non tracciato", + I18nKey::SbDiffStatusConflicted => "in conflitto", I18nKey::RpRailAria => "Pannello destro", I18nKey::RpExpand => "Mostra il pannello di destra", I18nKey::RpCollapse => "Nascondi il pannello destro", diff --git a/src/i18n/locales/ja_jp.rs b/src/i18n/locales/ja_jp.rs index bc08b42..2e49293 100644 --- a/src/i18n/locales/ja_jp.rs +++ b/src/i18n/locales/ja_jp.rs @@ -58,6 +58,24 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::SbExplorerTauriOnly => "ファイル エクスプローラーはデスクトップ アプリでのみ使用できます。", I18nKey::SbGraphLoadError => "コミット履歴を読み込めませんでした。", I18nKey::SbGraphGitMissing => "Git がインストールされていないか、PATH 上にありません。グラフを表示するには git をインストールします。", + I18nKey::SbDiffTitle => "ファイル差分", + I18nKey::SbDiffRefresh => "変更ファイルを再読み込み", + I18nKey::SbDiffResizeAria => "差分とグラフのパネルをリサイズ", + I18nKey::SbDiffLoadError => "ワーキングツリーの変更を読み込めませんでした。", + I18nKey::SbDiffEmpty => "変更なし — ワーキングツリーはクリーンです。", + I18nKey::SbDiffGitMissing => "Git がインストールされていないか、PATH 上にありません。変更を追跡するには git をインストールしてください。", + I18nKey::SbDiffStage => "Stage", + I18nKey::SbDiffUnstage => "Unstage", + I18nKey::SbDiffStageAriaPrefix => "Stage", + I18nKey::SbDiffUnstageAriaPrefix => "Unstage", + I18nKey::SbDiffViewerEmpty => "表示する差分がありません。", + I18nKey::SbDiffViewerLoadError => "差分を読み込めませんでした。", + I18nKey::SbDiffStatusModified => "変更", + I18nKey::SbDiffStatusAdded => "追加", + I18nKey::SbDiffStatusDeleted => "削除", + I18nKey::SbDiffStatusRenamed => "リネーム", + I18nKey::SbDiffStatusUntracked => "未追跡", + I18nKey::SbDiffStatusConflicted => "競合", I18nKey::RpRailAria => "右パネル", I18nKey::RpExpand => "右パネルを表示", I18nKey::RpCollapse => "右パネルを非表示にする", diff --git a/src/i18n/locales/ko_kr.rs b/src/i18n/locales/ko_kr.rs index 975592b..0ec9908 100644 --- a/src/i18n/locales/ko_kr.rs +++ b/src/i18n/locales/ko_kr.rs @@ -58,6 +58,24 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::SbExplorerTauriOnly => "파일 탐색기는 데스크톱 앱에서만 사용할 수 있습니다.", I18nKey::SbGraphLoadError => "커밋 기록을 로드할 수 없습니다.", I18nKey::SbGraphGitMissing => "Git이 설치되지 않았거나 PATH에 없습니다. 그래프를 보려면 git을 설치하세요.", + I18nKey::SbDiffTitle => "파일 Diff", + I18nKey::SbDiffRefresh => "변경된 파일 새로고침", + I18nKey::SbDiffResizeAria => "Diff 및 그래프 패널 크기 조정", + I18nKey::SbDiffLoadError => "워킹 트리 변경 사항을 불러올 수 없습니다.", + I18nKey::SbDiffEmpty => "변경 사항 없음 — 워킹 트리가 깨끗합니다.", + I18nKey::SbDiffGitMissing => "Git이 설치되지 않았거나 PATH에 없습니다. 변경 사항을 추적하려면 git을 설치하세요.", + I18nKey::SbDiffStage => "Stage", + I18nKey::SbDiffUnstage => "Unstage", + I18nKey::SbDiffStageAriaPrefix => "Stage", + I18nKey::SbDiffUnstageAriaPrefix => "Unstage", + I18nKey::SbDiffViewerEmpty => "표시할 Diff가 없습니다.", + I18nKey::SbDiffViewerLoadError => "Diff를 불러올 수 없습니다.", + I18nKey::SbDiffStatusModified => "수정됨", + I18nKey::SbDiffStatusAdded => "추가됨", + I18nKey::SbDiffStatusDeleted => "삭제됨", + I18nKey::SbDiffStatusRenamed => "이름 변경됨", + I18nKey::SbDiffStatusUntracked => "추적 안 됨", + I18nKey::SbDiffStatusConflicted => "충돌", I18nKey::RpRailAria => "오른쪽 패널", I18nKey::RpExpand => "오른쪽 패널 표시", I18nKey::RpCollapse => "오른쪽 패널 숨기기", diff --git a/src/i18n/locales/pl_pl.rs b/src/i18n/locales/pl_pl.rs index fa80359..593983d 100644 --- a/src/i18n/locales/pl_pl.rs +++ b/src/i18n/locales/pl_pl.rs @@ -60,6 +60,24 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::SbExplorerTauriOnly => "Eksplorator plików jest dostępny tylko w aplikacji komputerowej.", I18nKey::SbGraphLoadError => "Nie można wczytać historii zatwierdzeń.", I18nKey::SbGraphGitMissing => "Git nie jest zainstalowany lub nie jest na PATH. Zainstaluj git, aby wyświetlić wykres.", + I18nKey::SbDiffTitle => "Diff plików", + I18nKey::SbDiffRefresh => "Odśwież zmienione pliki", + I18nKey::SbDiffResizeAria => "Zmień rozmiar paneli diff i grafu", + I18nKey::SbDiffLoadError => "Nie udało się wczytać zmian working tree.", + I18nKey::SbDiffEmpty => "Brak zmian — working tree jest czyste.", + I18nKey::SbDiffGitMissing => "Git nie jest zainstalowany lub nie jest na PATH. Zainstaluj git, aby śledzić zmiany.", + I18nKey::SbDiffStage => "Stage", + I18nKey::SbDiffUnstage => "Unstage", + I18nKey::SbDiffStageAriaPrefix => "Stage", + I18nKey::SbDiffUnstageAriaPrefix => "Unstage", + I18nKey::SbDiffViewerEmpty => "Brak diffa do wyświetlenia.", + I18nKey::SbDiffViewerLoadError => "Nie udało się wczytać diffa.", + I18nKey::SbDiffStatusModified => "zmodyfikowane", + I18nKey::SbDiffStatusAdded => "dodane", + I18nKey::SbDiffStatusDeleted => "usunięte", + I18nKey::SbDiffStatusRenamed => "zmieniono nazwę", + I18nKey::SbDiffStatusUntracked => "nieśledzone", + I18nKey::SbDiffStatusConflicted => "konflikt", I18nKey::RpRailAria => "Prawy panel", I18nKey::RpExpand => "Pokaż prawy panel", I18nKey::RpCollapse => "Ukryj prawy panel", diff --git a/src/i18n/locales/pt_br.rs b/src/i18n/locales/pt_br.rs index 2dadc53..20c3356 100644 --- a/src/i18n/locales/pt_br.rs +++ b/src/i18n/locales/pt_br.rs @@ -60,6 +60,24 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::SbExplorerTauriOnly => "O explorador de arquivos está disponível apenas no aplicativo de desktop.", I18nKey::SbGraphLoadError => "Não foi possível carregar o histórico de commits.", I18nKey::SbGraphGitMissing => "O Git não está instalado ou não está no PATH. Instale o git para visualizar o gráfico.", + I18nKey::SbDiffTitle => "Diff de arquivos", + I18nKey::SbDiffRefresh => "Atualizar arquivos alterados", + I18nKey::SbDiffResizeAria => "Redimensionar painéis de diff e grafo", + I18nKey::SbDiffLoadError => "Não foi possível carregar as alterações da working tree.", + I18nKey::SbDiffEmpty => "Sem alterações — working tree limpa.", + I18nKey::SbDiffGitMissing => "O Git não está instalado ou não está no PATH. Instale o git para rastrear alterações.", + I18nKey::SbDiffStage => "Stage", + I18nKey::SbDiffUnstage => "Unstage", + I18nKey::SbDiffStageAriaPrefix => "Stage", + I18nKey::SbDiffUnstageAriaPrefix => "Unstage", + I18nKey::SbDiffViewerEmpty => "Sem diff para exibir.", + I18nKey::SbDiffViewerLoadError => "Não foi possível carregar o diff.", + I18nKey::SbDiffStatusModified => "modificado", + I18nKey::SbDiffStatusAdded => "adicionado", + I18nKey::SbDiffStatusDeleted => "excluído", + I18nKey::SbDiffStatusRenamed => "renomeado", + I18nKey::SbDiffStatusUntracked => "não rastreado", + I18nKey::SbDiffStatusConflicted => "em conflito", I18nKey::RpRailAria => "Painel direito", I18nKey::RpExpand => "Mostrar painel direito", I18nKey::RpCollapse => "Ocultar painel direito", diff --git a/src/i18n/locales/ru_ru.rs b/src/i18n/locales/ru_ru.rs index 1ae17da..d140b01 100644 --- a/src/i18n/locales/ru_ru.rs +++ b/src/i18n/locales/ru_ru.rs @@ -60,6 +60,24 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::SbExplorerTauriOnly => "Проводник доступен только в настольном приложении.", I18nKey::SbGraphLoadError => "Не удалось загрузить историю коммитов.", I18nKey::SbGraphGitMissing => "Git не установлен или отсутствует в PATH. Установите git, чтобы просмотреть график.", + I18nKey::SbDiffTitle => "Diff файлов", + I18nKey::SbDiffRefresh => "Обновить изменённые файлы", + I18nKey::SbDiffResizeAria => "Изменить размер панелей diff и графа", + I18nKey::SbDiffLoadError => "Не удалось загрузить изменения рабочего дерева.", + I18nKey::SbDiffEmpty => "Нет изменений — рабочее дерево чистое.", + I18nKey::SbDiffGitMissing => "Git не установлен или отсутствует в PATH. Установите git для отслеживания изменений.", + I18nKey::SbDiffStage => "Stage", + I18nKey::SbDiffUnstage => "Unstage", + I18nKey::SbDiffStageAriaPrefix => "Stage", + I18nKey::SbDiffUnstageAriaPrefix => "Unstage", + I18nKey::SbDiffViewerEmpty => "Нет diff для отображения.", + I18nKey::SbDiffViewerLoadError => "Не удалось загрузить diff.", + I18nKey::SbDiffStatusModified => "изменён", + I18nKey::SbDiffStatusAdded => "добавлен", + I18nKey::SbDiffStatusDeleted => "удалён", + I18nKey::SbDiffStatusRenamed => "переименован", + I18nKey::SbDiffStatusUntracked => "не отслеживается", + I18nKey::SbDiffStatusConflicted => "конфликт", I18nKey::RpRailAria => "Правая панель", I18nKey::RpExpand => "Показать правую панель", I18nKey::RpCollapse => "Скрыть правую панель", diff --git a/src/i18n/locales/zh_cn.rs b/src/i18n/locales/zh_cn.rs index f5f08b5..5f9a65d 100644 --- a/src/i18n/locales/zh_cn.rs +++ b/src/i18n/locales/zh_cn.rs @@ -58,6 +58,24 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::SbExplorerTauriOnly => "文件资源管理器仅在桌面应用程序中可用。", I18nKey::SbGraphLoadError => "无法加载提交历史记录。", I18nKey::SbGraphGitMissing => "Git 未安装或不在 PATH 中。安装 git 来查看图表。", + I18nKey::SbDiffTitle => "文件 Diff", + I18nKey::SbDiffRefresh => "刷新已修改文件", + I18nKey::SbDiffResizeAria => "调整 Diff 和图表面板大小", + I18nKey::SbDiffLoadError => "无法加载工作树更改。", + I18nKey::SbDiffEmpty => "无更改 — 工作树是干净的。", + I18nKey::SbDiffGitMissing => "Git 未安装或不在 PATH 中。安装 git 以跟踪更改。", + I18nKey::SbDiffStage => "Stage", + I18nKey::SbDiffUnstage => "Unstage", + I18nKey::SbDiffStageAriaPrefix => "Stage", + I18nKey::SbDiffUnstageAriaPrefix => "Unstage", + I18nKey::SbDiffViewerEmpty => "没有可显示的 Diff。", + I18nKey::SbDiffViewerLoadError => "无法加载 Diff。", + I18nKey::SbDiffStatusModified => "已修改", + I18nKey::SbDiffStatusAdded => "已添加", + I18nKey::SbDiffStatusDeleted => "已删除", + I18nKey::SbDiffStatusRenamed => "已重命名", + I18nKey::SbDiffStatusUntracked => "未跟踪", + I18nKey::SbDiffStatusConflicted => "冲突", I18nKey::RpRailAria => "右面板", I18nKey::RpExpand => "显示右侧面板", I18nKey::RpCollapse => "隐藏右侧面板", diff --git a/src/i18n/locales/zh_tw.rs b/src/i18n/locales/zh_tw.rs index 59bd1b7..11217f8 100644 --- a/src/i18n/locales/zh_tw.rs +++ b/src/i18n/locales/zh_tw.rs @@ -58,6 +58,24 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::SbExplorerTauriOnly => "文件資源管理器僅在桌面應用程式中可用。", I18nKey::SbGraphLoadError => "無法載入提交歷史記錄。", I18nKey::SbGraphGitMissing => "Git 未安裝或不在 PATH 中。安裝 git 來查看圖表。", + I18nKey::SbDiffTitle => "檔案 Diff", + I18nKey::SbDiffRefresh => "重新整理已變更檔案", + I18nKey::SbDiffResizeAria => "調整 Diff 和圖表面板大小", + I18nKey::SbDiffLoadError => "無法載入工作樹變更。", + I18nKey::SbDiffEmpty => "沒有變更 — 工作樹是乾淨的。", + I18nKey::SbDiffGitMissing => "Git 未安裝或不在 PATH 中。安裝 git 以追蹤變更。", + I18nKey::SbDiffStage => "Stage", + I18nKey::SbDiffUnstage => "Unstage", + I18nKey::SbDiffStageAriaPrefix => "Stage", + I18nKey::SbDiffUnstageAriaPrefix => "Unstage", + I18nKey::SbDiffViewerEmpty => "沒有可顯示的 Diff。", + I18nKey::SbDiffViewerLoadError => "無法載入 Diff。", + I18nKey::SbDiffStatusModified => "已修改", + I18nKey::SbDiffStatusAdded => "已新增", + I18nKey::SbDiffStatusDeleted => "已刪除", + I18nKey::SbDiffStatusRenamed => "已重新命名", + I18nKey::SbDiffStatusUntracked => "未追蹤", + I18nKey::SbDiffStatusConflicted => "衝突", I18nKey::RpRailAria => "右面板", I18nKey::RpExpand => "顯示右側面板", I18nKey::RpCollapse => "隱藏右側面板", diff --git a/src/tauri_bridge.rs b/src/tauri_bridge.rs index 777b3b2..549c373 100644 --- a/src/tauri_bridge.rs +++ b/src/tauri_bridge.rs @@ -1804,6 +1804,168 @@ pub async fn git_commit_graph(cwd: String, limit: Option) -> Result Result, String> { + #[derive(Serialize)] + struct Args { + cwd: String, + } + invoke_typed("git_status_changes", Args { cwd }).await +} + +pub async fn git_file_diff( + cwd: String, + rel_path: String, + staged: bool, +) -> Result { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Args { + cwd: String, + rel_path: String, + staged: bool, + } + invoke_typed( + "git_file_diff", + Args { + cwd, + rel_path, + staged, + }, + ) + .await +} + +pub async fn git_stage_file(cwd: String, rel_path: String) -> Result<(), String> { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Args { + cwd: String, + rel_path: String, + } + invoke_unit_js("git_stage_file", args_value(Args { cwd, rel_path })?).await +} + +pub async fn git_unstage_file(cwd: String, rel_path: String) -> Result<(), String> { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Args { + cwd: String, + rel_path: String, + } + invoke_unit_js("git_unstage_file", args_value(Args { cwd, rel_path })?).await +} + +pub async fn git_status_watch_start(cwd: String) -> Result { + #[derive(Serialize)] + struct Args { + cwd: String, + } + invoke_typed("git_status_watch_start", Args { cwd }).await +} + +pub async fn git_status_watch_stop(token: u64) -> Result<(), String> { + #[derive(Serialize)] + struct Args { + token: u64, + } + invoke_unit_js("git_status_watch_stop", args_value(Args { token })?).await +} + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitStatusDirtyPayload { + /// The repo cwd that triggered the event. Listeners with multiple + /// active watchers compare this against their own state to decide + /// whether to act; the sidebar today only watches one repo at a time. + #[allow(dead_code)] + pub cwd: String, +} + +/// Subscribes to the backend `git_status_dirty` window event. The returned +/// `TauriEventListener` removes the underlying listener on `Drop`. Returns +/// `None` outside a Tauri shell or when the event API is unavailable. +pub fn listen_git_status_dirty( + callback: impl FnMut(GitStatusDirtyPayload) + 'static, +) -> Option { + listen_tauri_event::("git_status_dirty", callback) +} + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(catch, js_namespace = ["window", "__TAURI__", "event"], js_name = listen)] + async fn tauri_listen_raw(event: &str, callback: &js_sys::Function) -> Result; +} + +/// RAII handle returned by [`listen_tauri_event`]. Drop calls the +/// asynchronous unlisten function returned by `tauri.event.listen`. We +/// store the JS callback inside so it stays alive (forgetting the closure +/// would leak it forever; using `into_js_value` is essentially the same +/// once the unlisten fires, but we keep the binding scoped here). +pub struct TauriEventListener { + unlisten: std::rc::Rc>>, + _callback: send_wrapper::SendWrapper>, +} + +impl Drop for TauriEventListener { + fn drop(&mut self) { + if let Some(unlisten) = self.unlisten.borrow_mut().take() { + let _ = unlisten.call0(&JsValue::NULL); + } + } +} + +fn listen_tauri_event( + event: &'static str, + mut callback: impl FnMut(T) + 'static, +) -> Option +where + T: DeserializeOwned + 'static, +{ + if !is_tauri_shell() { + return None; + } + + let cb = wasm_bindgen::closure::Closure::wrap(Box::new(move |js_event: JsValue| { + let payload = + js_sys::Reflect::get(&js_event, &JsValue::from_str("payload")).unwrap_or(JsValue::NULL); + if payload.is_null() || payload.is_undefined() { + return; + } + if let Ok(parsed) = serde_wasm_bindgen::from_value::(payload) { + callback(parsed); + } + }) as Box); + let cb_function = cb.as_ref().unchecked_ref::().clone(); + let unlisten_slot: std::rc::Rc>> = + std::rc::Rc::new(std::cell::RefCell::new(None)); + let unlisten_slot_for_promise = unlisten_slot.clone(); + let event_name = event.to_string(); + wasm_bindgen_futures::spawn_local(async move { + if let Ok(unlisten) = tauri_listen_raw(&event_name, &cb_function).await { + if let Ok(func) = unlisten.dyn_into::() { + *unlisten_slot_for_promise.borrow_mut() = Some(func); + } + } + }); + + Some(TauriEventListener { + unlisten: unlisten_slot, + _callback: send_wrapper::SendWrapper::new(cb), + }) +} + pub async fn pty_kill(session_id: u64) -> Result<(), String> { #[derive(Serialize)] #[serde(rename_all = "camelCase")] diff --git a/src/workbench/file_diff/file-diff.css b/src/workbench/file_diff/file-diff.css new file mode 100644 index 0000000..b9b6c1c --- /dev/null +++ b/src/workbench/file_diff/file-diff.css @@ -0,0 +1,107 @@ +.file-diff-view { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + background: var(--bg-app); + color: var(--text); +} + +.file-diff-view__head { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.75rem; + background: var(--bg-panel-header); + border-bottom: 1px solid var(--border); + flex: 0 0 auto; +} + +.file-diff-view__path { + flex: 1 1 auto; + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-diff-view__badge { + flex-shrink: 0; + font-size: 0.62rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 0.1rem 0.45rem; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--text-muted); + background: var(--overlay-2); +} + +.file-diff-view__badge--staged { + color: var(--success); + border-color: var(--success); +} + +.file-diff-view__badge--unstaged { + color: var(--accent-cool); + border-color: var(--accent-cool); +} + +.file-diff-view__body { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding: 0; +} + +.file-diff-view__pre { + margin: 0; + padding: 0.5rem 0; + font-family: var(--font-mono); + font-size: 0.78rem; + line-height: 1.45; + white-space: pre; +} + +.file-diff-view__line { + display: block; + padding: 0 0.75rem; +} + +.file-diff-view__line--add { + background: color-mix(in srgb, var(--success) 18%, transparent); + color: var(--success); +} + +.file-diff-view__line--del { + background: color-mix(in srgb, var(--danger) 18%, transparent); + color: var(--danger); +} + +.file-diff-view__line--hunk { + color: var(--text-muted); + font-weight: 600; + border-top: 1px solid var(--border); + padding-top: 0.2rem; + padding-bottom: 0.2rem; + margin-top: 0.25rem; +} + +.file-diff-view__line--header { + color: var(--text-muted); + font-weight: 600; +} + +.file-diff-view__line--ctx { + color: var(--text); +} + +.file-diff-view__empty { + margin: 0; + padding: 0.6rem 0.75rem; + color: var(--text-muted); + font-size: 0.78rem; +} diff --git a/src/workbench/file_diff/mod.rs b/src/workbench/file_diff/mod.rs new file mode 100644 index 0000000..fea123b --- /dev/null +++ b/src/workbench/file_diff/mod.rs @@ -0,0 +1,146 @@ +//! Center-tab diff viewer (inline). Loads `git diff [--cached]` for one +//! file and renders the unified-diff text with line classifiers +//! (`@@`, `+`, `-`). Side-by-side view is intentionally out-of-scope. + +use crate::i18n::I18nKey; +use crate::service::I18nService; +use crate::tauri_bridge::{git_file_diff, GIT_MISSING_CODE}; +use crate::workbench::WorkbenchService; +use leptos::prelude::*; +use leptos::task::spawn_local; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum DiffErrorKind { + GitMissing, + LoadFailed, +} + +#[component] +pub fn FileDiffDock(workspace_id: u64, rel_path: String, staged: bool) -> impl IntoView { + let wb = expect_context::(); + let i18n = expect_context::(); + let diff_text = RwSignal::new(None::); + let error_kind = RwSignal::new(None::); + + let rel_for_load = rel_path.clone(); + Effect::new(move |_| { + let _ = wb.workspaces().get(); + let _ = wb.sidebar_repo_epoch().get(); + let cwd = wb.workspaces().with_untracked(|list| { + list.iter() + .find(|w| w.id == workspace_id) + .map(|w| w.cwd.clone()) + }); + let Some(cwd) = cwd.filter(|c| !c.trim().is_empty()) else { + return; + }; + let rel = rel_for_load.clone(); + spawn_local(async move { + match git_file_diff(cwd, rel, staged).await { + Ok(text) => { + diff_text.set(Some(text)); + error_kind.set(None); + } + Err(e) if e == GIT_MISSING_CODE => { + diff_text.set(None); + error_kind.set(Some(DiffErrorKind::GitMissing)); + } + Err(_) => { + diff_text.set(None); + error_kind.set(Some(DiffErrorKind::LoadFailed)); + } + } + }); + }); + + view! { +
+
+ + {rel_path.clone()} + + + {if staged { "staged" } else { "unstaged" }} + +
+
+ "…"

+ } + .into_any(); + }; + if text.trim().is_empty() { + return view! { +

+ {move || i18n.tr(I18nKey::SbDiffViewerEmpty)()} +

+ } + .into_any(); + } + view! { }.into_any() + } + > +

+ {move || match error_kind.get() { + Some(DiffErrorKind::GitMissing) => i18n.tr(I18nKey::SbDiffGitMissing)(), + _ => i18n.tr(I18nKey::SbDiffViewerLoadError)(), + }} +

+
+
+
+ } +} + +#[component] +fn DiffLines(text: String) -> impl IntoView { + let lines: Vec<(usize, String, &'static str)> = text + .lines() + .enumerate() + .map(|(i, line)| { + let kind = classify_line(line); + (i, line.to_string(), kind) + }) + .collect(); + + view! { +
+            {line}{"\n"}
+                    }
+                }
+            />
+        
+ } +} + +fn classify_line(line: &str) -> &'static str { + if line.starts_with("@@") { + "hunk" + } else if line.starts_with("+++") || line.starts_with("---") || line.starts_with("diff ") { + "header" + } else if line.starts_with('+') { + "add" + } else if line.starts_with('-') { + "del" + } else { + "ctx" + } +} diff --git a/src/workbench/file_diff_section/file-diff-section.css b/src/workbench/file_diff_section/file-diff-section.css new file mode 100644 index 0000000..b320c95 --- /dev/null +++ b/src/workbench/file_diff_section/file-diff-section.css @@ -0,0 +1,160 @@ +.sidebar-view-section--sb-diff { + flex: 0 0 auto; +} + +.sidebar-view-section--sb-diff.sidebar-view-section--open { + flex: 1 1 auto; + min-height: 0; +} + +.file-diff-section { + display: flex; + flex-direction: column; + min-height: 0; +} + +.file-diff-section__list { + list-style: none; + margin: 0; + padding: 0.15rem 0 0.3rem; +} + +.file-diff-section__row { + display: flex; + align-items: center; + min-height: 22px; + padding: 0.05rem 0.15rem 0.05rem 0.25rem; + font-size: 0.74rem; + position: relative; +} + +.file-diff-section__row:hover { + background: var(--overlay-2); +} + +.file-diff-section__row-btn { + display: flex; + align-items: center; + gap: 0.4rem; + flex: 1 1 auto; + min-width: 0; + background: transparent; + border: none; + color: inherit; + font: inherit; + text-align: left; + padding: 0; + cursor: pointer; +} + +.file-diff-section__row-btn:focus-visible { + outline: 1px solid var(--accent); + outline-offset: -1px; +} + +.file-diff-section__status { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + flex-shrink: 0; + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 700; + line-height: 1; +} + +.file-diff-section__status--modified { + color: var(--accent-cool); +} + +.file-diff-section__status--added, +.file-diff-section__status--untracked { + color: var(--success); +} + +.file-diff-section__status--deleted { + color: var(--danger); +} + +.file-diff-section__status--renamed { + color: var(--accent); +} + +.file-diff-section__status--conflicted { + color: var(--danger); +} + +.file-diff-section__path { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text); +} + +.file-diff-section__counts { + display: inline-flex; + align-items: center; + gap: 0.3rem; + flex-shrink: 0; + font-family: var(--font-mono); + font-size: 0.66rem; + margin-left: 0.4rem; +} + +.file-diff-section__count--add { + color: var(--success); +} + +.file-diff-section__count--del { + color: var(--danger); +} + +.file-diff-section__actions { + display: inline-flex; + align-items: center; + gap: 0.1rem; + flex-shrink: 0; + margin-left: 0.25rem; + opacity: 0; + transition: opacity 0.12s ease; +} + +.file-diff-section__row:hover .file-diff-section__actions, +.file-diff-section__row:focus-within .file-diff-section__actions { + opacity: 1; +} + +.file-diff-section__action { + flex-shrink: 0; + width: 1.1rem; + height: 1.1rem; + display: inline-flex; + align-items: center; + justify-content: center; + font: inherit; + font-size: 0.85rem; + line-height: 1; + color: var(--text-muted); + background: transparent; + border: 1px solid transparent; + border-radius: 3px; + cursor: pointer; +} + +.file-diff-section__action:hover, +.file-diff-section__action:focus-visible { + color: var(--text); + background: var(--overlay-3); + border-color: var(--border); +} + +.file-diff-section__action--stage:hover { + color: var(--success); +} + +.file-diff-section__action--unstage:hover { + color: var(--danger); +} diff --git a/src/workbench/file_diff_section/mod.rs b/src/workbench/file_diff_section/mod.rs new file mode 100644 index 0000000..8694128 --- /dev/null +++ b/src/workbench/file_diff_section/mod.rs @@ -0,0 +1,407 @@ +//! Sidebar section listing the changed files in the active workspace's +//! repository (`git status` + `git diff --numstat`). Mirrors the lifecycle +//! of [`crate::workbench::git_graph::GitGraphSection`] but additionally +//! starts a backend filesystem watcher to refresh on every change. + +use crate::i18n::I18nKey; +use crate::service::I18nService; +use crate::tauri_bridge::{ + git_stage_file, git_status_changes, git_status_watch_start, git_status_watch_stop, + git_unstage_file, listen_git_status_dirty, ChangedFile, TauriEventListener, GIT_MISSING_CODE, +}; +use crate::workbench::sidebar_view_section::{SidebarSectionIconBtn, SidebarViewSection}; +use crate::workbench::WorkbenchService; +use gloo_timers::callback::Timeout; +use leptos::prelude::*; +use leptos::task::spawn_local; +use leptos_icons::Icon as LxIcon; +use send_wrapper::SendWrapper; +use std::cell::RefCell; +use std::rc::Rc; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum DiffErrorKind { + GitMissing, + LoadFailed, +} + +#[component] +pub fn FileDiffSection(git_repo_available: ReadSignal>) -> impl IntoView { + let wb = expect_context::(); + let i18n = expect_context::(); + let collapsed = wb.sidebar_collapsed(); + + let diff_open = RwSignal::new(wb.active_sidebar_diff_open()); + let entries = RwSignal::new(None::>); + let error_kind = RwSignal::new(None::); + let load_gen = RwSignal::new(0u32); + + let title = Signal::derive(move || i18n.tr(I18nKey::SbDiffTitle)().to_uppercase()); + + Effect::new(move |_| { + let _ = wb.active_id().get(); + let _ = wb.workspaces().get(); + let stored = wb.active_sidebar_diff_open(); + if diff_open.get_untracked() != stored { + diff_open.set(stored); + } + }); + + Effect::new(move |_| { + let open = diff_open.get(); + if open != wb.active_sidebar_diff_open() { + wb.set_active_sidebar_diff_open(open); + } + }); + + let reload = move || load_gen.update(|g| *g = g.wrapping_add(1)); + + let last_cwd = StoredValue::new(None::); + let last_load_gen = StoredValue::new(0u32); + + Effect::new(move |_| { + let gen = load_gen.get(); + let force_reload = gen != last_load_gen.get_value(); + last_load_gen.set_value(gen); + let _ = wb.sidebar_repo_epoch().get(); + match git_repo_available.get() { + Some(true) => {} + Some(false) => { + entries.set(None); + error_kind.set(None); + last_cwd.set_value(None); + return; + } + None => return, + } + let Some(cwd) = wb.default_workspace_cwd() else { + return; + }; + let cwd_load = cwd.clone(); + let same_cwd = last_cwd.with_value(|prev| prev.as_deref() == Some(cwd.as_str())); + let had_data = entries.get_untracked().is_some(); + if same_cwd && had_data && !force_reload { + return; + } + last_cwd.set_value(Some(cwd)); + if !had_data { + entries.set(None); + error_kind.set(None); + } + spawn_local(async move { + match git_status_changes(cwd_load).await { + Ok(list) => { + entries.set(Some(list)); + error_kind.set(None); + } + Err(e) if e == GIT_MISSING_CODE => { + entries.set(None); + error_kind.set(Some(DiffErrorKind::GitMissing)); + } + Err(_) => { + entries.set(None); + error_kind.set(Some(DiffErrorKind::LoadFailed)); + } + } + }); + }); + + // Watcher lifecycle: keep one token per repo cwd. Drop the previous one + // on cwd change, the event listener on unmount. + let watch_token = StoredValue::new(None::); + let last_watch_cwd = StoredValue::new(None::); + + Effect::new(move |_| { + let _ = wb.sidebar_repo_epoch().get(); + let cwd = match git_repo_available.get() { + Some(true) => wb.default_workspace_cwd(), + _ => None, + }; + let same = last_watch_cwd.with_value(|prev| prev.as_deref() == cwd.as_deref()); + if same { + return; + } + if let Some(token) = watch_token.get_value() { + spawn_local(async move { + let _ = git_status_watch_stop(token).await; + }); + watch_token.set_value(None); + } + last_watch_cwd.set_value(cwd.clone()); + let Some(cwd) = cwd else { + return; + }; + spawn_local(async move { + if let Ok(token) = git_status_watch_start(cwd).await { + watch_token.set_value(Some(token)); + } + }); + }); + + on_cleanup(move || { + if let Some(token) = watch_token.get_value() { + spawn_local(async move { + let _ = git_status_watch_stop(token).await; + }); + watch_token.set_value(None); + } + }); + + // Listener for the dirty event with a 200ms debounce on top of the + // backend's 300ms aggregator. Two-tier debounce so a single fast `git + // checkout` that fires staged + unstaged + index events still runs + // exactly one `git status` reload. + // + // `SendWrapper` is needed because Leptos requires `Send + Sync` cleanup + // closures even though the target is single-threaded WASM. + let pending_timeout: SendWrapper>>> = + SendWrapper::new(Rc::new(RefCell::new(None))); + let pending_for_cleanup = pending_timeout.clone(); + let listener_handle: SendWrapper>>> = + SendWrapper::new(Rc::new(RefCell::new(None))); + let listener_for_cleanup = listener_handle.clone(); + + Effect::new(move |_| { + if listener_handle.borrow().is_some() { + return; + } + let pending = pending_timeout.clone(); + let listener = listen_git_status_dirty(move |_payload| { + if let Some(prev) = pending.borrow_mut().take() { + prev.cancel(); + } + let timeout = Timeout::new(200, move || { + load_gen.update(|g| *g = g.wrapping_add(1)); + }); + *pending.borrow_mut() = Some(timeout); + }); + *listener_handle.borrow_mut() = listener; + }); + + on_cleanup(move || { + if let Some(prev) = pending_for_cleanup.borrow_mut().take() { + prev.cancel(); + } + listener_for_cleanup.borrow_mut().take(); + }); + + let show = move || !collapsed.get() && git_repo_available.get() == Some(true); + + view! { + + + + + }.into_any() + > + + + + } +} + +#[component] +fn FileDiffBody( + entries: RwSignal>>, + error_kind: RwSignal>, + reload: Callback<()>, +) -> impl IntoView { + let i18n = expect_context::(); + + view! { +
+ "…"

+ } + .into_any(); + }; + if list.is_empty() { + return view! { + + } + .into_any(); + } + view! { }.into_any() + } + > + +
+
+ } +} + +#[component] +fn FileDiffList(entries: Vec, reload: Callback<()>) -> impl IntoView { + let wb = expect_context::(); + let i18n = expect_context::(); + + view! { +
    + I18nKey::SbDiffStatusAdded, + "deleted" => I18nKey::SbDiffStatusDeleted, + "renamed" => I18nKey::SbDiffStatusRenamed, + "untracked" => I18nKey::SbDiffStatusUntracked, + "conflicted" => I18nKey::SbDiffStatusConflicted, + _ => I18nKey::SbDiffStatusModified, + }; + let status_marker = status_marker_for(&status_kind); + let added = entry.added_lines; + let removed = entry.removed_lines; + let row_class = format!( + "file-diff-section__row file-diff-section__row--{status_kind}" + ); + let marker_class = format!( + "file-diff-section__status file-diff-section__status--{status_kind}" + ); + let on_open = move |_| { + let workspace_id = wb.active_id().get_untracked(); + let Some(ws_id) = workspace_id else { + return; + }; + wb.open_center_diff_tab(ws_id, rel_for_open.clone(), staged && !unstaged); + }; + let on_stage = { + let rel = rel_for_stage.clone(); + move |ev: web_sys::MouseEvent| { + ev.stop_propagation(); + let Some(cwd) = wb.default_workspace_cwd() else { + return; + }; + let rel = rel.clone(); + let reload = reload; + spawn_local(async move { + let _ = git_stage_file(cwd, rel).await; + reload.run(()); + }); + } + }; + let on_unstage = { + let rel = rel_for_unstage.clone(); + move |ev: web_sys::MouseEvent| { + ev.stop_propagation(); + let Some(cwd) = wb.default_workspace_cwd() else { + return; + }; + let rel = rel.clone(); + let reload = reload; + spawn_local(async move { + let _ = git_unstage_file(cwd, rel).await; + reload.run(()); + }); + } + }; + let stage_aria_label = { + let prefix = i18n.tr(I18nKey::SbDiffStageAriaPrefix)(); + let path = rel.clone(); + move || format!("{prefix} {path}") + }; + let unstage_aria_label = { + let prefix = i18n.tr(I18nKey::SbDiffUnstageAriaPrefix)(); + let path = rel.clone(); + move || format!("{prefix} {path}") + }; + let status_title_fn = i18n.tr(status_label_key); + let status_title = StoredValue::new(status_title_fn()); + view! { +
  • + +
    + + + + + + +
    +
  • + } + } + /> +
+ } +} + +fn status_marker_for(kind: &str) -> &'static str { + match kind { + "modified" => "M", + "added" => "A", + "deleted" => "D", + "renamed" => "R", + "untracked" => "?", + "conflicted" => "C", + _ => "•", + } +} diff --git a/src/workbench/git_graph/mod.rs b/src/workbench/git_graph/mod.rs index 0b75627..fb68ba4 100644 --- a/src/workbench/git_graph/mod.rs +++ b/src/workbench/git_graph/mod.rs @@ -2,12 +2,19 @@ use crate::i18n::I18nKey; use crate::service::I18nService; -use crate::tauri_bridge::{git_commit_graph, GitGraphEntry, GitGraphLayout, GIT_MISSING_CODE}; +use crate::tauri_bridge::{ + git_commit_graph, listen_git_status_dirty, GitGraphEntry, GitGraphLayout, TauriEventListener, + GIT_MISSING_CODE, +}; use crate::workbench::sidebar_view_section::{SidebarSectionIconBtn, SidebarViewSection}; use crate::workbench::WorkbenchService; +use gloo_timers::callback::Timeout; use leptos::prelude::*; use leptos::task::spawn_local; use leptos_icons::Icon as LxIcon; +use send_wrapper::SendWrapper; +use std::cell::RefCell; +use std::rc::Rc; #[component] pub fn GitGraphSection(git_repo_available: ReadSignal>) -> impl IntoView { @@ -90,6 +97,40 @@ pub fn GitGraphSection(git_repo_available: ReadSignal>) -> impl Int }); }); + // Auto-refresh on `git_status_dirty` (shared watcher with the + // FileDiffSection). 400ms debounce keeps a burst of HEAD/index/refs + // changes from triggering more than one `git log`. + let pending_timeout: SendWrapper>>> = + SendWrapper::new(Rc::new(RefCell::new(None))); + let pending_for_cleanup = pending_timeout.clone(); + let listener_handle: SendWrapper>>> = + SendWrapper::new(Rc::new(RefCell::new(None))); + let listener_for_cleanup = listener_handle.clone(); + + Effect::new(move |_| { + if listener_handle.borrow().is_some() { + return; + } + let pending = pending_timeout.clone(); + let listener = listen_git_status_dirty(move |_payload| { + if let Some(prev) = pending.borrow_mut().take() { + prev.cancel(); + } + let timeout = Timeout::new(400, move || { + load_gen.update(|g| *g = g.wrapping_add(1)); + }); + *pending.borrow_mut() = Some(timeout); + }); + *listener_handle.borrow_mut() = listener; + }); + + on_cleanup(move || { + if let Some(prev) = pending_for_cleanup.borrow_mut().take() { + prev.cancel(); + } + listener_for_cleanup.borrow_mut().take(); + }); + let show = move || { !collapsed.get() && git_repo_available.get() == Some(true) }; diff --git a/src/workbench/mod.rs b/src/workbench/mod.rs index 928ae96..4e21805 100644 --- a/src/workbench/mod.rs +++ b/src/workbench/mod.rs @@ -13,6 +13,8 @@ mod browser_tab; mod chat_markdown; mod close_terminals_tab_dialog; mod create_workspace_wizard; +mod file_diff; +mod file_diff_section; mod file_preview; mod git_graph; mod harness_chords; diff --git a/src/workbench/sidebar.rs b/src/workbench/sidebar.rs index 6110b8d..a2ade64 100644 --- a/src/workbench/sidebar.rs +++ b/src/workbench/sidebar.rs @@ -1,13 +1,15 @@ use crate::config::{ - SIDEBAR_EXPLORER_HEIGHT_PCT_DEFAULT, SIDEBAR_EXPLORER_HEIGHT_PCT_KEY, - SIDEBAR_EXPLORER_HEIGHT_PCT_MAX, SIDEBAR_EXPLORER_HEIGHT_PCT_MIN, - SIDEBAR_PANELS_HEIGHT_PCT_DEFAULT, SIDEBAR_PANELS_HEIGHT_PCT_KEY, - SIDEBAR_PANELS_HEIGHT_PCT_MAX, SIDEBAR_PANELS_HEIGHT_PCT_MIN, SIDEBAR_WIDTH_PX_DEFAULT, - SIDEBAR_WIDTH_PX_MIN, + SIDEBAR_DIFF_HEIGHT_PCT_DEFAULT, SIDEBAR_DIFF_HEIGHT_PCT_KEY, SIDEBAR_DIFF_HEIGHT_PCT_MAX, + SIDEBAR_DIFF_HEIGHT_PCT_MIN, SIDEBAR_EXPLORER_HEIGHT_PCT_DEFAULT, + SIDEBAR_EXPLORER_HEIGHT_PCT_KEY, SIDEBAR_EXPLORER_HEIGHT_PCT_MAX, + SIDEBAR_EXPLORER_HEIGHT_PCT_MIN, SIDEBAR_PANELS_HEIGHT_PCT_DEFAULT, + SIDEBAR_PANELS_HEIGHT_PCT_KEY, SIDEBAR_PANELS_HEIGHT_PCT_MAX, SIDEBAR_PANELS_HEIGHT_PCT_MIN, + SIDEBAR_WIDTH_PX_DEFAULT, SIDEBAR_WIDTH_PX_MIN, }; use crate::i18n::I18nKey; use crate::service::I18nService; use crate::tauri_bridge::{git_is_repository, is_tauri_shell}; +use crate::workbench::file_diff_section::FileDiffSection; use crate::workbench::git_graph::GitGraphSection; use crate::workbench::project_explorer::ProjectExplorerSection; use crate::workbench::sidebar_resizer::SidebarResizer; @@ -53,12 +55,16 @@ pub fn Sidebar() -> impl IntoView { let workspaces = wb.workspaces(); let panels_height_pct = RwSignal::new(read_panels_height_pct()); let explorer_height_pct = RwSignal::new(read_explorer_height_pct()); + let diff_height_pct = RwSignal::new(read_diff_height_pct()); Effect::new(move |_| { write_panels_height_pct(panels_height_pct.get()); }); Effect::new(move |_| { write_explorer_height_pct(explorer_height_pct.get()); }); + Effect::new(move |_| { + write_diff_height_pct(diff_height_pct.get()); + }); let context_menu = RwSignal::new(None::); let rename_dialog = RwSignal::new(None::); let rename_input = RwSignal::new(String::new()); @@ -414,7 +420,9 @@ pub fn Sidebar() -> impl IntoView { let _ = wb.active_id().get(); let explorer_open = wb.active_sidebar_explorer_open(); let graph_open = wb.active_sidebar_graph_open(); - if !explorer_open && !graph_open { + let diff_open = wb.active_sidebar_diff_open() + && git_repo_available.get() == Some(true); + if !explorer_open && !graph_open && !diff_open { return "flex: 0 0 auto; min-height: 0;".to_string(); } format!( @@ -431,7 +439,10 @@ pub fn Sidebar() -> impl IntoView { if !wb.active_sidebar_explorer_open() { return "flex: 0 0 auto; min-height: 0;".to_string(); } - if !wb.active_sidebar_graph_open() { + let diff_visible = wb.active_sidebar_diff_open() + && git_repo_available.get() == Some(true); + let graph_visible = wb.active_sidebar_graph_open(); + if !diff_visible && !graph_visible { return "flex: 1 1 auto; min-height: 0;".to_string(); } format!( @@ -443,7 +454,11 @@ pub fn Sidebar() -> impl IntoView { impl IntoView { />
+ +
+ + + +
@@ -716,3 +766,22 @@ fn write_explorer_height_pct(pct: f64) { let _ = storage.set_item(SIDEBAR_EXPLORER_HEIGHT_PCT_KEY, &format!("{pct:.2}")); } } + +fn read_diff_height_pct() -> f64 { + let stored = web_sys::window() + .and_then(|w| w.local_storage().ok().flatten()) + .and_then(|s| s.get_item(SIDEBAR_DIFF_HEIGHT_PCT_KEY).ok().flatten()) + .and_then(|raw| raw.parse::().ok()); + let pct = stored.unwrap_or(SIDEBAR_DIFF_HEIGHT_PCT_DEFAULT); + pct.max(SIDEBAR_DIFF_HEIGHT_PCT_MIN) + .min(SIDEBAR_DIFF_HEIGHT_PCT_MAX) +} + +fn write_diff_height_pct(pct: f64) { + let Some(window) = web_sys::window() else { + return; + }; + if let Ok(Some(storage)) = window.local_storage() { + let _ = storage.set_item(SIDEBAR_DIFF_HEIGHT_PCT_KEY, &format!("{pct:.2}")); + } +} diff --git a/src/workbench/sidebar_resizer/mod.rs b/src/workbench/sidebar_resizer/mod.rs index 3d7576a..c544565 100644 --- a/src/workbench/sidebar_resizer/mod.rs +++ b/src/workbench/sidebar_resizer/mod.rs @@ -4,8 +4,9 @@ //! Dragging adjusts a percent value, clamped to a project-wide range. use crate::config::{ - SIDEBAR_EXPLORER_HEIGHT_PCT_MAX, SIDEBAR_EXPLORER_HEIGHT_PCT_MIN, - SIDEBAR_PANELS_HEIGHT_PCT_MAX, SIDEBAR_PANELS_HEIGHT_PCT_MIN, + SIDEBAR_DIFF_HEIGHT_PCT_MAX, SIDEBAR_DIFF_HEIGHT_PCT_MIN, SIDEBAR_EXPLORER_HEIGHT_PCT_MAX, + SIDEBAR_EXPLORER_HEIGHT_PCT_MIN, SIDEBAR_PANELS_HEIGHT_PCT_MAX, + SIDEBAR_PANELS_HEIGHT_PCT_MIN, }; use crate::i18n::I18nKey; use crate::service::I18nService; @@ -18,6 +19,7 @@ use web_sys::{Element, PointerEvent}; pub enum SidebarResizerClamp { #[default] ExplorerInPanels, + DiffInPanels, PanelsInSidebar, } @@ -27,6 +29,7 @@ impl SidebarResizerClamp { Self::ExplorerInPanels => { (SIDEBAR_EXPLORER_HEIGHT_PCT_MIN, SIDEBAR_EXPLORER_HEIGHT_PCT_MAX) } + Self::DiffInPanels => (SIDEBAR_DIFF_HEIGHT_PCT_MIN, SIDEBAR_DIFF_HEIGHT_PCT_MAX), Self::PanelsInSidebar => { (SIDEBAR_PANELS_HEIGHT_PCT_MIN, SIDEBAR_PANELS_HEIGHT_PCT_MAX) } diff --git a/src/workbench/state.rs b/src/workbench/state.rs index 1b929b3..77dd865 100644 --- a/src/workbench/state.rs +++ b/src/workbench/state.rs @@ -90,6 +90,10 @@ pub struct WorkspaceEntry { /// expands it; the per-workspace state then persists across sessions. #[serde(default = "default_sidebar_graph_open")] pub sidebar_graph_open: bool, + /// Sidebar `File Diff` section expanded. Defaults open so changes are + /// visible the moment the workspace mounts. Persisted across sessions. + #[serde(default = "default_sidebar_section_open")] + pub sidebar_diff_open: bool, /// Relative paths (from `cwd`) expanded in the project explorer tree. #[serde(default)] pub sidebar_explorer_expanded_paths: Vec, @@ -152,6 +156,9 @@ pub enum CenterTabKind { Terminals, Settings, FilePreview { rel_path: String }, + /// Side-by-side or inline diff view for one changed file. + /// `staged` selects between `git diff [--cached]`. + FileDiff { rel_path: String, staged: bool }, } /// Aggregated token / cost stats for a workspace's agent chat. Each @@ -408,6 +415,7 @@ impl WorkspaceEntry { agent_chat_usage: ChatUsageStats::default(), sidebar_explorer_open: true, sidebar_graph_open: false, + sidebar_diff_open: true, sidebar_explorer_expanded_paths: Vec::new(), center_tabs: default_center_tabs(), center_active_tab_id: default_center_active_tab_id(), @@ -1480,6 +1488,15 @@ impl WorkbenchService { self.update_active_workspace(|w| w.sidebar_graph_open = open); } + pub fn active_sidebar_diff_open(&self) -> bool { + self.with_active_workspace(|w| w.sidebar_diff_open) + .unwrap_or(true) + } + + pub fn set_active_sidebar_diff_open(&self, open: bool) { + self.update_active_workspace(|w| w.sidebar_diff_open = open); + } + pub fn active_sidebar_explorer_expanded_paths(&self) -> Vec { self.with_active_workspace(|w| w.sidebar_explorer_expanded_paths.clone()) .unwrap_or_default() @@ -1660,6 +1677,44 @@ impl WorkbenchService { }); } + /// Open (or focus) a `FileDiff` center tab for the given path. Reopening + /// an existing diff tab with a different `staged` flag updates the kind + /// in place rather than spawning a duplicate tab — the row in the + /// sidebar already disambiguates staged vs unstaged. + pub fn open_center_diff_tab(&self, workspace_id: u64, rel_path: String, staged: bool) { + let rel_path = rel_path.trim().trim_start_matches(['/', '\\']).to_string(); + if rel_path.is_empty() { + return; + } + self.workspaces.update(|workspaces| { + let Some(workspace) = workspaces.iter_mut().find(|w| w.id == workspace_id) else { + return; + }; + if let Some(tab) = workspace.center_tabs.iter_mut().find(|tab| { + matches!(&tab.kind, CenterTabKind::FileDiff { rel_path: existing, .. } if existing == &rel_path) + }) { + tab.kind = CenterTabKind::FileDiff { + rel_path: rel_path.clone(), + staged, + }; + tab.title = diff_tab_title(&rel_path); + let active = tab.id; + workspace.center_active_tab_id = active; + repair_center_tab_state(workspace); + return; + } + let id = workspace.center_next_tab_id.max(default_center_next_tab_id()); + workspace.center_next_tab_id = id.saturating_add(1); + workspace.center_tabs.push(CenterTab { + id, + title: diff_tab_title(&rel_path), + kind: CenterTabKind::FileDiff { rel_path, staged }, + }); + workspace.center_active_tab_id = id; + repair_center_tab_state(workspace); + }); + } + /// Create an ephemeral "shell" workspace that hosts only the settings /// tab when no real workspace is open. The shell has empty `cwd`, /// `configuring: false`, no terminal slots, and starts with an empty @@ -1691,6 +1746,7 @@ impl WorkbenchService { agent_chat_usage: ChatUsageStats::default(), sidebar_explorer_open: true, sidebar_graph_open: false, + sidebar_diff_open: true, sidebar_explorer_expanded_paths: Vec::new(), center_tabs: Vec::new(), center_active_tab_id: 0, @@ -1817,6 +1873,7 @@ impl WorkbenchService { agent_chat_usage: ChatUsageStats::default(), sidebar_explorer_open: true, sidebar_graph_open: false, + sidebar_diff_open: true, sidebar_explorer_expanded_paths: Vec::new(), center_tabs: default_center_tabs(), center_active_tab_id: default_center_active_tab_id(), @@ -2432,6 +2489,7 @@ impl WorkbenchService { agent_chat_usage: ChatUsageStats::default(), sidebar_explorer_open: true, sidebar_graph_open: false, + sidebar_diff_open: true, sidebar_explorer_expanded_paths: Vec::new(), center_tabs: default_center_tabs(), center_active_tab_id: default_center_active_tab_id(), @@ -3229,6 +3287,15 @@ fn file_tab_title(rel_path: &str) -> String { .to_string() } +fn diff_tab_title(rel_path: &str) -> String { + let base = rel_path + .rsplit(['/', '\\']) + .next() + .filter(|name| !name.is_empty()) + .unwrap_or(rel_path); + format!("{base} (diff)") +} + /// On-disk schema for the workbench layout. Versioned via /// [`WORKBENCH_SNAPSHOT_VERSION`]. #[derive(Clone, Debug, Serialize, Deserialize)] @@ -3378,6 +3445,7 @@ mod center_tab_tests { agent_chat_usage: ChatUsageStats::default(), sidebar_explorer_open: true, sidebar_graph_open: false, + sidebar_diff_open: true, sidebar_explorer_expanded_paths: Vec::new(), center_tabs: tabs, center_active_tab_id: active, @@ -3471,6 +3539,7 @@ mod terminal_slot_tests { agent_chat_usage: ChatUsageStats::default(), sidebar_explorer_open: true, sidebar_graph_open: false, + sidebar_diff_open: true, sidebar_explorer_expanded_paths: Vec::new(), center_tabs: default_center_tabs(), center_active_tab_id: default_center_active_tab_id(), diff --git a/src/workbench/workspace_panel.rs b/src/workbench/workspace_panel.rs index 67af028..94af2cf 100644 --- a/src/workbench/workspace_panel.rs +++ b/src/workbench/workspace_panel.rs @@ -3,6 +3,7 @@ use crate::service::I18nService; use crate::workbench::app_prefs::AppPrefsService; use crate::workbench::browser_tab::sync_embedded_browser_layer; use crate::workbench::create_workspace_wizard::WorkspaceConfigurator; +use crate::workbench::file_diff::FileDiffDock; use crate::workbench::file_preview::FilePreviewDock; use crate::workbench::harness_chords::{ dispatch_shortcut_action, HarnessShortcutAction, ShortcutKeys, @@ -557,6 +558,18 @@ fn DynamicCenterPanels(workspace_id: u64, active_tab_id: Memo) -> impl Into
}.into_any(), + CenterTabKind::FileDiff { rel_path, staged } => view! { +
+ +
+ }.into_any(), CenterTabKind::Terminals => view! { <> }.into_any(), } } @@ -569,6 +582,7 @@ fn center_tab_icon(kind: &CenterTabKind) -> icondata::Icon { CenterTabKind::Terminals => icondata::LuTerminal, CenterTabKind::Settings => icondata::LuSettings2, CenterTabKind::FilePreview { .. } => icondata::LuFileText, + CenterTabKind::FileDiff { .. } => icondata::LuFileDiff, } } From 167f607bb0260dcc40de1ffd45d4ca0ef7592b62 Mon Sep 17 00:00:00 2001 From: iptoux Date: Sat, 23 May 2026 14:16:49 +0200 Subject: [PATCH 2/3] fix(git_graph): correct argument formatting for git command in fetch_graph_entries function Updated the argument formatting in the fetch_graph_entries function to ensure proper execution of the git command. This change improves the clarity and correctness of the command being constructed, enhancing the functionality of the git graph feature. --- src-tauri/src/git_graph.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/git_graph.rs b/src-tauri/src/git_graph.rs index 33e4cb0..e8cc610 100644 --- a/src-tauri/src/git_graph.rs +++ b/src-tauri/src/git_graph.rs @@ -83,7 +83,8 @@ fn fetch_graph_entries(work_tree: &Path, limit: u32) -> Result Date: Sat, 23 May 2026 14:17:50 +0200 Subject: [PATCH 3/3] docs(changelog): document sidebar file diff section and git log fix Co-authored-by: Cursor --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2548e8f..f505944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Sidebar File Diff section + center diff viewer**: new collapsible **File Diff** panel sits between Project Files and Git Commits in the sidebar. Backend module `src-tauri/src/git_status.rs` exposes `git_status_changes` (`git status --porcelain=v1 -z` + merged `--numstat` for staged/unstaged, untracked line counts via file read), `git_file_diff` (unified diff, synthetic `+++` body for untracked files), `git_stage_file` / `git_unstage_file`, and `git_status_watch_start` / `git_status_watch_stop` — a `notify`-based recursive watcher on the work tree (debounced 300 ms) emits `git_status_dirty` via Tauri so the UI refreshes without manual polling. `ChangedFile` rows show a status marker (`M`/`A`/`D`/`R`/`?`/`C`), path, and `+N`/`-N` counts; hover reveals **stage** (`+`) and **unstage** (`−`) actions. Clicking a row opens (or focuses) a `CenterTabKind::FileDiff { rel_path, staged }` tab with an inline diff viewer (`src/workbench/file_diff/`) that classifies lines (`@@` hunk headers, `+`/`-`/`---`/`+++`). Frontend bridge mirrors all types and adds `listen_git_status_dirty` with an RAII `TauriEventListener`. Per-workspace `sidebar_diff_open` (default **open**) persists expand/collapse; `open_center_diff_tab` deduplicates tabs per path. Sidebar panels block is now a **three-slot** layout (Explorer → Diff → Graph) with two `SidebarResizer` handles and `SIDEBAR_DIFF_HEIGHT_PCT_*` in `localStorage`. 18 new `SbDiff*` i18n keys in all 13 locales. Component CSS in `file_diff_section/file-diff-section.css` and `file_diff/file-diff.css` (theme tokens only). + ### Changed +- **Git Commits auto-refresh**: `GitGraphSection` now listens to the same `git_status_dirty` event (400 ms debounced reload) so the commit graph updates when the index or working tree changes, without a manual refresh. + ### Fixed +- **Git commit graph `git log` invocation**: `fetch_graph_entries` passed `-c log.graphWidth=14` as a single argv token, which Git rejects (`unknown option: -c log.graphWidth=14`) and surfaced in the UI as "Could not load commit history." even though the repository was detected and File Diff worked. Split into `-c` + `log.graphWidth=14` as separate arguments. + ### Removed