返回 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 /// One completed background refresh, including an unavailable Git result.
21 #[derive(Debug)]
22 pub(crate) struct WorkspaceContextSnapshot {
23 pub workspace: std::path::PathBuf,
24 pub context: Option<String>,
25 pub is_linked_worktree: bool,
26 }
27
28 fn collect_snapshot(workspace: &Path) -> WorkspaceContextSnapshot {
29 let context = collect(workspace);
30 let is_linked_worktree = context.is_some()
31 && run_git(
32 workspace,
33 &[
34 "rev-parse",
35 "--path-format=absolute",
36 "--git-dir",
37 "--git-common-dir",
38 ],
39 )
40 .ok()
41 .is_some_and(|paths| {
42 let mut paths = paths.lines();
43 matches!((paths.next(), paths.next(), paths.next()),
44 (Some(git_dir), Some(common_dir), None) if git_dir != common_dir)
45 });
46 WorkspaceContextSnapshot {
47 workspace: workspace.to_path_buf(),
48 context,
49 is_linked_worktree,
50 }
51 }
52
53 fn apply_snapshot(app: &mut App, snapshot: WorkspaceContextSnapshot) {
54 if snapshot.workspace != app.workspace {
55 return;
56 }
57 if app.workspace_context != snapshot.context
58 || app.workspace_is_linked_worktree != snapshot.is_linked_worktree
59 {
60 app.needs_redraw = true;
61 }
62 app.workspace_context = snapshot.context;
63 app.workspace_is_linked_worktree = snapshot.is_linked_worktree;
64 }
65
66 /// Pull a fresh workspace context from disk if the cached value is
67 /// older than [`REFRESH_SECS`] and `allow_refresh` is true. Always
68 /// drains any pending async result into `app.workspace_context` first
69 /// so the render pass sees the latest value (#399 S1).
70 pub(super) fn refresh_if_needed(app: &mut App, now: Instant, allow_refresh: bool) {
71 // Completion is distinct from a missing result: losing a repository must
72 // clear a stale branch, and an old workspace's refresh must not replace it.
73 let completed = app
74 .workspace_context_cell
75 .lock()
76 .ok()
77 .and_then(|mut cell| cell.take());
78 if let Some(snapshot) = completed {
79 apply_snapshot(app, snapshot);
80 }
81
82 if app
83 .workspace_context_refreshed_at
84 .is_some_and(|refreshed_at| {
85 now.duration_since(refreshed_at) < Duration::from_secs(REFRESH_SECS)
86 })
87 {
88 return;
89 }
90
91 if !allow_refresh {
92 return;
93 }
94
95 // The Session sidebar shows the memory file's size every frame it is
96 // visible. Stat it here, on the same TTL as the git context, so the draw
97 // closure reads a cached string instead of issuing a syscall per frame
98 // (#3908). Cheap on a local disk; tens of ms on NFS/SSHFS/cloud-synced
99 // home directories, which is exactly where the stutter was reported.
100 refresh_memory_size_hint(app);
101
102 // Offload git query to a background thread when a Tokio runtime is
103 // available. Fall back to synchronous execution for tests and other
104 // non-async contexts (#399 S1).
105 if let Ok(handle) = tokio::runtime::Handle::try_current() {
106 let ctx = app.workspace_context_cell.clone();
107 let workspace = app.workspace.clone();
108 handle.spawn_blocking(move || {
109 let result = collect_snapshot(&workspace);
110 if let Ok(mut guard) = ctx.lock() {
111 *guard = Some(result);
112 }
113 });
114 } else {
115 // No runtime — run synchronously so tests and one-shot callers
116 // still get a result immediately.
117 let snapshot = collect_snapshot(&app.workspace);
118 apply_snapshot(app, snapshot);
119 }
120 app.workspace_context_refreshed_at = Some(now);
121 }
122
123 /// Re-read the memory file's size into [`App::memory_size_hint`].
124 ///
125 /// A missing or unreadable file renders as an em dash, matching what the
126 /// sidebar showed when it stat-ed inline.
127 fn refresh_memory_size_hint(app: &mut App) {
128 let hint = if app.use_memory {
129 Some(
130 std::fs::metadata(&app.memory_path)
131 .map(|meta| format_size(meta.len()))
132 .unwrap_or_else(|_| "\u{2014}".to_string()),
133 )
134 } else {
135 None
136 };
137 if app.memory_size_hint != hint {
138 app.needs_redraw = true;
139 app.memory_size_hint = hint;
140 }
141 }
142
143 /// Human-readable byte size, in the exact shape the sidebar rendered inline.
144 fn format_size(bytes: u64) -> String {
145 if bytes >= 1024 * 1024 {
146 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
147 } else if bytes >= 1024 {
148 format!("{:.1} KB", bytes as f64 / 1024.0)
149 } else {
150 format!("{bytes} B")
151 }
152 }
153
154 /// Force a workspace-context re-query on the next render tick, bypassing the
155 /// normal TTL. Keeps the current value visible while the background git query
156 /// is running.
157 pub(super) fn refresh_now(app: &mut App, now: Instant) {
158 if let Ok(mut cell) = app.workspace_context_cell.lock() {
159 *cell = None;
160 }
161 app.workspace_context_refreshed_at = None;
162 refresh_if_needed(app, now, true);
163 }
164
165 #[derive(Debug, Default, Clone, Copy)]
166 struct ChangeSummary {
167 staged: usize,
168 modified: usize,
169 untracked: usize,
170 conflicts: usize,
171 }
172
173 impl ChangeSummary {
174 fn is_clean(&self) -> bool {
175 self.staged == 0 && self.modified == 0 && self.untracked == 0 && self.conflicts == 0
176 }
177 }
178
179 /// Build the human-readable workspace context string ("branch | status")
180 /// from `git rev-parse` + `git status`. Returns `None` if the workspace
181 /// is not a git repository or git itself is unavailable.
182 pub(crate) fn collect(workspace: &Path) -> Option<String> {
183 let branch = branch(workspace)?;
184 let summary = change_summary(workspace)?;
185
186 let mut parts = Vec::new();
187 if summary.staged > 0 {
188 parts.push(format!("{} staged", summary.staged));
189 }
190 if summary.modified > 0 {
191 parts.push(format!("{} modified", summary.modified));
192 }
193 if summary.untracked > 0 {
194 parts.push(format!("{} untracked", summary.untracked));
195 }
196 if summary.conflicts > 0 {
197 parts.push(format!("{} conflicts", summary.conflicts));
198 }
199
200 let status = if summary.is_clean() {
201 "clean".to_string()
202 } else {
203 parts.join(", ")
204 };
205
206 Some(format!("{branch} | {status}"))
207 }
208
209 pub(crate) fn branch_from_context(context: &str) -> Option<&str> {
210 let (branch, _) = context.rsplit_once(" | ")?;
211 (!branch.is_empty()).then_some(branch)
212 }
213
214 /// Concise, factual workspace identity for the footer status chip (#3188).
215 ///
216 /// The identity is sourced from workspace/git detection only — never from
217 /// model narration or config text. `name` is the workspace basename, `branch`
218 /// is `Some` only when the workspace is a git repository (carrying the cached
219 /// `"detached:<hash>"` form for detached HEAD), and `is_git` distinguishes a
220 /// real repo from a plain directory so the footer can show an explicit
221 /// non-repo state instead of an empty `Repo:` label.
222 #[derive(Debug, Clone, PartialEq, Eq)]
223 pub(crate) struct WorkspaceIdentity {
224 pub name: String,
225 pub branch: Option<String>,
226 pub is_git: bool,
227 }
228
229 /// Basename used as the workspace identity. Falls back to a stable sentinel
230 /// when the path has no final component (filesystem root). Derived purely
231 /// from the workspace path, so it never spawns git on the render path.
232 pub(crate) fn workspace_basename(workspace: &Path) -> String {
233 workspace
234 .file_name()
235 .and_then(|s| s.to_str())
236 .filter(|s| !s.is_empty())
237 .unwrap_or("(root)")
238 .to_string()
239 }
240
241 /// Resolve the footer identity from the workspace path plus the cached
242 /// "branch | status" context string. `context` is `None` when the workspace
243 /// is not a git repository (or git is unavailable), which we surface as an
244 /// explicit non-repo state rather than hiding the chip.
245 pub(crate) fn identity_from_context(workspace: &Path, context: Option<&str>) -> WorkspaceIdentity {
246 let branch = context.and_then(branch_from_context).map(str::to_string);
247 WorkspaceIdentity {
248 name: workspace_basename(workspace),
249 is_git: branch.is_some(),
250 branch,
251 }
252 }
253
254 /// Hard display-column cap for the opt-in `workspace` / `git_branch`
255 /// metrics-line chips (#6112): the only status items whose value is
256 /// arbitrary-length text, so they are the ones that could reflow the row.
257 /// The full path stays in `/status` and the empty-state caption.
258 pub(crate) const STATUS_CHIP_MAX_WIDTH: usize = 24;
259
260 /// Left-truncate `text` to `max_width` display columns, keeping the tail —
261 /// the discriminating part of a directory name or branch — and marking the
262 /// cut with a leading `…`. Unicode-safe: widths come from `unicode_width`
263 /// and the cut never splits a `char`.
264 pub(crate) fn truncate_left(text: &str, max_width: usize) -> String {
265 use unicode_segmentation::UnicodeSegmentation;
266 use unicode_width::UnicodeWidthStr;
267 if max_width == 0 {
268 return String::new();
269 }
270 let text: String = text.chars().filter(|ch| !ch.is_control()).collect();
271 if text.width() <= max_width {
272 return text;
273 }
274 let mut width = 1; // ellipsis
275 let mut start = text.len();
276 for (index, grapheme) in text.grapheme_indices(true).rev() {
277 let next = width + grapheme.width();
278 if next > max_width {
279 break;
280 }
281 width = next;
282 start = index;
283 }
284 format!("…{}", &text[start..])
285 }
286
287 /// Linked worktrees often repeat a repository leaf name. Include their parent
288 /// directory as a disambiguator, without reading the filesystem during draw.
289 pub(crate) fn status_workspace_name(workspace: &Path, is_linked_worktree: bool) -> String {
290 let leaf = workspace_basename(workspace);
291 if is_linked_worktree && let Some(parent) = workspace.parent().and_then(Path::file_name) {
292 return format!("{}/{leaf}", parent.to_string_lossy());
293 }
294 leaf
295 }
296
297 pub(super) fn branch(workspace: &Path) -> Option<String> {
298 let branch = run_git(workspace, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
299 let branch = branch.trim().to_string();
300 if branch == "HEAD" || branch.is_empty() {
301 let short_hash = run_git(workspace, &["rev-parse", "--short", "HEAD"]).ok()?;
302 let short_hash = short_hash.trim();
303 if short_hash.is_empty() {
304 return None;
305 }
306 return Some(format!("detached:{short_hash}"));
307 }
308 Some(branch)
309 }
310
311 fn change_summary(workspace: &Path) -> Option<ChangeSummary> {
312 let status = run_git(
313 workspace,
314 &["status", "--short", "--untracked-files=normal"],
315 )
316 .ok()?;
317
318 if status.trim().is_empty() {
319 return Some(ChangeSummary::default());
320 }
321
322 let mut summary = ChangeSummary::default();
323 for line in status.lines() {
324 if line.trim().is_empty() {
325 continue;
326 }
327
328 let mut chars = line.chars();
329 let staged = chars.next()?;
330 let modified = chars.next().unwrap_or(' ');
331
332 if staged == ' ' && modified == ' ' {
333 continue;
334 }
335 if staged == '?' && modified == '?' {
336 summary.untracked = summary.untracked.saturating_add(1);
337 continue;
338 }
339
340 if staged == 'U' || modified == 'U' {
341 summary.conflicts = summary.conflicts.saturating_add(1);
342 }
343 if staged != ' ' && staged != '?' {
344 summary.staged = summary.staged.saturating_add(1);
345 }
346 if modified != ' ' && modified != '?' {
347 summary.modified = summary.modified.saturating_add(1);
348 }
349 }
350
351 Some(summary)
352 }
353
354 fn run_git(workspace: &Path, args: &[&str]) -> std::io::Result<String> {
355 let output = Git::output(args, workspace)?;
356 if !output.status.success() {
357 return Err(std::io::Error::other("git command failed"));
358 }
359 Ok(String::from_utf8_lossy(&output.stdout).to_string())
360 }
361
362 #[cfg(test)]
363 mod tests {
364 use super::*;
365
366 #[test]
367 fn memory_size_hint_is_cached_off_the_render_path() {
368 // #3908: the Session sidebar rendered this by stat-ing the memory file
369 // inside the draw closure, once per frame. The stat now happens here,
370 // on the workspace-context TTL, so the sidebar reads a plain String.
371 let dir = tempfile::tempdir().expect("temp dir");
372 let memory = dir.path().join("MEMORY.md");
373 std::fs::write(&memory, vec![b'x'; 2048]).unwrap();
374
375 let mut app = crate::tui::app::App::new(
376 crate::test_support::test_tui_options(dir.path()),
377 &crate::config::Config::default(),
378 );
379 app.use_memory = true;
380 app.memory_path = memory.clone();
381
382 refresh_memory_size_hint(&mut app);
383 assert_eq!(app.memory_size_hint.as_deref(), Some("2.0 KB"));
384
385 // A file that is not there reads the same as one we cannot stat: the
386 // sidebar's original em dash, not a crash or a stale number.
387 std::fs::remove_file(&memory).unwrap();
388 refresh_memory_size_hint(&mut app);
389 assert_eq!(app.memory_size_hint.as_deref(), Some("\u{2014}"));
390
391 // Memory off means nothing to show at all.
392 app.use_memory = false;
393 refresh_memory_size_hint(&mut app);
394 assert_eq!(app.memory_size_hint, None);
395 }
396
397 #[test]
398 fn memory_size_formats_match_the_sidebar_original() {
399 assert_eq!(format_size(512), "512 B");
400 assert_eq!(format_size(1024), "1.0 KB");
401 assert_eq!(format_size(1024 * 1024), "1.0 MB");
402 }
403
404 #[test]
405 fn workspace_basename_handles_root_path() {
406 assert_eq!(workspace_basename(Path::new("/")), "(root)");
407 assert_eq!(workspace_basename(Path::new("/a/b/project")), "project");
408 }
409
410 #[test]
411 fn truncate_left_keeps_the_tail_within_budget() {
412 // Short values pass through untouched.
413 assert_eq!(truncate_left("codewhale", 24), "codewhale");
414 // Exactly at the cap is not a truncation.
415 assert_eq!(truncate_left("abcdefghij", 10), "abcdefghij");
416 // Long values keep the tail behind a one-column ellipsis.
417 let cut = truncate_left("very-long-workspace-name", 10);
418 assert_eq!(cut, "\u{2026}pace-name");
419 assert_eq!(
420 unicode_width::UnicodeWidthStr::width(cut.as_str()),
421 10,
422 "{cut}"
423 );
424 // Wide chars count by display columns and are never split.
425 let cut = truncate_left("workspace-作業ディレクトリ", 10);
426 assert!(cut.starts_with('\u{2026}'), "{cut}");
427 assert!(
428 unicode_width::UnicodeWidthStr::width(cut.as_str()) <= 10,
429 "{cut}"
430 );
431 }
432 #[test]
433 fn workspace_chip_respects_zero_width_graphemes_and_terminal_controls() {
434 use unicode_width::UnicodeWidthStr;
435 for text in ["e\u{301}-family-👨‍👩‍👧‍👦", "作業-directory", "\x1b[31mname\n"]
436 {
437 for budget in 0..25 {
438 let result = truncate_left(text, budget);
439 assert!(result.width() <= budget, "{result:?} exceeds {budget}");
440 assert!(!result.chars().any(char::is_control));
441 }
442 }
443 assert_eq!(truncate_left("prefix-👨‍👩‍👧‍👦", 3), "…👨‍👩‍👧‍👦");
444 assert_eq!(
445 status_workspace_name(Path::new("/trees/feature/codewhale"), true),
446 "feature/codewhale"
447 );
448 assert_eq!(
449 status_workspace_name(Path::new("/trees/feature/codewhale"), false),
450 "codewhale"
451 );
452 }
453
454 #[test]
455 fn workspace_snapshot_detects_linked_worktrees_and_detached_heads() {
456 let root = tempfile::tempdir().unwrap();
457 let main = root.path().join("main");
458 let linked = root.path().join("feature");
459 std::fs::create_dir(&main).unwrap();
460 run_git(&main, &["init", "--initial-branch=main"]).unwrap();
461 run_git(
462 &main,
463 &[
464 "-c",
465 "user.name=Fixture",
466 "-c",
467 "user.email=fixture@example.invalid",
468 "-c",
469 "commit.gpgsign=false",
470 "commit",
471 "--allow-empty",
472 "-m",
473 "fixture",
474 ],
475 )
476 .unwrap();
477 run_git(
478 &main,
479 &["worktree", "add", "-b", "feature", linked.to_str().unwrap()],
480 )
481 .unwrap();
482 let ordinary = collect_snapshot(&main);
483 assert!(!ordinary.is_linked_worktree);
484 let linked_snapshot = collect_snapshot(&linked);
485 assert!(linked_snapshot.is_linked_worktree);
486 assert_eq!(
487 linked_snapshot
488 .context
489 .as_deref()
490 .and_then(branch_from_context),
491 Some("feature")
492 );
493 run_git(&linked, &["checkout", "--detach"]).unwrap();
494 let detached = collect_snapshot(&linked);
495 assert!(detached.is_linked_worktree);
496 assert!(
497 detached
498 .context
499 .as_deref()
500 .and_then(branch_from_context)
501 .unwrap()
502 .starts_with("detached:")
503 );
504 let outside = root.path().join("outside");
505 std::fs::create_dir(&outside).unwrap();
506 let missing = collect_snapshot(&outside);
507 assert!(missing.context.is_none());
508 assert!(!missing.is_linked_worktree);
509 }
510 }
511
511 lines RUST