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 diff --git a/index.html b/index.html index 6605f87..2e1a708 100644 --- a/index.html +++ b/index.html @@ -50,6 +50,8 @@ + + Result 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, } }