From b92d9afa03f1ff39d7f70ecdb9320bc855f0b93b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:19:02 +0800 Subject: [PATCH] feat(scm): group the panel by index and working tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Changes tab ran one `git diff HEAD`, so it could not tell a staged change from an unstaged one and gave every row the letter `M`. The panel now renders `WorkingTreeStatus`, which reports both halves of `XY` separately, in the four sections git itself talks about: Merge Changes, Staged Changes, Changes, Untracked. A row wears the letter of the half its group is about, so a file added to the index and then edited again reads `A` under Staged and `M` under Changes — and clicking it opens the matching patch, `--cached` for a staged row and the working tree for the rest. The group chevron sits in a box exactly as wide as `git_badge`, so the fold arrows and the status letters below them form one column. Two things guard the render loop. The pane's directory is turned into the repository root before anything is cached or run, because porcelain pathspecs are relative to the root and a write from a subdirectory would name the wrong files; and the `ScmData` watcher compares before it notifies, since `scm_refresh` reaches for the global through `default_global` from inside `render` and an unconditional notify would ask for a frame from inside a frame forever. --- src/ui/i18n/en.rs | 15 + src/ui/i18n/ja.rs | 17 + src/ui/i18n/mod.rs | 29 ++ src/ui/i18n/zh.rs | 13 + src/ui/scm/mod.rs | 8 +- src/ui/scm/panel.rs | 886 ++++++++++++++++++++++++++++++++++---------- src/ui/scm/state.rs | 41 +- 7 files changed, 814 insertions(+), 195 deletions(-) diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 7d0a1768..6e1c8200 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -857,6 +857,21 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::ScmResetToCommit => "Reset to Commit", L10nKey::ScmRefresh => "Refresh", L10nKey::ScmBackToChanges => "Back", + L10nKey::ScmTooManyChanges => "Showing the first {shown} of {total} changes.", + L10nKey::ScmOpenChanges => "Open Changes", + L10nKey::ScmDiscardAllConfirm => { + "Discard every change in this repository? This cannot be undone." + } + L10nKey::ScmAmendConfirm => { + "Amend the last commit? It will be replaced by a new one, so anyone who already has it has to reconcile." + } + L10nKey::ScmOpMerge => "merging", + L10nKey::ScmOpRebase => "rebasing", + L10nKey::ScmOpCherryPick => "cherry-picking", + L10nKey::ScmOpRevert => "reverting", + L10nKey::ScmOpBisect => "bisecting", + L10nKey::ScmOpAm => "applying", + L10nKey::ScmSwitchRepository => "Switch Repository", L10nKey::WindowStop => "Stop", L10nKey::WindowDelete => "Delete", L10nKey::WindowThisWorkspace => "this workspace", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index e0c3518e..4a782126 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -907,6 +907,23 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::ScmResetToCommit => "このコミットにリセット", L10nKey::ScmRefresh => "更新", L10nKey::ScmBackToChanges => "戻る", + L10nKey::ScmTooManyChanges => { + "変更が多いため、{total} 件のうち先頭 {shown} 件のみ表示しています。" + } + L10nKey::ScmOpenChanges => "変更を開く", + L10nKey::ScmDiscardAllConfirm => { + "このリポジトリのすべての変更を破棄しますか?元に戻せません。" + } + L10nKey::ScmAmendConfirm => { + "直前のコミットを修正しますか?新しいコミットに置き換わるため、すでに取得した人は対応が必要になります。" + } + L10nKey::ScmOpMerge => "マージ中", + L10nKey::ScmOpRebase => "リベース中", + L10nKey::ScmOpCherryPick => "チェリーピック中", + L10nKey::ScmOpRevert => "リバート中", + L10nKey::ScmOpBisect => "二分探索中", + L10nKey::ScmOpAm => "パッチ適用中", + L10nKey::ScmSwitchRepository => "リポジトリを切り替え", L10nKey::WindowStop => "停止", L10nKey::WindowDelete => "削除", L10nKey::WindowThisWorkspace => "このワークスペース", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 484a737d..81cf061a 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -683,6 +683,24 @@ pub enum L10nKey { ScmResetToCommit, ScmRefresh, ScmBackToChanges, + /// Shown when the working tree has more changes than the status parser + /// keeps. The list is still useful; the count at the top would otherwise + /// be a lie. + ScmTooManyChanges, + ScmOpenChanges, + ScmDiscardAllConfirm, + ScmAmendConfirm, + /// Which sequencer operation is parked in the repository. Deliberately no + /// separate wording for an interactive rebase: modern git writes + /// `rebase-merge/interactive` for every rebase, so the UI would be + /// guessing — and `git status` does not draw the distinction either. + ScmOpMerge, + ScmOpRebase, + ScmOpCherryPick, + ScmOpRevert, + ScmOpBisect, + ScmOpAm, + ScmSwitchRepository, ScmFilesChanged, WindowStop, WindowDelete, @@ -1123,6 +1141,17 @@ const SCM_KEYS_AWAITING_A_CALLER: &[L10nKey] = &[ L10nKey::ScmResetToCommit, L10nKey::ScmRefresh, L10nKey::ScmBackToChanges, + L10nKey::ScmTooManyChanges, + L10nKey::ScmOpenChanges, + L10nKey::ScmDiscardAllConfirm, + L10nKey::ScmAmendConfirm, + L10nKey::ScmOpMerge, + L10nKey::ScmOpRebase, + L10nKey::ScmOpCherryPick, + L10nKey::ScmOpRevert, + L10nKey::ScmOpBisect, + L10nKey::ScmOpAm, + L10nKey::ScmSwitchRepository, L10nKey::ScmFilesChanged, L10nKey::DiffViewSplit, L10nKey::DiffViewUnified, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index ac50df6b..e23182a7 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -830,6 +830,19 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::ScmResetToCommit => "重置到该提交", L10nKey::ScmRefresh => "刷新", L10nKey::ScmBackToChanges => "返回", + L10nKey::ScmTooManyChanges => "改动过多,仅显示前 {shown} 项(共 {total} 项)。", + L10nKey::ScmOpenChanges => "查看改动", + L10nKey::ScmDiscardAllConfirm => "放弃本仓库的全部改动?此操作无法撤销。", + L10nKey::ScmAmendConfirm => { + "修补上一次提交?它会被一个新提交取代,已经拿到旧提交的人需要自行处理。" + } + L10nKey::ScmOpMerge => "合并中", + L10nKey::ScmOpRebase => "变基中", + L10nKey::ScmOpCherryPick => "拣选中", + L10nKey::ScmOpRevert => "还原中", + L10nKey::ScmOpBisect => "二分查找中", + L10nKey::ScmOpAm => "应用补丁中", + L10nKey::ScmSwitchRepository => "切换仓库", L10nKey::WindowStop => "停止", L10nKey::WindowDelete => "删除", L10nKey::WindowThisWorkspace => "此工作区", diff --git a/src/ui/scm/mod.rs b/src/ui/scm/mod.rs index 24c60ed7..fa2944c1 100644 --- a/src/ui/scm/mod.rs +++ b/src/ui/scm/mod.rs @@ -4,12 +4,10 @@ //! `file_tree.rs` use. The directory only keeps the surface from piling into //! `right_panel.rs`. -// The scaffolding lands one step ahead of the code that consumes it: the panel -// still renders the old flat list, so the status helpers, the path helpers and -// most of `ScmPanelState` have no caller yet. Each `allow` comes off as its -// module gets wired up rather than being left as a blanket at the top. +// What is left unused is what the graph and the commit detail view will call: +// `relative_time` has no row to date yet, and `status_rank` is the file tree's +// to use. Both allows come off with the step that wires them up. pub(crate) mod actions; -#[allow(dead_code)] pub(crate) mod panel; #[allow(dead_code)] pub(crate) mod path; diff --git a/src/ui/scm/panel.rs b/src/ui/scm/panel.rs index 801aae5c..2634e228 100644 --- a/src/ui/scm/panel.rs +++ b/src/ui/scm/panel.rs @@ -1,18 +1,64 @@ -//! The source control panel body. +//! The source control panel body: four groups of file rows over the working +//! tree status, in the order git itself talks about them. //! -//! Lifted out of `right_panel.rs` unchanged — this is still the flat -//! `git diff HEAD` list the Changes tab always showed. The groups, the real -//! status letters and the commit box land on top of it in later steps. +//! The panel is rendered from `WorkingTreeStatus`, which knows the difference +//! between the index and the working tree. That is the whole reason the old +//! flat list had to go: it ran one `git diff HEAD`, so it could not tell a +//! staged change from an unstaged one and showed every row the letter `M`. -use gpui::{AnyElement, Context, Window, div, prelude::*, px}; -use gpui_component::{ActiveTheme as _, h_flex, v_flex}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::time::{Duration, Instant}; -use crate::terminal::git_diff::MAX_RENDERED_FILES; +use gpui::{AnyElement, Context, SharedString, Window, div, prelude::*, px}; +use gpui_component::{ActiveTheme as _, Icon, IconName, h_flex, v_flex}; + +use tty7_core::core::git::diff::MAX_RENDERED_FILES; +use tty7_core::core::git::status::{ChangeCode, DecoStatus, StatusEntry, WorkingTreeStatus}; + +use crate::terminal::git_data::status_of; +use crate::terminal::git_diff::DiffSource; use crate::ui::app::{CONTENT_INSET, Tty7App}; -use crate::ui::i18n::{L10nKey, t, t_plural}; +use crate::ui::host_ops::{HostId, SharedHost}; +use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural}; use crate::ui::right_panel::git_badge; +use crate::ui::scm::path::split_display_path; +use crate::ui::scm::state::{RepoKey, ScmGroup}; +use crate::ui::scm::status::{status_color, status_glyph}; + +/// A file row, and the group header above it. Both 24px, so the list reads as +/// one grid rather than as headers with a list hanging off them. +const ROW_H: f32 = 24.; + +/// The status letter's column, from `git_badge`. The group chevron sits in a +/// box of exactly this width so the two line up in one column down the panel. +const BADGE_W: f32 = 14.; + +/// Rows are laid out inside this inset and then pad themselves back out, so a +/// hovered row's background is wider than its text on both sides. +const ROW_INSET: f32 = 4.; + +/// Untracked files past this many start folded. A fresh clone of a repository +/// with a stale `.gitignore` can put thousands of them in front of the three +/// changes the user came to look at. +const UNTRACKED_AUTO_COLLAPSE: usize = 20; + +/// How long to wait before asking git again about a directory that answered +/// with nothing. +/// +/// `scm_refresh` is safe to call every frame — it de-duplicates in-flight +/// probes and skips fresh ones. What it cannot do is notice that a probe came +/// back empty: a repository we never got a status for stays stale forever, so +/// without this the panel would start a new `git status` on every frame. +const PROBE_RETRY: Duration = Duration::from_secs(2); + +/// What the panel knows about the directory the active pane is sitting in. +enum RepoLookup { + /// Nothing has answered yet — the tab's own probe is still out. + Pending, + NotARepo, + Root(PathBuf), +} impl Tty7App { pub(crate) fn render_panel_scm( @@ -20,212 +66,652 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) -> AnyElement { - let sf = cx.global::().sidebar; - let target = self - .tabs - .get(self.active) - .and_then(|t| t.detail_pane(window, cx)) - .and_then(|leaf| { - let v = leaf.read(cx); - let cwd = v - .git_status_cwd() - .map(|p| p.to_path_buf()) - .or_else(|| v.host_cwd())?; - Some((v.host(cx)?, cwd)) - }); + self.scm_watch_status(cx); - let Some((host, cwd)) = target else { + let Some((host, cwd)) = self.scm_pane_target(window, cx) else { let title = self.panel_title(t(L10nKey::PanelScmTitle), None, None, window, cx); - return self.panel_scroll( - self.panel_empty( - t(L10nKey::PanelNoWorkingDirectory), - Some(t(L10nKey::PanelNoWorkingDirectoryHint)), - cx, - ), - title, - ); - }; - let key = (host.id(), cwd.clone()); - if self.right_panel.diff_cwd.as_ref() != Some(&key) { - self.right_panel.diff_cwd = Some(key); - self.right_panel.diff = None; - self.spawn_right_panel_diff(host.clone(), cwd.clone(), cx); - } else if self.right_panel.diff.is_none() && self.right_panel.diff_pending.is_none() { - self.spawn_right_panel_diff(host.clone(), cwd.clone(), cx); - } - - let count = match &self.right_panel.diff { - Some(Some(snap)) => { - let n = snap.files.len() + snap.untracked_count(); - (n > 0).then(|| n.to_string()) - } - _ => None, - }; - let title = self.panel_title(t(L10nKey::PanelScmTitle), count, None, window, cx); - let mono = cx.theme().mono_font_family.clone(); - - let inner = match &self.right_panel.diff { - None => self.panel_empty(t(L10nKey::PanelLoading), None, cx), - Some(None) => self.panel_empty( - t(L10nKey::PanelNotAGitRepo), - Some(t(L10nKey::PanelNotAGitRepoHint)), + let body = self.panel_empty( + t(L10nKey::PanelNoWorkingDirectory), + Some(t(L10nKey::PanelNoWorkingDirectoryHint)), cx, - ), - Some(Some(snap)) if snap.files.is_empty() && snap.untracked.is_empty() => self - .panel_empty( - t(L10nKey::PanelNoChanges), - Some(t(L10nKey::PanelNoChangesHint)), - cx, - ), - Some(Some(snap)) => { - let snap = Arc::clone(snap); - let untracked = snap.untracked_count(); - let focused = self.diff_overlay_focus(host.id(), &cwd).map(str::to_string); - let shown = snap.files.len().min(MAX_RENDERED_FILES); - let mut list = v_flex().px(px(CONTENT_INSET - 4.)).py(px(2.)).gap(px(1.)); - for file in snap.files.iter().take(shown) { - let path = file.path.clone(); - let (added, removed) = (file.added, file.removed); - let selected = focused.as_deref() == Some(path.as_str()); - list = list.child( - h_flex() - .id(gpui::SharedString::from(format!("panel-change-{path}"))) - .items_center() - .gap(px(8.)) - .px(px(4.)) - .py(px(3.)) - .rounded(px(5.)) - .cursor_pointer() - .hover(|s| s.bg(gpui::rgb(sf.hover))) - .when(selected, |s| s.bg(gpui::rgb(sf.selected))) - .on_click({ - let host_id = host.id(); - let cwd = cwd.clone(); - let path = path.clone(); - cx.listener(move |this, _, window, cx| { - this.toggle_diff_overlay_at( - host_id, - cwd.clone(), - Some(path.clone()), - window, - cx, - ); - }) - }) - .child(git_badge("M", cx.theme().muted_foreground, &mono)) - .child( - div() - .flex_1() - .min_w_0() - .truncate() - .text_size(px(12.)) - .font_family(mono.clone()) - .text_color(cx.theme().foreground) - .child(path), - ) - .when(added > 0, |this| { - this.child( - div() - .flex_none() - .text_size(px(11.)) - .font_family(mono.clone()) - .text_color(cx.theme().success) - .child(format!("+{added}")), - ) - }) - .when(removed > 0, |this| { - this.child( - div() - .flex_none() - .text_size(px(11.)) - .font_family(mono.clone()) - .text_color(cx.theme().danger) - .child(format!("−{removed}")), - ) - }), - ); - } - if snap.files.len() > shown { - let rest = snap.files.len() - shown; - list = list.child( - div() - .px(px(4.)) - .py(px(3.)) - .text_size(px(11.5)) - .text_color(cx.theme().muted_foreground) - .child(t_plural(L10nKey::PanelMoreChangedFiles, rest, &[])), - ); - } - if untracked > 0 { - list = list.child( - h_flex() - .items_center() - .gap(px(8.)) - .px(px(4.)) - .py(px(3.)) - .child(git_badge( - "U", - cx.theme().muted_foreground.opacity(0.75), - &mono, - )) - .child( - div() - .text_size(px(11.5)) - .text_color(cx.theme().muted_foreground) - .child(t_plural(L10nKey::PanelUntracked, untracked, &[])), - ), - ); - } - list.into_any_element() + ); + return self.scm_shell(title, body); + }; + + let root = match self.scm_repo_root(&host, &cwd, cx) { + RepoLookup::Pending => { + let title = self.panel_title(t(L10nKey::PanelScmTitle), None, None, window, cx); + let body = self.panel_empty(t(L10nKey::PanelLoading), None, cx); + return self.scm_shell(title, body); } + RepoLookup::NotARepo => { + let title = self.panel_title(t(L10nKey::PanelScmTitle), None, None, window, cx); + let body = self.panel_empty( + t(L10nKey::PanelNotAGitRepo), + Some(t(L10nKey::PanelNotAGitRepoHint)), + cx, + ); + return self.scm_shell(title, body); + } + RepoLookup::Root(root) => root, }; - self.panel_scroll(inner, title) + + self.scm_probe(&host, &root, cx); + let Some(status) = self.scm_seen_status(host.id(), &root, cx) else { + let title = self.panel_title(t(L10nKey::PanelScmTitle), None, None, window, cx); + let body = self.panel_empty(t(L10nKey::PanelLoading), None, cx); + return self.scm_shell(title, body); + }; + + self.scm.repo = Some(RepoKey { + host: host.id(), + root: root.clone(), + }); + + let count = (status.total_entries > 0).then(|| status.total_entries.to_string()); + let title = self.panel_title(t(L10nKey::PanelScmTitle), count, None, window, cx); + + if status.is_clean() { + let body = self.panel_empty( + t(L10nKey::PanelNoChanges), + Some(t(L10nKey::PanelNoChangesHint)), + cx, + ); + return self.scm_shell(title, body); + } + + let body = self.scm_groups(&host, &root, &status, cx); + self.scm_shell(title, body) } - fn spawn_right_panel_diff( - &mut self, - host: crate::ui::host_ops::SharedHost, - cwd: PathBuf, + /// Title over a scrolling body, with the panel's own scroll handle. + /// + /// Not `panel_scroll`: that one owns `right_panel.scroll`, and the rows + /// that land between the title and the list in later steps have to stay + /// pinned while the list moves under them. + fn scm_shell(&self, title: AnyElement, body: AnyElement) -> AnyElement { + let scroller = div() + .id("panel-scm-body") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .track_scroll(&self.scm.scroll) + .child(body); + v_flex() + .flex_1() + .min_h_0() + .child(title) + .child(crate::ui::scrollbar::with_vertical_scrollbar( + "panel-scm-scrollbar", + scroller, + &self.scm.scroll, + )) + .into_any_element() + } + + /// The host and directory the panel is looking at. + fn scm_pane_target( + &self, + window: &mut Window, cx: &mut Context, - ) { - if self.right_panel.diff_pending.is_some() { - return; - } - self.right_panel.diff_pending = Some((host.id(), cwd.clone())); - self.spawn_shared_diff_probe(host, cwd, cx); + ) -> Option<(SharedHost, PathBuf)> { + let leaf = self.tabs.get(self.active)?.detail_pane(window, cx)?; + let view = leaf.read(cx); + let cwd = view + .git_status_cwd() + .map(Path::to_path_buf) + .or_else(|| view.host_cwd())?; + Some((view.host(cx)?, cwd)) } - pub(crate) fn right_panel_refresh_changes(&mut self, cx: &mut Context) { - if self.right_panel.diff_pending.is_some() { + /// Turn the pane's directory into the repository root every write has to + /// run from. + /// + /// Pathspecs out of `status --porcelain=v2` are relative to the root, so + /// running `git add` from a subdirectory would name the wrong files. The + /// root is also the cache key, which is what lets two panes in two + /// subdirectories of one repository share a single status. + /// + /// The cheap repository/not-a-repository answer comes from the cache the + /// tab badge already fills in, so a directory that is not a repository + /// never reaches `git status` from here at all. + fn scm_repo_root( + &mut self, + host: &SharedHost, + cwd: &Path, + cx: &mut Context, + ) -> RepoLookup { + let id = host.id(); + let key = (id, cwd.to_path_buf()); + if let Some(root) = self.scm.roots.get(&key) { + return RepoLookup::Root(root.clone()); + } + match cx + .try_global::() + .and_then(|cache| cache.known_repo_for(id, cwd)) + { + None => RepoLookup::Pending, + Some(None) => RepoLookup::NotARepo, + Some(Some(_)) => { + self.scm_probe(host, cwd, cx); + match status_of(cx, id, cwd) { + Some(status) => { + let root = status.root.clone(); + self.scm.roots.insert(key, root.clone()); + RepoLookup::Root(root) + } + None => RepoLookup::Pending, + } + } + } + } + + /// `scm_refresh` with a floor under how often a fruitless probe repeats. + fn scm_probe(&mut self, host: &SharedHost, root: &Path, cx: &mut Context) { + let key = (host.id(), root.to_path_buf()); + if status_of(cx, host.id(), root).is_none() { + let now = Instant::now(); + match self.scm.probe_attempt.get(&key) { + Some(at) if now.duration_since(*at) < PROBE_RETRY => return, + _ => { + self.scm.probe_attempt.insert(key, now); + } + } + } + self.scm_refresh(host.clone(), root.to_path_buf(), cx); + } + + /// Read the status and record which one this frame drew, so the watcher + /// below can tell a real change from its own noise. + fn scm_seen_status( + &mut self, + host: HostId, + root: &Path, + cx: &mut Context, + ) -> Option> { + let status = status_of(cx, host, root); + self.scm.seen = Some(( + (host, root.to_path_buf()), + status.as_ref().map_or(0, |s| Arc::as_ptr(s) as usize), + )); + status + } + + /// Re-render when a probe lands. + /// + /// The subscription has to compare before it notifies. `scm_refresh` + /// reaches for `ScmData` through `default_global`, which fires the global + /// observers whether or not anything changed — and it is called from + /// `render`. An unconditional `cx.notify()` here would therefore ask for a + /// frame from inside a frame, forever. + fn scm_watch_status(&mut self, cx: &mut Context) { + if self.scm.watch.is_some() { return; } - let Some((id, cwd)) = self.right_panel.diff_cwd.clone() else { + self.scm.watch = Some(cx.observe_global::( + |this, cx| { + let Some((key, seen)) = this.scm.seen.clone() else { + return; + }; + let now = status_of(cx, key.0, &key.1).map_or(0, |s| Arc::as_ptr(&s) as usize); + if now != seen { + this.scm.seen = Some((key, now)); + cx.notify(); + } + }, + )); + } + + fn scm_groups( + &mut self, + host: &SharedHost, + root: &Path, + status: &Arc, + cx: &mut Context, + ) -> AnyElement { + let mut list = v_flex().px(px(CONTENT_INSET - ROW_INSET)).py(px(2.)); + for group in ScmGroup::ORDER { + let entries: Vec<&StatusEntry> = status + .entries + .iter() + .filter(|e| in_group(e, group)) + .collect(); + if entries.is_empty() { + continue; + } + let collapsed = self.scm.group_collapsed(group, entries.len()); + list = list.child(self.scm_group_header(group, entries.len(), collapsed, cx)); + if collapsed { + continue; + } + let shown = entries.len().min(MAX_RENDERED_FILES); + for entry in entries.iter().take(shown) { + list = list.child(self.scm_file_row(host, root, group, entry, cx)); + } + if entries.len() > shown { + list = list.child(self.scm_note( + t_plural(L10nKey::PanelMoreChangedFiles, entries.len() - shown, &[]), + cx, + )); + } + } + if status.truncated { + list = list.child(self.scm_note( + t_fmt( + L10nKey::ScmTooManyChanges, + &[ + ("shown", &status.entries.len().to_string()), + ("total", &status.total_entries.to_string()), + ], + ), + cx, + )); + } + list.into_any_element() + } + + fn scm_note(&self, text: String, cx: &mut Context) -> AnyElement { + div() + .px(px(ROW_INSET)) + .py(px(3.)) + .text_size(px(11.)) + .text_color(cx.theme().muted_foreground.opacity(0.75)) + .child(text) + .into_any_element() + } + + fn scm_group_header( + &self, + group: ScmGroup, + count: usize, + collapsed: bool, + cx: &mut Context, + ) -> AnyElement { + let sf = cx.global::().sidebar; + let mono = cx.theme().mono_font_family.clone(); + h_flex() + .id(SharedString::from(format!("scm-group-{group:?}"))) + .items_center() + .gap(px(8.)) + .h(px(ROW_H)) + .px(px(ROW_INSET)) + .rounded(px(5.)) + .cursor_pointer() + .hover(|s| s.bg(gpui::rgb(sf.hover))) + .on_click(cx.listener(move |this, _, _window, cx| { + this.scm_toggle_group(group, count, cx); + })) + // The chevron's box is exactly the width of `git_badge`, so this + // column and the status letters below it are one straight line. + .child( + div() + .flex_none() + .w(px(BADGE_W)) + .flex() + .justify_center() + .text_color(cx.theme().muted_foreground) + .child( + Icon::new(if collapsed { + IconName::ChevronRight + } else { + IconName::ChevronDown + }) + .size(px(11.)), + ), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(px(10.5)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(cx.theme().muted_foreground) + .child(t(group_label(group)).to_uppercase()), + ) + .child( + div() + .flex_none() + .text_size(px(11.)) + .font_family(mono) + .text_color(cx.theme().muted_foreground.opacity(0.75)) + .child(count.to_string()), + ) + .into_any_element() + } + + fn scm_file_row( + &self, + host: &SharedHost, + root: &Path, + group: ScmGroup, + entry: &StatusEntry, + cx: &mut Context, + ) -> AnyElement { + let sf = cx.global::().sidebar; + let mono = cx.theme().mono_font_family.clone(); + let path = entry.path.as_str().to_string(); + let (name, dir) = split_display_path(&path); + let (letter, deco) = row_status(entry, group); + let selected = self.diff_overlay_focus(host.id(), root) == Some(path.as_str()); + let source = group_diff_source(group); + + h_flex() + .id(SharedString::from(format!("scm-row-{group:?}-{path}"))) + .items_center() + .gap(px(8.)) + .h(px(ROW_H)) + .px(px(ROW_INSET)) + .py(px(3.)) + .rounded(px(5.)) + .cursor_pointer() + .hover(|s| s.bg(gpui::rgb(sf.hover))) + .when(selected, |s| s.bg(gpui::rgb(sf.selected))) + .on_click({ + let host_id = host.id(); + let root = root.to_path_buf(); + let path = path.clone(); + cx.listener(move |this, _, window, cx| { + this.open_diff_overlay( + host_id, + root.clone(), + source.clone(), + Some(path.clone()), + window, + cx, + ); + }) + }) + .child(git_badge(letter, status_color(deco, cx), &mono)) + .child( + div() + .flex_none() + .text_size(px(12.)) + .font_family(mono.clone()) + .text_color(if deco == DecoStatus::Deleted { + cx.theme().muted_foreground + } else { + cx.theme().foreground + }) + .when(deco == DecoStatus::Deleted, |s| s.line_through()) + .child(name.to_string()), + ) + // The directory gives way first: which file it is matters more + // than where it lives, and the name is already the shorter half. + .when(!dir.is_empty(), |this| { + this.child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(px(11.)) + .text_color(cx.theme().muted_foreground.opacity(0.75)) + .child(dir.to_string()), + ) + }) + .into_any_element() + } + + /// What `app.rs`'s `GitStatusCache` observer calls when the cheap + /// per-tab probe lands. + /// + /// That probe runs at every command boundary, which makes it the best + /// signal there is that the working tree moved — far better than a timer. + /// The comparison in front of the bump is what keeps it from turning every + /// notification (including the one the probe's *start* fires) into another + /// `git status`. + pub(crate) fn right_panel_refresh_changes(&mut self, cx: &mut Context) { + let Some(repo) = self.scm.repo.clone() else { return; }; - let Some(host) = crate::ui::host_registry::HostRegistry::get(cx, id) else { - return; - }; - let Some(Some(snap)) = &self.right_panel.diff else { - return; - }; - let Some(status) = cx + let Some(seen) = cx .try_global::() - .and_then(|cache| cache.status_for(id, &cwd)) + .and_then(|cache| cache.status_for(repo.host, &repo.root)) else { return; }; - let stale = status.branch != snap.branch || (status.added, status.removed) != snap.totals(); - if stale { - self.spawn_right_panel_diff(host, cwd, cx); + if self.scm.last_tab_status.as_ref() == Some(&seen) { + return; } + self.scm.last_tab_status = Some(seen); + self.scm_invalidate(&repo, cx); } + + /// Send the next look at a repository back to git. + pub(crate) fn scm_invalidate(&mut self, repo: &RepoKey, cx: &mut Context) { + cx.default_global::() + .bump(repo.host, &repo.root); + cx.notify(); + } + + fn scm_toggle_group(&mut self, group: ScmGroup, count: usize, cx: &mut Context) { + let collapsed = self.scm.group_collapsed(group, count); + self.scm.set_group_collapsed(group, !collapsed); + cx.notify(); + } +} + +/// Which sections an entry shows up in. +/// +/// A file can be staged and unstaged at once (`XY == "MM"`), and then it +/// belongs in both — the same thing VS Code shows, and the honest reading of +/// what a commit right now would contain. +pub(crate) fn in_group(entry: &StatusEntry, group: ScmGroup) -> bool { + match group { + ScmGroup::Merge => entry.is_conflicted(), + ScmGroup::Untracked => entry.is_untracked(), + ScmGroup::Staged => entry.is_staged(), + ScmGroup::Changes => entry.is_unstaged() && !entry.is_untracked(), + } +} + +/// The letter and colour a row wears, decided by the half of `XY` its group +/// is about — a file staged as added and then modified is `A` under Staged +/// and `M` under Changes, which is what `git status` itself says. +pub(crate) fn row_status(entry: &StatusEntry, group: ScmGroup) -> (&'static str, DecoStatus) { + let deco = match group { + ScmGroup::Merge => DecoStatus::Conflict, + ScmGroup::Untracked => DecoStatus::Untracked, + ScmGroup::Staged => code_deco(entry.index), + ScmGroup::Changes => code_deco(entry.worktree), + }; + let letter = match group { + // The two groups whose letter is fixed use the shared glyph table, so + // a conflict is `U` here and in the file tree alike. + ScmGroup::Merge | ScmGroup::Untracked => status_glyph(deco), + ScmGroup::Staged => letter_of(entry.index), + ScmGroup::Changes => letter_of(entry.worktree), + }; + (letter, deco) +} + +/// `ChangeCode::letter` returns a `char`; rows want a `&'static str` so the +/// badge never allocates. `T` and `C` keep their own letters rather than being +/// folded into `M` and `R` — git shows them, and they mean different things. +fn letter_of(code: ChangeCode) -> &'static str { + match code { + ChangeCode::None => " ", + ChangeCode::Modified => "M", + ChangeCode::TypeChanged => "T", + ChangeCode::Added => "A", + ChangeCode::Deleted => "D", + ChangeCode::Renamed => "R", + ChangeCode::Copied => "C", + ChangeCode::Unmerged => "U", + } +} + +fn code_deco(code: ChangeCode) -> DecoStatus { + match code { + ChangeCode::Deleted => DecoStatus::Deleted, + ChangeCode::Added => DecoStatus::Added, + ChangeCode::Renamed | ChangeCode::Copied => DecoStatus::Renamed, + ChangeCode::Unmerged => DecoStatus::Conflict, + _ => DecoStatus::Modified, + } +} + +/// Which patch a row's click opens. +/// +/// Staged rows show `git diff --cached`; everything else shows the working +/// tree. Getting this wrong is not cosmetic — the file name would be right and +/// the hunks underneath it would be someone else's. +pub(crate) fn group_diff_source(group: ScmGroup) -> DiffSource { + match group { + ScmGroup::Staged => DiffSource::Staged, + ScmGroup::Merge | ScmGroup::Changes | ScmGroup::Untracked => DiffSource::Worktree, + } +} + +fn group_label(group: ScmGroup) -> L10nKey { + match group { + ScmGroup::Merge => L10nKey::ScmGroupMerge, + ScmGroup::Staged => L10nKey::ScmGroupStaged, + ScmGroup::Changes => L10nKey::ScmGroupChanges, + ScmGroup::Untracked => L10nKey::ScmGroupUntracked, + } +} + +/// Whether a group nobody has touched starts folded. +pub(crate) fn starts_collapsed(group: ScmGroup, count: usize) -> bool { + group == ScmGroup::Untracked && count > UNTRACKED_AUTO_COLLAPSE } #[cfg(test)] mod tests { + use super::*; use crate::core::config::{CoreConfig, DiffViewMode, RightPanelTab}; use crate::ui::app::test_window::harness; use gpui::TestAppContext; + use tty7_core::core::git::status::{ConflictKind, EntryKind, RepoPath}; + + fn entry(path: &str, index: ChangeCode, worktree: ChangeCode, kind: EntryKind) -> StatusEntry { + StatusEntry { + path: RepoPath::from_bytes(path.as_bytes()), + orig_path: None, + index, + worktree, + kind, + submodule: None, + rename_score: None, + conflict: (kind == EntryKind::Unmerged).then_some(ConflictKind::BothModified), + } + } + + fn groups_of(entry: &StatusEntry) -> Vec { + ScmGroup::ORDER + .into_iter() + .filter(|g| in_group(entry, *g)) + .collect() + } + + #[test] + fn a_file_staged_and_edited_again_lands_in_both_groups() { + let e = entry( + "a.rs", + ChangeCode::Modified, + ChangeCode::Modified, + EntryKind::Tracked, + ); + assert_eq!(groups_of(&e), vec![ScmGroup::Staged, ScmGroup::Changes]); + } + + #[test] + fn each_other_kind_of_entry_lands_in_exactly_one_group() { + let staged = entry( + "a.rs", + ChangeCode::Added, + ChangeCode::None, + EntryKind::Tracked, + ); + assert_eq!(groups_of(&staged), vec![ScmGroup::Staged]); + + let unstaged = entry( + "b.rs", + ChangeCode::None, + ChangeCode::Modified, + EntryKind::Tracked, + ); + assert_eq!(groups_of(&unstaged), vec![ScmGroup::Changes]); + + let untracked = entry( + "c.rs", + ChangeCode::None, + ChangeCode::None, + EntryKind::Untracked, + ); + assert_eq!(groups_of(&untracked), vec![ScmGroup::Untracked]); + + // A conflict is only ever a conflict: it must not also show up under + // Changes, or resolving it would look like two separate jobs. + let conflict = entry( + "d.rs", + ChangeCode::Unmerged, + ChangeCode::Unmerged, + EntryKind::Unmerged, + ); + assert_eq!(groups_of(&conflict), vec![ScmGroup::Merge]); + } + + #[test] + fn a_row_wears_the_letter_of_the_half_its_group_is_about() { + // Added to the index, then edited again in the working tree. + let e = entry( + "a.rs", + ChangeCode::Added, + ChangeCode::Modified, + EntryKind::Tracked, + ); + assert_eq!(row_status(&e, ScmGroup::Staged), ("A", DecoStatus::Added)); + assert_eq!( + row_status(&e, ScmGroup::Changes), + ("M", DecoStatus::Modified) + ); + + let untracked = entry( + "c.rs", + ChangeCode::None, + ChangeCode::None, + EntryKind::Untracked, + ); + assert_eq!( + row_status(&untracked, ScmGroup::Untracked), + ("?", DecoStatus::Untracked) + ); + let conflict = entry( + "d.rs", + ChangeCode::Unmerged, + ChangeCode::Unmerged, + EntryKind::Unmerged, + ); + assert_eq!( + row_status(&conflict, ScmGroup::Merge), + ("U", DecoStatus::Conflict) + ); + } + + #[test] + fn staged_rows_open_the_cached_diff_and_the_rest_the_working_tree() { + assert_eq!(group_diff_source(ScmGroup::Staged), DiffSource::Staged); + for group in [ScmGroup::Merge, ScmGroup::Changes, ScmGroup::Untracked] { + assert_eq!(group_diff_source(group), DiffSource::Worktree); + } + } + + #[test] + fn only_a_long_untracked_list_starts_folded() { + assert!(!starts_collapsed( + ScmGroup::Untracked, + UNTRACKED_AUTO_COLLAPSE + )); + assert!(starts_collapsed( + ScmGroup::Untracked, + UNTRACKED_AUTO_COLLAPSE + 1 + )); + for group in [ScmGroup::Merge, ScmGroup::Staged, ScmGroup::Changes] { + assert!(!starts_collapsed(group, 1_000)); + } + } fn tab_from(json: &str) -> RightPanelTab { serde_json::from_str::(json) @@ -295,4 +781,28 @@ mod tests { DiffViewMode::Unified ); } + + #[gpui::test] + fn a_folded_group_stays_folded_across_rerenders(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (app, mut vcx) = harness(cx); + + app.update(&mut vcx, |app, cx| { + app.set_right_panel_tab(RightPanelTab::Scm, cx); + app.scm_toggle_group(ScmGroup::Staged, 3, cx); + }); + vcx.background_executor.run_until_parked(); + vcx.run_until_parked(); + assert!(app.read_with(&vcx, |app, _| app.scm.group_collapsed(ScmGroup::Staged, 3))); + + // And a long untracked list that the user opened by hand stays open, + // rather than snapping shut again on the count. + app.update(&mut vcx, |app, cx| { + app.scm_toggle_group(ScmGroup::Untracked, 500, cx) + }); + vcx.run_until_parked(); + assert!(!app.read_with(&vcx, |app, _| { + app.scm.group_collapsed(ScmGroup::Untracked, 500) + })); + } } diff --git a/src/ui/scm/state.rs b/src/ui/scm/state.rs index 55b2405b..6b738e1d 100644 --- a/src/ui/scm/state.rs +++ b/src/ui/scm/state.rs @@ -67,9 +67,27 @@ pub(crate) struct ScmPanelState { /// Whether the next commit rewrites HEAD. Armed from the commit dropdown /// rather than a checkbox row — 260px does not have a row to spare. pub(crate) amend: bool, - /// Groups the user folded shut. Absent means open, so a group that has - /// never been touched renders expanded. + /// Groups the user folded shut, and the ones whose fold state they have + /// set at all. Both are needed: a group nobody has touched follows the + /// default for its size (a thousand untracked files start folded), and + /// opening one by hand has to outlast the next file landing in it. pub(crate) collapsed: HashSet, + pub(crate) toggled: HashSet, + /// Working directory → the repository root containing it. Cached because + /// the root is what every write and every cache lookup is keyed by, and + /// only a `git status` can say what it is. + pub(crate) roots: HashMap<(HostId, PathBuf), PathBuf>, + /// When the panel last asked for a status that it did not get back. + pub(crate) probe_attempt: HashMap<(HostId, PathBuf), std::time::Instant>, + /// The status the last frame drew, as (cache key, `Arc` identity). The + /// watcher compares against it so a global write that changed nothing does + /// not ask for another frame. + pub(crate) seen: Option<((HostId, PathBuf), usize)>, + pub(crate) watch: Option, + /// The cheap per-tab git status the panel last reacted to. A change in it + /// means a command touched the repository and the expensive status is due + /// another look. + pub(crate) last_tab_status: Option, pub(crate) graph: GraphState, /// When set, the panel body is replaced by a single commit's detail view /// instead of the working tree. @@ -87,6 +105,25 @@ impl ScmPanelState { pub(crate) fn draft(&self, repo: &RepoKey) -> &str { self.drafts.get(repo).map(String::as_str).unwrap_or("") } + + /// Whether a group renders folded. `count` decides it only for a group the + /// user has never touched. + pub(crate) fn group_collapsed(&self, group: ScmGroup, count: usize) -> bool { + if self.toggled.contains(&group) { + self.collapsed.contains(&group) + } else { + crate::ui::scm::panel::starts_collapsed(group, count) + } + } + + pub(crate) fn set_group_collapsed(&mut self, group: ScmGroup, collapsed: bool) { + self.toggled.insert(group); + if collapsed { + self.collapsed.insert(group); + } else { + self.collapsed.remove(&group); + } + } } #[derive(Default)]