mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
Merge pull request #149 from l0ng-ai/fix/stale-sidebar-git-status
fix(git-status): refresh the sidebar counts on window focus and agent tool calls
This commit is contained in:
@@ -631,6 +631,16 @@ pub struct AgentSessionState {
|
||||
/// a stale path; while absent, consumers fall back to the pane's proc cwd.
|
||||
#[serde(default)]
|
||||
pub cwd: Option<std::path::PathBuf>,
|
||||
/// Tool completions seen in this session, counted only so consumers can
|
||||
/// spot *that* the agent did something — a turn's edits land tool by tool,
|
||||
/// and the status alone can't say so (`ToolComplete` is a no-op transition
|
||||
/// during normal work, by design). The sidebar's git probe watches this to
|
||||
/// refresh mid-turn instead of waiting for `stop`; see
|
||||
/// [`TerminalView::refresh_git_status`](crate::terminal::view::TerminalView).
|
||||
/// Monotonic within a session and never reset — consumers compare against
|
||||
/// the value they last saw, so only the *change* means anything.
|
||||
#[serde(default)]
|
||||
pub activity: u64,
|
||||
}
|
||||
|
||||
impl AgentStatus {
|
||||
@@ -698,6 +708,10 @@ impl AgentSessionState {
|
||||
// stream of completions during normal work is a no-op and can
|
||||
// never overwrite Done between turns.
|
||||
AgentEventKind::ToolComplete => {
|
||||
// The count moves even when the status doesn't: a tool call is
|
||||
// the one signal that the working tree may have just changed
|
||||
// under a turn that won't end for minutes.
|
||||
self.activity = self.activity.wrapping_add(1);
|
||||
if self.status == AgentStatus::Waiting {
|
||||
self.status = AgentStatus::Working;
|
||||
self.message = None;
|
||||
@@ -1103,6 +1117,49 @@ mod tests {
|
||||
assert_eq!(s.session_id.as_deref(), Some("sid-1"));
|
||||
}
|
||||
|
||||
/// Tool completions are deliberately a *status* no-op during normal work
|
||||
/// (the assertions above), which leaves consumers watching the status with
|
||||
/// no way to tell that an agent mid-turn just wrote a file. `activity` is
|
||||
/// what makes them observable: it moves on every completion, in every
|
||||
/// status, and never rewinds — the sidebar's git probe compares it against
|
||||
/// the value it last saw.
|
||||
#[test]
|
||||
fn tool_completions_count_even_when_the_status_holds_still() {
|
||||
let ev = |kind| AgentEvent {
|
||||
agent: Some(CLIAgent::Claude),
|
||||
kind,
|
||||
session_id: None,
|
||||
message: None,
|
||||
cwd: None,
|
||||
};
|
||||
|
||||
let mut s = AgentSessionState::default();
|
||||
s.apply_event(&ev(AgentEventKind::PromptSubmit));
|
||||
assert_eq!(s.activity, 0, "a turn starting is not tool activity");
|
||||
|
||||
for n in 1..=3 {
|
||||
s.apply_event(&ev(AgentEventKind::ToolComplete));
|
||||
assert_eq!(s.status, AgentStatus::Working, "the status holds still…");
|
||||
assert_eq!(s.activity, n, "…while the counter is what moves");
|
||||
}
|
||||
|
||||
// A straggler after the turn ended still counts: it may well have
|
||||
// written a file, and it must not be mistaken for "nothing happened".
|
||||
s.apply_event(&ev(AgentEventKind::Stop));
|
||||
s.apply_event(&ev(AgentEventKind::ToolComplete));
|
||||
assert_eq!(
|
||||
s.status,
|
||||
AgentStatus::Done,
|
||||
"and still doesn't resurrect the turn"
|
||||
);
|
||||
assert_eq!(s.activity, 4);
|
||||
|
||||
// Session end resets plenty of state but not this — a rewind to 0 would
|
||||
// read to a delta-comparing consumer as one more tool call.
|
||||
s.apply_event(&ev(AgentEventKind::SessionEnd));
|
||||
assert_eq!(s.activity, 4);
|
||||
}
|
||||
|
||||
/// The agent's cwd claim: any event carrying one sets it, later events
|
||||
/// without one leave it alone (mid-turn events keep the worktree path
|
||||
/// alive), and session end drops it — an exited agent must not pin the
|
||||
|
||||
@@ -3322,6 +3322,7 @@ mod tests {
|
||||
launch_argv: None,
|
||||
rich: true,
|
||||
cwd: None,
|
||||
activity: 0,
|
||||
});
|
||||
apply_signals(&mut st, sniffer.feed(b"\x1b]9;noise\x07"));
|
||||
assert_eq!(
|
||||
|
||||
@@ -1581,6 +1581,7 @@ mod tests {
|
||||
]),
|
||||
rich: true,
|
||||
cwd: Some("/repo/.claude/worktrees/fix-x".into()),
|
||||
activity: 12,
|
||||
})),
|
||||
DaemonMsg::AgentStatus(None),
|
||||
DaemonMsg::LoopbackForward(LoopbackForward { local_port: 49152 }),
|
||||
|
||||
+192
-25
@@ -20,6 +20,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// A repo's git snapshot: the branch it's on and how much the working tree has
|
||||
/// changed against `HEAD`. `added`/`removed` sum the per-file line counts from
|
||||
@@ -62,39 +63,46 @@ pub fn probe(cwd: &Path) -> Option<RepoSnapshot> {
|
||||
if !cwd.exists() {
|
||||
return None;
|
||||
}
|
||||
// Doubles as the "is this a git repo" gate: fails outside a work tree.
|
||||
let root = git(cwd, &["rev-parse", "--show-toplevel"])?;
|
||||
let root = PathBuf::from(root.trim_end_matches(['\n', '\r']));
|
||||
// One `rev-parse` answers every path question at once: the work-tree root
|
||||
// (which doubles as the "is this a git repo" gate — it fails outside a
|
||||
// work tree) plus the git-dir/common-dir pair that tells a linked worktree
|
||||
// from a main checkout. Asking separately cost two process spawns per
|
||||
// probe, which mattered once probes stopped being rare: they now also fire
|
||||
// on window activation and on an agent's tool calls, across every pane.
|
||||
let paths = git(
|
||||
cwd,
|
||||
&[
|
||||
"rev-parse",
|
||||
"--path-format=absolute",
|
||||
"--show-toplevel",
|
||||
"--git-dir",
|
||||
"--git-common-dir",
|
||||
],
|
||||
)?;
|
||||
let mut lines = paths.lines().map(|l| l.trim_end_matches(['\n', '\r']));
|
||||
let root = PathBuf::from(lines.next()?);
|
||||
// A git old enough to reject `--path-format` fails the whole invocation
|
||||
// above, so reaching here means the two dirs are present — but degrade to
|
||||
// "main checkout" rather than trusting that, same as the old code did.
|
||||
let home = repo_home(&root, lines.next(), lines.next());
|
||||
let branch = branch_name(cwd)?;
|
||||
Some(RepoSnapshot {
|
||||
home: repo_home(cwd, &root),
|
||||
home,
|
||||
root,
|
||||
branch,
|
||||
counts: diff_numstat(cwd),
|
||||
})
|
||||
}
|
||||
|
||||
/// The repository "home" every checkout of one repo shares: for a linked
|
||||
/// worktree (its git dir differs from the common git dir) the main work
|
||||
/// tree's root — the parent of `<main>/.git`; for the main checkout itself,
|
||||
/// a submodule, or any failure to tell, the work-tree root unchanged. A bare
|
||||
/// common dir (no trailing `.git` component, the bare-repo-plus-worktrees
|
||||
/// layout) anchors on the bare directory itself — still one shared key.
|
||||
fn repo_home(cwd: &Path, root: &Path) -> PathBuf {
|
||||
let both = git(
|
||||
cwd,
|
||||
&[
|
||||
"rev-parse",
|
||||
"--path-format=absolute",
|
||||
"--git-dir",
|
||||
"--git-common-dir",
|
||||
],
|
||||
);
|
||||
let Some(both) = both else {
|
||||
return root.to_path_buf();
|
||||
};
|
||||
let mut lines = both.lines();
|
||||
let (Some(git_dir), Some(common)) = (lines.next(), lines.next()) else {
|
||||
/// The repository "home" every checkout of one repo shares, from the work-tree
|
||||
/// `root` and the `--git-dir` / `--git-common-dir` pair: for a linked worktree
|
||||
/// (its git dir differs from the common git dir) the main work tree's root —
|
||||
/// the parent of `<main>/.git`; for the main checkout itself, a submodule, or
|
||||
/// any failure to tell, the work-tree root unchanged. A bare common dir (no
|
||||
/// trailing `.git` component, the bare-repo-plus-worktrees layout) anchors on
|
||||
/// the bare directory itself — still one shared key.
|
||||
fn repo_home(root: &Path, git_dir: Option<&str>, common_dir: Option<&str>) -> PathBuf {
|
||||
let (Some(git_dir), Some(common)) = (git_dir, common_dir) else {
|
||||
return root.to_path_buf();
|
||||
};
|
||||
if git_dir == common {
|
||||
@@ -133,6 +141,9 @@ pub struct GitStatusCache {
|
||||
/// In-flight cwds re-triggered meanwhile — reprobed once their flight
|
||||
/// lands, so the newest trigger's state is never skipped.
|
||||
dirty: HashSet<PathBuf>,
|
||||
/// When each cwd's last probe *landed*, for the throttle that opportunistic
|
||||
/// triggers go through ([`begin_probe_throttled`](Self::begin_probe_throttled)).
|
||||
last_probe: HashMap<PathBuf, Instant>,
|
||||
}
|
||||
|
||||
impl gpui::Global for GitStatusCache {}
|
||||
@@ -177,12 +188,75 @@ impl GitStatusCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Claim an *opportunistic* probe for `cwd`: one triggered by a cheap,
|
||||
/// frequent signal — the window regaining focus, an agent finishing a tool
|
||||
/// call — rather than by a rare edge like a command ending.
|
||||
///
|
||||
/// Unlike [`begin_probe`](Self::begin_probe) this declines instead of
|
||||
/// queueing: a probe already in flight, or one against a repo probed less
|
||||
/// than `min_interval` ago, drops the trigger entirely (no dirty mark, no
|
||||
/// rerun). That's the whole point of the two entry points — the rare edges
|
||||
/// must never be missed, while these signals repeat on their own, so a
|
||||
/// count that's a second stale beats a `git` storm across every pane of a
|
||||
/// repo the moment the user alt-tabs back.
|
||||
///
|
||||
/// The throttle counts per *repo*, not per cwd (see
|
||||
/// [`throttle_key`](Self::throttle_key)), and the claim stamps the clock
|
||||
/// rather than waiting for the landing: without that, a dozen panes
|
||||
/// scattered over one repo's subdirectories would all claim in the same
|
||||
/// instant — each of them passing a throttle no probe had answered yet —
|
||||
/// and produce a dozen identical full-repo diffs.
|
||||
pub fn begin_probe_throttled(&mut self, cwd: &Path, min_interval: Duration) -> bool {
|
||||
if self.in_flight.contains(cwd) {
|
||||
return false;
|
||||
}
|
||||
let key = self.throttle_key(cwd).to_path_buf();
|
||||
if self
|
||||
.last_probe
|
||||
.get(&key)
|
||||
.is_some_and(|at| at.elapsed() < min_interval)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.last_probe.insert(key, Instant::now());
|
||||
self.in_flight.insert(cwd.to_path_buf());
|
||||
true
|
||||
}
|
||||
|
||||
/// What the opportunistic throttle counts against: the work-tree root once
|
||||
/// some probe has answered for `cwd`, and `cwd` itself before that.
|
||||
///
|
||||
/// The counts a probe produces are repo-wide — `git diff --numstat HEAD`
|
||||
/// ignores which subdirectory it ran in — so panes at `repo/`, `repo/src`
|
||||
/// and `repo/docs` are three ways of asking one question, and want one
|
||||
/// shared clock rather than one each. In-flight dedup stays keyed by cwd:
|
||||
/// it brackets a specific spawn, and [`finish_probe`](Self::finish_probe)
|
||||
/// has to be able to release exactly what was claimed.
|
||||
///
|
||||
/// Before any probe has landed the root is simply unknown, so the first
|
||||
/// sweep over a repo still costs one probe per distinct cwd; every sweep
|
||||
/// after that collapses to one.
|
||||
fn throttle_key<'a>(&'a self, cwd: &'a Path) -> &'a Path {
|
||||
match self.roots.get(cwd) {
|
||||
Some(Some(root)) => root,
|
||||
_ => cwd,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold a landed probe for `cwd` into the cache. A failed diff inside a
|
||||
/// live repo keeps the root's previous counts (a transient `git` error is
|
||||
/// not "the tree went clean"). Returns whether the cwd was re-triggered
|
||||
/// while this probe flew — the caller should start one more probe.
|
||||
pub fn finish_probe(&mut self, cwd: &Path, snapshot: Option<RepoSnapshot>) -> bool {
|
||||
self.in_flight.remove(cwd);
|
||||
// Re-stamp on landing so the gap is measured from fresh counts, and
|
||||
// under the root this probe just resolved — which is how a cwd first
|
||||
// learns to share its repo's clock (at claim time it had none).
|
||||
let key = match &snapshot {
|
||||
Some(snap) => snap.root.clone(),
|
||||
None => self.throttle_key(cwd).to_path_buf(),
|
||||
};
|
||||
self.last_probe.insert(key, Instant::now());
|
||||
match snapshot {
|
||||
Some(snap) => {
|
||||
let (added, removed) = snap.counts.unwrap_or_else(|| {
|
||||
@@ -422,4 +496,97 @@ mod tests {
|
||||
assert_eq!(cache.status_for(main).unwrap().branch, "main");
|
||||
assert_eq!(cache.status_for(wt).unwrap().branch, "feat/x");
|
||||
}
|
||||
|
||||
/// The four shapes `repo_home` has to tell apart, straight from the
|
||||
/// `--git-dir` / `--git-common-dir` pair the merged `rev-parse` returns.
|
||||
#[test]
|
||||
fn repo_home_resolves_worktree_layouts() {
|
||||
let root = Path::new("/repo/.wt/feat");
|
||||
|
||||
// A main checkout: the two dirs agree, so the work tree is its own home.
|
||||
assert_eq!(
|
||||
repo_home(Path::new("/repo"), Some("/repo/.git"), Some("/repo/.git")),
|
||||
PathBuf::from("/repo")
|
||||
);
|
||||
// A linked worktree: the common dir is the main checkout's `.git`, so
|
||||
// the home is that `.git`'s parent — the main work tree.
|
||||
assert_eq!(
|
||||
repo_home(root, Some("/repo/.git/worktrees/feat"), Some("/repo/.git")),
|
||||
PathBuf::from("/repo")
|
||||
);
|
||||
// A bare repo with worktrees hanging off it: no `.git` component to
|
||||
// strip, so the bare dir itself is the shared key.
|
||||
assert_eq!(
|
||||
repo_home(root, Some("/bare.git/worktrees/feat"), Some("/bare.git")),
|
||||
PathBuf::from("/bare.git")
|
||||
);
|
||||
// A git too old (or too odd) to answer both: degrade to the work tree
|
||||
// rather than guessing a grouping key.
|
||||
assert_eq!(
|
||||
repo_home(root, Some("/repo/.git"), None),
|
||||
root.to_path_buf()
|
||||
);
|
||||
assert_eq!(repo_home(root, None, None), root.to_path_buf());
|
||||
}
|
||||
|
||||
/// The opportunistic path declines where the edge path queues: an in-flight
|
||||
/// probe drops the trigger (and leaves nothing dirty, so no rerun), and a
|
||||
/// probe that just landed rate-limits the next one.
|
||||
#[test]
|
||||
fn throttled_probes_decline_instead_of_queueing() {
|
||||
let mut cache = GitStatusCache::default();
|
||||
let cwd = Path::new("/repo");
|
||||
let gap = Duration::from_secs(60);
|
||||
|
||||
assert!(cache.begin_probe_throttled(cwd, gap));
|
||||
// In flight: declined, and unlike `begin_probe` it doesn't mark dirty —
|
||||
// the landing reports "nothing pending" rather than asking for a rerun.
|
||||
assert!(!cache.begin_probe_throttled(cwd, gap));
|
||||
assert!(!cache.finish_probe(cwd, Some(snap("/repo", "main", Some((1, 0))))));
|
||||
|
||||
// Landed just now: still inside the gap, so the next trigger is dropped.
|
||||
assert!(!cache.begin_probe_throttled(cwd, gap));
|
||||
// …but a zero gap always lets one through, and edge triggers never
|
||||
// consult the throttle at all.
|
||||
assert!(cache.begin_probe_throttled(cwd, Duration::ZERO));
|
||||
assert!(!cache.finish_probe(cwd, Some(snap("/repo", "main", Some((1, 0))))));
|
||||
assert!(cache.begin_probe(cwd));
|
||||
}
|
||||
|
||||
/// The throttle is per repo, not per cwd: panes sitting in different
|
||||
/// subdirectories ask one question (the counts are repo-wide), so once the
|
||||
/// cache knows where they live, a window activation costs one probe for
|
||||
/// the repo rather than one per pane.
|
||||
#[test]
|
||||
fn throttle_collapses_subdirectories_of_one_repo() {
|
||||
let mut cache = GitStatusCache::default();
|
||||
let (top, src, docs) = (
|
||||
Path::new("/repo"),
|
||||
Path::new("/repo/src"),
|
||||
Path::new("/repo/docs"),
|
||||
);
|
||||
let gap = Duration::from_secs(60);
|
||||
|
||||
// Nothing known yet, so each cwd is its own key and each gets a probe.
|
||||
for cwd in [top, src, docs] {
|
||||
assert!(cache.begin_probe_throttled(cwd, gap));
|
||||
assert!(!cache.finish_probe(cwd, Some(snap("/repo", "main", Some((3, 1))))));
|
||||
}
|
||||
|
||||
// Now all three resolve to `/repo`, so the next sweep collapses: the
|
||||
// first pane to ask spends the probe and the rest ride on it.
|
||||
assert!(!cache.begin_probe_throttled(top, gap));
|
||||
assert!(!cache.begin_probe_throttled(src, gap));
|
||||
|
||||
// …and the claim itself is what stops the stampede — with the clock
|
||||
// wound back far enough to let one through, the *others* still decline
|
||||
// while it is in flight, even though nothing has landed yet.
|
||||
assert!(cache.begin_probe_throttled(docs, Duration::ZERO));
|
||||
assert!(!cache.begin_probe_throttled(top, gap));
|
||||
assert!(!cache.begin_probe_throttled(src, gap));
|
||||
|
||||
// A pane elsewhere is untouched by any of it.
|
||||
let other = Path::new("/other");
|
||||
assert!(cache.begin_probe_throttled(other, gap));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2360,6 +2360,7 @@ mod tests {
|
||||
launch_argv: None,
|
||||
rich: true,
|
||||
cwd: None,
|
||||
activity: 0,
|
||||
}))
|
||||
.encode(&mut daemon_side)
|
||||
.unwrap();
|
||||
|
||||
+100
-18
@@ -261,6 +261,11 @@ pub struct TerminalView {
|
||||
/// by work-tree root — panes in one repo share one entry instead of each
|
||||
/// computing (and staling) its own.
|
||||
git_status_cwd: Option<std::path::PathBuf>,
|
||||
/// The agent session's tool-completion count as of the last poll, so a
|
||||
/// change means "the agent ran a tool since we looked" — the cue to refresh
|
||||
/// the git line mid-turn rather than at the end of one. Reset to 0 when no
|
||||
/// session is present, so a new session's first tool call reads as activity.
|
||||
last_agent_activity: u64,
|
||||
/// The inline command line editor. Live only while the shell sits idle
|
||||
/// at its prompt (`input_active`): there the terminal keeps keyboard focus and
|
||||
/// we run our own line editor (so we own Tab / ↑ / ↓ for completion and
|
||||
@@ -460,6 +465,27 @@ const INTEGRATION_GRACE: std::time::Duration = std::time::Duration::from_secs(8)
|
||||
/// How long the integration notice stays up when no keystroke dismisses it.
|
||||
const INTEGRATION_NOTICE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
||||
|
||||
/// Floor on how often an *opportunistic* git probe may run for one cwd (see
|
||||
/// [`GitRefresh::Opportunistic`]). Short enough that the sidebar's counts feel
|
||||
/// live while an agent works, long enough that a burst of tool calls — or an
|
||||
/// alt-tab into a window holding a dozen panes — collapses into one `git`
|
||||
/// shell-out per repo instead of a dozen.
|
||||
const OPPORTUNISTIC_GIT_GAP: std::time::Duration = std::time::Duration::from_millis(1500);
|
||||
|
||||
/// Why a git-status probe is being asked for — the two classes get opposite
|
||||
/// treatment when one is already in flight.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum GitRefresh {
|
||||
/// A rare state change that must not be missed: the pane changed
|
||||
/// directory, a command finished, an agent turn ended. Queues behind an
|
||||
/// in-flight probe (which then reruns) rather than being dropped.
|
||||
Edge,
|
||||
/// A cheap signal that repeats on its own: the window regained focus, the
|
||||
/// agent finished a tool call. Dropped outright when a probe is in flight
|
||||
/// or one ran within [`OPPORTUNISTIC_GIT_GAP`] — the next one will come.
|
||||
Opportunistic,
|
||||
}
|
||||
|
||||
/// Fig-descended PTY shims known to exec over the shell we spawned and re-host
|
||||
/// it on a nested PTY without forwarding OSC 133 — which starves shell
|
||||
/// integration and silently kills the whole command-editor overlay (#46).
|
||||
@@ -1022,6 +1048,7 @@ impl TerminalView {
|
||||
agent_result_unread: false,
|
||||
keep_unread_on_focus: false,
|
||||
git_status_cwd: None,
|
||||
last_agent_activity: 0,
|
||||
cmd: CmdEditor::new(),
|
||||
typeahead: Typeahead::new(),
|
||||
hold: GapHold::new(),
|
||||
@@ -1157,6 +1184,24 @@ impl TerminalView {
|
||||
self.git_status_cwd.as_deref()
|
||||
}
|
||||
|
||||
/// Re-probe this pane's git status opportunistically — for callers holding
|
||||
/// a reason to suspect the tree moved without the pane seeing it. The one
|
||||
/// that matters is the window regaining focus: edits made in an editor, or
|
||||
/// by a `git` command run in another app entirely, produce no event here at
|
||||
/// all, so without this the counts would sit stale until the user happened
|
||||
/// to run something in the pane.
|
||||
///
|
||||
/// Throttled and in-flight-deduped (see [`GitRefresh::Opportunistic`]), so
|
||||
/// calling it for every pane on every activation is cheap. A pane with no
|
||||
/// resolved cwd yet is skipped rather than being pinned to `None` — its
|
||||
/// first real probe is the poll loop's job.
|
||||
pub fn refresh_git_status_now(&mut self, cx: &mut Context<Self>) {
|
||||
let cwd = self.git_status_cwd.clone();
|
||||
if cwd.is_some() {
|
||||
self.refresh_git_status(cwd, GitRefresh::Opportunistic, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// The current grid selection as text, if any non-blank one exists — the
|
||||
/// source for "Agent: Send Selection".
|
||||
pub fn selection_text(&self) -> Option<String> {
|
||||
@@ -2627,6 +2672,13 @@ impl TerminalView {
|
||||
// otherwise stay invisible until it exits. All rare edges, so the
|
||||
// off-thread `git` shell-out runs seldom, not every 300ms tick.
|
||||
//
|
||||
// Those edges alone left the counts badly stale during the case they
|
||||
// matter most: a long agent turn writes file after file for minutes
|
||||
// with nothing to show for it. A tool completion is the one signal that
|
||||
// the tree may have just moved mid-turn, so it refreshes too — through
|
||||
// the throttled path, since a busy agent emits them several a second
|
||||
// and each one would otherwise cost a `git diff` across the repo.
|
||||
//
|
||||
// An agent that reports its own cwd through the hook channel wins over
|
||||
// the proc probe: it tracks internal chdirs the PTY can't observe
|
||||
// (Claude Code's EnterWorktree) and works where the proc fallback
|
||||
@@ -2639,18 +2691,31 @@ impl TerminalView {
|
||||
// a remote path — and being first in the chain it would win over
|
||||
// `local_cwd` unconditionally and hand that path straight to the local
|
||||
// `git`, which is the collision `local_cwd` exists to prevent.
|
||||
let session = self.terminal.agent_session();
|
||||
// A count that moved means at least one tool finished since the last
|
||||
// tick. With no session the counter resets, so a fresh agent's very
|
||||
// first tool call still reads as activity.
|
||||
let tool_activity = match session.as_ref().map(|s| s.activity) {
|
||||
Some(n) => std::mem::replace(&mut self.last_agent_activity, n) != n,
|
||||
None => {
|
||||
self.last_agent_activity = 0;
|
||||
false
|
||||
}
|
||||
};
|
||||
let cwd_now = self
|
||||
.remote_context()
|
||||
.is_none()
|
||||
.then(|| {
|
||||
self.terminal
|
||||
.agent_session()
|
||||
.and_then(|s| s.cwd)
|
||||
session
|
||||
.as_ref()
|
||||
.and_then(|s| s.cwd.clone())
|
||||
.or_else(|| self.cwd())
|
||||
})
|
||||
.flatten();
|
||||
if cwd_now.as_ref() != self.git_status_cwd.as_ref() || cmd_finished || turn_finished {
|
||||
self.refresh_git_status(cwd_now, cx);
|
||||
self.refresh_git_status(cwd_now, GitRefresh::Edge, cx);
|
||||
} else if tool_activity {
|
||||
self.refresh_git_status(cwd_now, GitRefresh::Opportunistic, cx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2666,7 +2731,12 @@ impl TerminalView {
|
||||
/// cannot be what keeps the local probe away from a remote path.
|
||||
///
|
||||
/// [`GitStatusCache`]: crate::terminal::git_status::GitStatusCache
|
||||
fn refresh_git_status(&mut self, cwd: Option<std::path::PathBuf>, cx: &mut Context<Self>) {
|
||||
fn refresh_git_status(
|
||||
&mut self,
|
||||
cwd: Option<std::path::PathBuf>,
|
||||
trigger: GitRefresh,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
use crate::terminal::git_status::GitStatusCache;
|
||||
|
||||
let changed = self.git_status_cwd != cwd;
|
||||
@@ -2678,7 +2748,11 @@ impl TerminalView {
|
||||
return;
|
||||
};
|
||||
cx.default_global::<GitStatusCache>(); // first probe of the process creates it
|
||||
if !cx.update_global::<GitStatusCache, _>(|cache, _| cache.begin_probe(&cwd)) {
|
||||
let claimed = cx.update_global::<GitStatusCache, _>(|cache, _| match trigger {
|
||||
GitRefresh::Edge => cache.begin_probe(&cwd),
|
||||
GitRefresh::Opportunistic => cache.begin_probe_throttled(&cwd, OPPORTUNISTIC_GIT_GAP),
|
||||
});
|
||||
if !claimed {
|
||||
return;
|
||||
}
|
||||
cx.spawn(async move |this, cx| {
|
||||
@@ -2689,19 +2763,27 @@ impl TerminalView {
|
||||
async move { crate::terminal::git_status::probe(&cwd) }
|
||||
})
|
||||
.await;
|
||||
let _ = this.update(cx, |view, cx| {
|
||||
// Landing through `update_global` wakes the sidebar's
|
||||
// `observe_global`, so every pane in the repo repaints — not
|
||||
// just this one.
|
||||
let rerun = cx.update_global::<GitStatusCache, _>(|cache, _| {
|
||||
cache.finish_probe(&cwd, result)
|
||||
// Land the result in the shared cache before touching the pane,
|
||||
// and whether or not the pane still exists: the in-flight claim is
|
||||
// keyed by *cwd*, so a pane closed mid-probe that never released
|
||||
// its claim would wedge the git line of every other pane in that
|
||||
// directory — permanently, since nothing else ever clears it.
|
||||
//
|
||||
// Landing through `update_global` wakes the sidebar's
|
||||
// `observe_global`, so every pane in the repo repaints — not just
|
||||
// this one.
|
||||
let rerun =
|
||||
cx.update_global::<GitStatusCache, _>(|cache, _| cache.finish_probe(&cwd, result));
|
||||
// A trigger arrived while we flew; go once more so its state is
|
||||
// observed — unless this pane has since left that cwd. Only edge
|
||||
// triggers set that flag, so the rerun is an edge too.
|
||||
if rerun {
|
||||
let _ = this.update(cx, |view, cx| {
|
||||
if view.git_status_cwd.as_deref() == Some(&cwd) {
|
||||
view.refresh_git_status(Some(cwd), GitRefresh::Edge, cx);
|
||||
}
|
||||
});
|
||||
// A trigger arrived while we flew; go once more so its state
|
||||
// is observed — unless this pane has since left that cwd.
|
||||
if rerun && view.git_status_cwd.as_deref() == Some(&cwd) {
|
||||
view.refresh_git_status(Some(cwd), cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
+28
-1
@@ -530,13 +530,22 @@ impl Tty7App {
|
||||
// so treat it like a release. Dismissing on *both* flips also keeps a
|
||||
// reveal scheduled just before the switch from popping the badges up
|
||||
// in a window the user already left.
|
||||
let activation_watch = cx.observe_window_activation(window, |this, _window, cx| {
|
||||
let activation_watch = cx.observe_window_activation(window, |this, window, cx| {
|
||||
this.dismiss_mod_hint(cx);
|
||||
// The panes' link-modifier tracking loses the release the same
|
||||
// way, and a stale "⌘ held" is worse than missing badges: a
|
||||
// plain unmodified click would open links. Treat the flip as a
|
||||
// release; holding ⌘ again re-arms it via `on_modifiers_changed`.
|
||||
this.set_link_modifier(false, cx);
|
||||
// Coming back is the only cue we get that the working tree may
|
||||
// have moved while the user was elsewhere: an edit in another
|
||||
// editor, a `git` command in another app, an agent in another
|
||||
// window. None of those reach a pane's poll loop, so without this
|
||||
// the sidebar's `+N −N` would keep showing pre-alt-tab numbers
|
||||
// until the user happened to run a command in the pane.
|
||||
if window.is_window_active() {
|
||||
this.refresh_git_status_all(cx);
|
||||
}
|
||||
});
|
||||
// Follow OS light/dark flips live: while "sync with system" is on, an
|
||||
// appearance change re-resolves the theme slot and repaints. While it's
|
||||
@@ -2067,6 +2076,24 @@ impl Tty7App {
|
||||
window.focus(&handle, cx);
|
||||
}
|
||||
|
||||
/// Ask every pane in the window to re-probe its git status. Called when the
|
||||
/// window regains focus: the sidebar shows a git line for *every* tab, not
|
||||
/// just the active one, so refreshing only the focused pane would leave the
|
||||
/// rest of the list stale — which is exactly the list the user is scanning
|
||||
/// right after switching back.
|
||||
///
|
||||
/// Panes sharing a cwd fold into one probe in the shared cache, and the
|
||||
/// throttle there counts per repo rather than per cwd, so once the cache
|
||||
/// knows where each pane lives the cost of a window with many panes is
|
||||
/// bounded by the number of distinct repos — not by the number of
|
||||
/// subdirectories they happen to sit in, which would be the same full-repo
|
||||
/// `git diff` asked several times over.
|
||||
fn refresh_git_status_all(&mut self, cx: &mut Context<Self>) {
|
||||
for leaf in self.tabs.iter().flat_map(|tab| tab.pane.leaves()) {
|
||||
leaf.update(cx, |view, cx| view.refresh_git_status_now(cx));
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a freshly opened tab should be inserted, per `new_tab_position`:
|
||||
/// right after the active tab, or appended at the end. Clamped to the tab
|
||||
/// count so the zero-tab home state (active 0, no tabs) inserts at 0.
|
||||
|
||||
Reference in New Issue
Block a user