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