返回 CodeWhale
workspace_context.rs
根目录 / crates / tui / src / tui / workspace_context.rs
1 //! Per-workspace git context shown in the composer header.
2 //!
3 //! The TUI shows a "branch | clean/N modified/…" badge sourced from
4 //! `git status` and `git rev-parse`. To avoid spawning git on every
5 //! render, the result is cached and only refreshed every
6 //! `REFRESH_SECS` seconds. The refresh prefers spawn-blocking on the
7 //! current Tokio runtime; tests and non-async callers fall through to
8 //! a synchronous call.
9
10 use crate::dependencies::{ExternalTool, Git};
11 use std::path::Path;
12 use std::time::{Duration, Instant};
13
14 use crate::tui::app::App;
15
16 /// How often (seconds) the workspace context badge is allowed to
17 /// re-query git. Exposed for tests that exercise the TTL.
18 pub(crate) const REFRESH_SECS: u64 = 15;
19
20 /// Pull a fresh workspace context from disk if the cached value is
21 /// older than [`REFRESH_SECS`] and `allow_refresh` is true. Always
22 /// drains any pending async result into `app.workspace_context` first
23 /// so the render pass sees the latest value (#399 S1).
24 pub(super) fn refresh_if_needed(app: &mut App, now: Instant, allow_refresh: bool) {
25 // Drain the async cell result into the live field first, so the render
26 // path always reads the latest value (#399 S1).
27 if let Ok(mut cell) = app.workspace_context_cell.lock()
28 && let Some(ctx) = cell.take()
29 {
30 if app.workspace_context.as_deref() != Some(ctx.as_str()) {
31 app.needs_redraw = true;
32 }
33 app.workspace_context = Some(ctx);
34 }
35
36 if app
37 .workspace_context_refreshed_at
38 .is_some_and(|refreshed_at| {
39 now.duration_since(refreshed_at) < Duration::from_secs(REFRESH_SECS)
40 })
41 {
42 return;
43 }
44
45 if !allow_refresh {
46 return;
47 }
48
49 // The Session sidebar shows the memory file's size every frame it is
50 // visible. Stat it here, on the same TTL as the git context, so the draw
51 // closure reads a cached string instead of issuing a syscall per frame
52 // (#3908). Cheap on a local disk; tens of ms on NFS/SSHFS/cloud-synced
53 // home directories, which is exactly where the stutter was reported.
54 refresh_memory_size_hint(app);
55
56 // Offload git query to a background thread when a Tokio runtime is
57 // available. Fall back to synchronous execution for tests and other
58 // non-async contexts (#399 S1).
59 if let Ok(handle) = tokio::runtime::Handle::try_current() {
60 let ctx = app.workspace_context_cell.clone();
61 let workspace = app.workspace.clone();
62 handle.spawn_blocking(move || {
63 let result = collect(&workspace);
64 if let Ok(mut guard) = ctx.lock() {
65 *guard = result;
66 }
67 });
68 } else {
69 // No runtime — run synchronously so tests and one-shot callers
70 // still get a result immediately.
71 app.workspace_context = collect(&app.workspace);
72 }
73 app.workspace_context_refreshed_at = Some(now);
74 }
75
76 /// Re-read the memory file's size into [`App::memory_size_hint`].
77 ///
78 /// A missing or unreadable file renders as an em dash, matching what the
79 /// sidebar showed when it stat-ed inline.
80 fn refresh_memory_size_hint(app: &mut App) {
81 let hint = if app.use_memory {
82 Some(
83 std::fs::metadata(&app.memory_path)
84 .map(|meta| format_size(meta.len()))
85 .unwrap_or_else(|_| "\u{2014}".to_string()),
86 )
87 } else {
88 None
89 };
90 if app.memory_size_hint != hint {
91 app.needs_redraw = true;
92 app.memory_size_hint = hint;
93 }
94 }
95
96 /// Human-readable byte size, in the exact shape the sidebar rendered inline.
97 fn format_size(bytes: u64) -> String {
98 if bytes >= 1024 * 1024 {
99 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
100 } else if bytes >= 1024 {
101 format!("{:.1} KB", bytes as f64 / 1024.0)
102 } else {
103 format!("{bytes} B")
104 }
105 }
106
107 /// Force a workspace-context re-query on the next render tick, bypassing the
108 /// normal TTL. Keeps the current value visible while the background git query
109 /// is running.
110 pub(super) fn refresh_now(app: &mut App, now: Instant) {
111 if let Ok(mut cell) = app.workspace_context_cell.lock() {
112 *cell = None;
113 }
114 app.workspace_context_refreshed_at = None;
115 refresh_if_needed(app, now, true);
116 }
117
118 #[derive(Debug, Default, Clone, Copy)]
119 struct ChangeSummary {
120 staged: usize,
121 modified: usize,
122 untracked: usize,
123 conflicts: usize,
124 }
125
126 impl ChangeSummary {
127 fn is_clean(&self) -> bool {
128 self.staged == 0 && self.modified == 0 && self.untracked == 0 && self.conflicts == 0
129 }
130 }
131
132 /// Build the human-readable workspace context string ("branch | status")
133 /// from `git rev-parse` + `git status`. Returns `None` if the workspace
134 /// is not a git repository or git itself is unavailable.
135 pub(crate) fn collect(workspace: &Path) -> Option<String> {
136 let branch = branch(workspace)?;
137 let summary = change_summary(workspace)?;
138
139 let mut parts = Vec::new();
140 if summary.staged > 0 {
141 parts.push(format!("{} staged", summary.staged));
142 }
143 if summary.modified > 0 {
144 parts.push(format!("{} modified", summary.modified));
145 }
146 if summary.untracked > 0 {
147 parts.push(format!("{} untracked", summary.untracked));
148 }
149 if summary.conflicts > 0 {
150 parts.push(format!("{} conflicts", summary.conflicts));
151 }
152
153 let status = if summary.is_clean() {
154 "clean".to_string()
155 } else {
156 parts.join(", ")
157 };
158
159 Some(format!("{branch} | {status}"))
160 }
161
162 pub(crate) fn branch_from_context(context: &str) -> Option<&str> {
163 let (branch, _) = context.rsplit_once(" | ")?;
164 (!branch.is_empty()).then_some(branch)
165 }
166
167 /// Concise, factual workspace identity for the footer status chip (#3188).
168 ///
169 /// The identity is sourced from workspace/git detection only — never from
170 /// model narration or config text. `name` is the workspace basename, `branch`
171 /// is `Some` only when the workspace is a git repository (carrying the cached
172 /// `"detached:<hash>"` form for detached HEAD), and `is_git` distinguishes a
173 /// real repo from a plain directory so the footer can show an explicit
174 /// non-repo state instead of an empty `Repo:` label.
175 #[derive(Debug, Clone, PartialEq, Eq)]
176 pub(crate) struct WorkspaceIdentity {
177 pub name: String,
178 pub branch: Option<String>,
179 pub is_git: bool,
180 }
181
182 /// Basename used as the workspace identity. Falls back to a stable sentinel
183 /// when the path has no final component (filesystem root). Derived purely
184 /// from the workspace path, so it never spawns git on the render path.
185 pub(crate) fn workspace_basename(workspace: &Path) -> String {
186 workspace
187 .file_name()
188 .and_then(|s| s.to_str())
189 .filter(|s| !s.is_empty())
190 .unwrap_or("(root)")
191 .to_string()
192 }
193
194 /// Resolve the footer identity from the workspace path plus the cached
195 /// "branch | status" context string. `context` is `None` when the workspace
196 /// is not a git repository (or git is unavailable), which we surface as an
197 /// explicit non-repo state rather than hiding the chip.
198 pub(crate) fn identity_from_context(workspace: &Path, context: Option<&str>) -> WorkspaceIdentity {
199 let branch = context.and_then(branch_from_context).map(str::to_string);
200 WorkspaceIdentity {
201 name: workspace_basename(workspace),
202 is_git: branch.is_some(),
203 branch,
204 }
205 }
206
207 pub(super) fn branch(workspace: &Path) -> Option<String> {
208 let branch = run_git(workspace, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
209 let branch = branch.trim().to_string();
210 if branch == "HEAD" || branch.is_empty() {
211 let short_hash = run_git(workspace, &["rev-parse", "--short", "HEAD"]).ok()?;
212 let short_hash = short_hash.trim();
213 if short_hash.is_empty() {
214 return None;
215 }
216 return Some(format!("detached:{short_hash}"));
217 }
218 Some(branch)
219 }
220
221 fn change_summary(workspace: &Path) -> Option<ChangeSummary> {
222 let status = run_git(
223 workspace,
224 &["status", "--short", "--untracked-files=normal"],
225 )
226 .ok()?;
227
228 if status.trim().is_empty() {
229 return Some(ChangeSummary::default());
230 }
231
232 let mut summary = ChangeSummary::default();
233 for line in status.lines() {
234 if line.trim().is_empty() {
235 continue;
236 }
237
238 let mut chars = line.chars();
239 let staged = chars.next()?;
240 let modified = chars.next().unwrap_or(' ');
241
242 if staged == ' ' && modified == ' ' {
243 continue;
244 }
245 if staged == '?' && modified == '?' {
246 summary.untracked = summary.untracked.saturating_add(1);
247 continue;
248 }
249
250 if staged == 'U' || modified == 'U' {
251 summary.conflicts = summary.conflicts.saturating_add(1);
252 }
253 if staged != ' ' && staged != '?' {
254 summary.staged = summary.staged.saturating_add(1);
255 }
256 if modified != ' ' && modified != '?' {
257 summary.modified = summary.modified.saturating_add(1);
258 }
259 }
260
261 Some(summary)
262 }
263
264 fn run_git(workspace: &Path, args: &[&str]) -> std::io::Result<String> {
265 let output = Git::output(args, workspace)?;
266 if !output.status.success() {
267 return Err(std::io::Error::other("git command failed"));
268 }
269 Ok(String::from_utf8_lossy(&output.stdout).to_string())
270 }
271
272 #[cfg(test)]
273 mod tests {
274 use super::*;
275
276 #[test]
277 fn memory_size_hint_is_cached_off_the_render_path() {
278 // #3908: the Session sidebar rendered this by stat-ing the memory file
279 // inside the draw closure, once per frame. The stat now happens here,
280 // on the workspace-context TTL, so the sidebar reads a plain String.
281 let dir = tempfile::tempdir().expect("temp dir");
282 let memory = dir.path().join("MEMORY.md");
283 std::fs::write(&memory, vec![b'x'; 2048]).unwrap();
284
285 let mut app = crate::tui::app::App::new(
286 crate::test_support::test_tui_options(dir.path()),
287 &crate::config::Config::default(),
288 );
289 app.use_memory = true;
290 app.memory_path = memory.clone();
291
292 refresh_memory_size_hint(&mut app);
293 assert_eq!(app.memory_size_hint.as_deref(), Some("2.0 KB"));
294
295 // A file that is not there reads the same as one we cannot stat: the
296 // sidebar's original em dash, not a crash or a stale number.
297 std::fs::remove_file(&memory).unwrap();
298 refresh_memory_size_hint(&mut app);
299 assert_eq!(app.memory_size_hint.as_deref(), Some("\u{2014}"));
300
301 // Memory off means nothing to show at all.
302 app.use_memory = false;
303 refresh_memory_size_hint(&mut app);
304 assert_eq!(app.memory_size_hint, None);
305 }
306
307 #[test]
308 fn memory_size_formats_match_the_sidebar_original() {
309 assert_eq!(format_size(512), "512 B");
310 assert_eq!(format_size(1024), "1.0 KB");
311 assert_eq!(format_size(1024 * 1024), "1.0 MB");
312 }
313
314 #[test]
315 fn workspace_basename_handles_root_path() {
316 assert_eq!(workspace_basename(Path::new("/")), "(root)");
317 assert_eq!(workspace_basename(Path::new("/a/b/project")), "project");
318 }
319 }
320
320 lines RUST