A post-modern development environment.
0

Configure Feed

Select the types of activity you want to include in your feed.

filetree: Color entries by VCS status with directory rollup

Reads each entry's git status via `helix-vcs` and colors the row by
state (modified / added / deleted / untracked). Directories roll up:
a dir is highlighted by the strongest status among its descendants,
so a clean tree shows clean folders. The scan runs on a tokio
blocking task so a large repo never stalls the panel; results
trickle into a shared cache that the render path reads
lock-free.

author
Isaac Corbrey
date (Jul 3, 2026, 7:07 PM -0400) commit 1411d30b parent ed591f68 change-id otzozklm
+332 -12
+13
helix-term/src/commands.rs
··· 3257 3257 } 3258 3258 cx.editor.filetree.focused = true; 3259 3259 3260 + // Kick off a VCS refresh whenever the user reaches into the panel. 3261 + // Runs on a tokio blocking task; results trickle into the cache and 3262 + // request a redraw per change, so the row coloring lights up as 3263 + // statuses arrive rather than blocking on the full scan. `<R>` while 3264 + // focused triggers the same path explicitly. 3265 + if let Some(root) = cx.editor.filetree.root.clone() { 3266 + ui::filetree::vcs::refresh( 3267 + cx.editor.diff_providers.clone(), 3268 + root, 3269 + cx.editor.filetree.vcs_cache.clone(), 3270 + ); 3271 + } 3272 + 3260 3273 // Try to surface the active buffer's path in the tree. Doc paths are 3261 3274 // canonical (set by Editor::open); the root we just stored is too; so 3262 3275 // [`ui::Filetree::reveal_path_with_ancestors`] can do the under-root
+63 -6
helix-term/src/ui/filetree.rs
··· 23 23 //! aren't wired up here. 24 24 25 25 mod context_menu; 26 + pub mod vcs; 26 27 27 28 use std::collections::HashSet; 28 29 use std::path::{Path, PathBuf}; ··· 522 523 } 523 524 KeyCode::Char('R') => { 524 525 self.dirty = true; 526 + // Also re-scan VCS state. Cheap to fire — the heavy work 527 + // runs on a tokio blocking task and trickles results into 528 + // the cache as they arrive. `clone()` on each field is 529 + // explicitly necessary to release the borrow on `ctx` so 530 + // each call can be made without the borrow checker 531 + // complaining about partial moves. 532 + if let Some(root) = ctx.editor.filetree.root.clone() { 533 + vcs::refresh( 534 + ctx.editor.diff_providers.clone(), 535 + root, 536 + ctx.editor.filetree.vcs_cache.clone(), 537 + ); 538 + } 525 539 EventResult::Consumed(None) 526 540 } 527 541 _ => EventResult::Consumed(None), // swallow other keys while focused ··· 907 921 // some inherited highlight. 908 922 let indicator_style = theme.try_get_exact("ui.filetree.indicator.open"); 909 923 924 + // VCS row styles. The renderer patches each entry's row style with 925 + // the corresponding scope when the VCS cache reports a status. 926 + // `try_get_exact` so untreated themes don't silently substitute a 927 + // base text style (which would make VCS state invisible). 928 + // 929 + // Fallbacks borrow from diagnostic colors — themes that don't 930 + // define filetree-specific VCS scopes usually do define diagnostic 931 + // colors, and "warning" for modified / "error" for conflict / 932 + // "hint" for untracked maps the semantic intent reasonably. 933 + let vcs_modified_style = theme 934 + .try_get_exact("ui.filetree.vcs.modified") 935 + .or_else(|| theme.try_get_exact("warning")); 936 + let vcs_untracked_style = theme 937 + .try_get_exact("ui.filetree.vcs.untracked") 938 + .or_else(|| theme.try_get_exact("hint")); 939 + let vcs_conflict_style = theme 940 + .try_get_exact("ui.filetree.vcs.conflict") 941 + .or_else(|| theme.try_get_exact("error")); 942 + 943 + let vcs_style_for = |status: helix_view::editor::VcsStatus| match status { 944 + helix_view::editor::VcsStatus::Untracked => vcs_untracked_style, 945 + helix_view::editor::VcsStatus::Modified => vcs_modified_style, 946 + helix_view::editor::VcsStatus::Conflict => vcs_conflict_style, 947 + }; 948 + 910 949 // Hover indicator styles. Like the unfocused-cursor chain, these use 911 950 // `try_get_exact` to avoid the dotted fallback substituting the 912 951 // base text style (which would make hover invisible). ··· 980 1019 }; 981 1020 982 1021 let is_hovered = hovered_row == Some(entry_idx); 1022 + // VCS lookup. Files key directly on their path; directories 1023 + // surface the most severe status of any descendant via 1024 + // `rollup_under` so a collapsed parent dir lights up when its 1025 + // children are dirty (mirrors the open-buffer-indicator 1026 + // behavior — collapsed parents reveal something interesting 1027 + // is inside). 1028 + let vcs_status = match entry.kind { 1029 + EntryKind::File => ctx.editor.filetree.vcs_cache.lookup(&entry.path), 1030 + EntryKind::Directory { .. } => { 1031 + ctx.editor.filetree.vcs_cache.rollup_under(&entry.path) 1032 + } 1033 + }; 1034 + let vcs_patch = vcs_status.and_then(vcs_style_for); 1035 + 983 1036 // Row text style, in priority order: 984 1037 // - cursor row + focused : focused selected style 985 1038 // - cursor row + unfocused: bufferline-active style (themed 986 1039 // chain above) 987 - // - hovered row : hover row style (typically BOLD) 1040 + // - hovered row : hover row style (typically 1041 + // underline), patched onto base 1042 + // - VCS-dirty row : base patched with the per-status 1043 + // VCS style 988 1044 // - everything else : the entry's base file/dir style 989 - // Cursor wins over hover because the cursor is sticky state, 990 - // hover is mouse-position state. Selecting a row by clicking 991 - // implicitly makes it the cursor, so no row should ever be 992 - // both cursor and hover at once — but the priority ordering 993 - // makes that explicit anyway. 1045 + // Cursor wins over everything because it's sticky state; hover 1046 + // is mouse-position state; VCS is per-file state, lowest 1047 + // priority for the *text* but it still tints unhighlighted 1048 + // rows. 994 1049 let style = if is_cursor_row { 995 1050 if focused { 996 1051 selected_style ··· 999 1054 } 1000 1055 } else if is_hovered { 1001 1056 base.patch(hover_row_style) 1057 + } else if let Some(vcs) = vcs_patch { 1058 + base.patch(vcs) 1002 1059 } else { 1003 1060 base 1004 1061 };
+65
helix-term/src/ui/filetree/vcs.rs
··· 1 + //! VCS status refresh for the filetree. 2 + //! 3 + //! [`refresh`] kicks off an async iteration over the workspace's diff 4 + //! provider (currently always git, when the feature's enabled) and 5 + //! populates the panel's [`VcsCache`] as each [`FileChange`] arrives. It's 6 + //! fire-and-forget — the actual iteration runs on a tokio blocking task — 7 + //! and signals each batch of writes to the editor via 8 + //! [`helix_event::request_redraw`] so the next frame picks up the new 9 + //! statuses without waiting for an unrelated event. 10 + 11 + use std::path::PathBuf; 12 + 13 + use helix_vcs::{DiffProviderRegistry, FileChange}; 14 + use helix_view::editor::{VcsCache, VcsStatus}; 15 + 16 + /// Translate an upstream `FileChange` into our trimmed `VcsStatus`, or 17 + /// `None` for changes that don't correspond to an extant on-disk path 18 + /// (`Deleted`, and the `from_path` half of a `Renamed`). 19 + /// 20 + /// Returns the path the status applies to alongside the status, so the 21 + /// caller doesn't have to re-match the variant to figure out which path is 22 + /// the "where the file lives now" one. 23 + fn status_from(change: FileChange) -> Option<(PathBuf, VcsStatus)> { 24 + match change { 25 + FileChange::Untracked { path } => Some((path, VcsStatus::Untracked)), 26 + FileChange::Modified { path } => Some((path, VcsStatus::Modified)), 27 + FileChange::Conflict { path } => Some((path, VcsStatus::Conflict)), 28 + // The current on-disk path under a rename is `to_path`; semantically 29 + // it's a file whose content differs from HEAD, which is what 30 + // `Modified` represents visually. 31 + FileChange::Renamed { to_path, .. } => Some((to_path, VcsStatus::Modified)), 32 + // Deletions don't have a corresponding path on disk that the panel's 33 + // filesystem walker can produce, so there's nothing for the renderer 34 + // to decorate. Drop them. 35 + FileChange::Deleted { .. } => None, 36 + } 37 + } 38 + 39 + /// Kick off a fresh VCS scan rooted at `root`, writing results into 40 + /// `cache`. Old entries are cleared up front so files that no longer have 41 + /// uncommitted changes don't linger as stale decorations. 42 + /// 43 + /// Returns immediately; the scan runs on a tokio blocking task. A redraw 44 + /// is requested for each change received so the panel updates as new 45 + /// statuses arrive rather than waiting for an unrelated event. 46 + pub fn refresh(providers: DiffProviderRegistry, root: PathBuf, cache: VcsCache) { 47 + cache.clear(); 48 + providers.for_each_changed_file(root.into(), move |change| { 49 + match change { 50 + Ok(change) => { 51 + if let Some((path, status)) = status_from(change) { 52 + cache.insert(path, status); 53 + helix_event::request_redraw(); 54 + } 55 + true 56 + } 57 + // Errors from the diff provider are non-fatal — keep iterating 58 + // so a single missing or unreadable file doesn't blank the 59 + // whole cache. We deliberately don't surface the error to the 60 + // user via `Editor::set_error` because this runs on a 61 + // background task; the editor handle isn't reachable. 62 + Err(_) => true, 63 + } 64 + }); 65 + }
+180 -6
helix-view/src/editor.rs
··· 1335 1335 } 1336 1336 } 1337 1337 1338 + /// Per-file VCS status surfaced as a row decoration in the filetree. 1339 + /// 1340 + /// Simpler than `helix_vcs::FileChange` — the renderer only distinguishes 1341 + /// between three shades of "this file has uncommitted state", so the cache 1342 + /// projects the upstream enum onto this trimmed-down one. `Deleted` and 1343 + /// `Renamed::from_path` are dropped because neither corresponds to a file 1344 + /// that exists on disk (so neither shows up in the panel's walker). 1345 + /// `Renamed::to_path` collapses to [`VcsStatus::Modified`] — it's a path 1346 + /// that exists with a different content than HEAD, which is what Modified 1347 + /// means visually. 1348 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 1349 + pub enum VcsStatus { 1350 + Untracked, 1351 + Modified, 1352 + Conflict, 1353 + } 1354 + 1355 + impl VcsStatus { 1356 + /// Ordering by visual severity, ascending. Used by directory rollup to 1357 + /// pick a single representative status when several descendants have 1358 + /// different statuses. 1359 + pub fn severity(self) -> u8 { 1360 + match self { 1361 + Self::Untracked => 0, 1362 + Self::Modified => 1, 1363 + Self::Conflict => 2, 1364 + } 1365 + } 1366 + } 1367 + 1368 + /// Path-keyed cache of VCS statuses for files under the panel root. 1369 + /// 1370 + /// Populated asynchronously by 1371 + /// `helix_vcs::DiffProviderRegistry::for_each_changed_file` (iteration runs 1372 + /// on a tokio blocking task), so the cache is wrapped in `Arc<Mutex>` for 1373 + /// thread-safe access from both the background callback and the synchronous 1374 + /// render path. The renderer holds the lock for the duration of a single 1375 + /// row's lookup, which is negligible — readers and the writer don't contend 1376 + /// in any meaningful way. 1377 + #[derive(Debug, Clone, Default)] 1378 + pub struct VcsCache { 1379 + inner: Arc<std::sync::Mutex<HashMap<PathBuf, VcsStatus>>>, 1380 + } 1381 + 1382 + impl VcsCache { 1383 + /// Look up a single path. `None` means "no recorded change" — could mean 1384 + /// the file is clean, or that the cache hasn't been populated yet. 1385 + pub fn lookup(&self, path: &Path) -> Option<VcsStatus> { 1386 + self.inner.lock().unwrap().get(path).copied() 1387 + } 1388 + 1389 + /// Insert or update a single entry. Callers typically use this from the 1390 + /// VCS iteration callback. 1391 + pub fn insert(&self, path: PathBuf, status: VcsStatus) { 1392 + self.inner.lock().unwrap().insert(path, status); 1393 + } 1394 + 1395 + /// Empty the cache. Called at the start of a refresh so stale entries 1396 + /// (files that are no longer modified) don't linger. 1397 + pub fn clear(&self) { 1398 + self.inner.lock().unwrap().clear(); 1399 + } 1400 + 1401 + /// The most-severe status among descendants of `dir`. Used to color 1402 + /// directory rows when any file under them has uncommitted changes — 1403 + /// the panel still shows the directory clean if none of its descendants 1404 + /// are changed, but lights up at the highest severity found otherwise. 1405 + /// 1406 + /// `dir` itself is excluded from the scan (a directory can't be its own 1407 + /// descendant, and including it would have us colored against our own 1408 + /// row's status, which would be weird). 1409 + pub fn rollup_under(&self, dir: &Path) -> Option<VcsStatus> { 1410 + let map = self.inner.lock().unwrap(); 1411 + let mut highest: Option<VcsStatus> = None; 1412 + for (path, status) in map.iter() { 1413 + if path == dir || !path.starts_with(dir) { 1414 + continue; 1415 + } 1416 + let candidate = *status; 1417 + highest = Some(match highest { 1418 + Some(h) if h.severity() >= candidate.severity() => h, 1419 + _ => candidate, 1420 + }); 1421 + } 1422 + highest 1423 + } 1424 + } 1425 + 1338 1426 /// Runtime state of the persistent filetree panel. 1339 1427 /// 1340 1428 /// Configuration that doesn't change per-session lives on [`FiletreeConfig`]. ··· 1347 1435 /// strip, a narrow terminal yields an on-top overlay, an un-summoned panel 1348 1436 /// yields nothing at all. 1349 1437 /// 1350 - /// The interaction state (`root`, `cursor_idx`, `scroll`, `expanded`) 1351 - /// persists across visibility toggles, so a user who hides the panel and 1352 - /// summons it again finds it exactly as they left it. The actual flat entry 1353 - /// list and the directory-walking logic live in `helix-term`, derived from 1354 - /// this state plus the live filesystem. 1438 + /// The interaction state (`root`, `cursor_idx`, `scroll`, `expanded`, 1439 + /// `vcs_cache`) persists across visibility toggles, so a user who hides the 1440 + /// panel and summons it again finds it exactly as they left it. The actual 1441 + /// flat entry list and the directory-walking logic live in `helix-term`, 1442 + /// derived from this state plus the live filesystem. 1355 1443 #[derive(Debug, Default)] 1356 1444 pub struct FiletreeState { 1357 1445 /// Whether the user has summoned the panel. The panel may still render ··· 1377 1465 /// `root` + this set: a directory's children are rendered iff its path is 1378 1466 /// in here. Persists across hide/show. 1379 1467 pub expanded: HashSet<PathBuf>, 1468 + /// VCS state for files under the panel root. Populated asynchronously 1469 + /// by the filetree's VCS refresh path (in `helix-term`); queried by the 1470 + /// renderer on every row to apply per-status theming. See 1471 + /// [`VcsCache`] for the data model. 1472 + pub vcs_cache: VcsCache, 1380 1473 } 1381 1474 1382 1475 impl FiletreeState { ··· 1693 1786 cursor_idx: 0, 1694 1787 scroll: 0, 1695 1788 expanded: HashSet::new(), 1789 + vcs_cache: VcsCache::default(), 1696 1790 }, 1697 1791 } 1698 1792 } ··· 2932 3026 2933 3027 #[cfg(test)] 2934 3028 mod tests { 2935 - use super::{FiletreeRenderMode, FiletreeState}; 3029 + use super::{FiletreeRenderMode, FiletreeState, VcsCache, VcsStatus}; 2936 3030 2937 3031 /// Exhaustive table over the mode decision in 2938 3032 /// [`FiletreeState::render_mode`]. The interesting axes are: ··· 3419 3513 assert!(state.is_expanded(&path)); 3420 3514 assert!(!state.toggle_expanded(&path), "second toggle collapses"); 3421 3515 assert!(!state.is_expanded(&path)); 3516 + } 3517 + 3518 + /// [`VcsStatus::severity`] orders the three statuses by visual urgency. 3519 + /// Closed domain: enumerate. 3520 + #[test] 3521 + fn vcs_status_severity_orders_correctly() { 3522 + assert!(VcsStatus::Conflict.severity() > VcsStatus::Modified.severity()); 3523 + assert!(VcsStatus::Modified.severity() > VcsStatus::Untracked.severity()); 3524 + // Symmetry: severity itself is well-defined and stable across calls. 3525 + assert_eq!( 3526 + VcsStatus::Untracked.severity(), 3527 + VcsStatus::Untracked.severity() 3528 + ); 3529 + } 3530 + 3531 + /// [`VcsCache::lookup`] / [`VcsCache::insert`] / [`VcsCache::clear`] is 3532 + /// straight HashMap delegation, but the surface is small enough that 3533 + /// pinning the contract guards against accidental signature changes. 3534 + #[test] 3535 + fn vcs_cache_insert_lookup_clear() { 3536 + use std::path::PathBuf; 3537 + let cache = VcsCache::default(); 3538 + let path = PathBuf::from("/r/a.rs"); 3539 + assert_eq!(cache.lookup(&path), None); 3540 + 3541 + cache.insert(path.clone(), VcsStatus::Modified); 3542 + assert_eq!(cache.lookup(&path), Some(VcsStatus::Modified)); 3543 + 3544 + // Insert with same key overwrites. 3545 + cache.insert(path.clone(), VcsStatus::Conflict); 3546 + assert_eq!(cache.lookup(&path), Some(VcsStatus::Conflict)); 3547 + 3548 + cache.clear(); 3549 + assert_eq!(cache.lookup(&path), None); 3550 + } 3551 + 3552 + /// [`VcsCache::rollup_under`] is the directory-rollup function: given 3553 + /// a directory, return the most-severe status among its descendants. 3554 + /// Multiple boundary cases to pin: empty cache, no descendants, multiple 3555 + /// descendants with mixed severities, the directory's own path 3556 + /// excluded (not a descendant of itself). 3557 + #[test] 3558 + fn vcs_cache_rollup_under_picks_most_severe() { 3559 + use std::path::PathBuf; 3560 + let cache = VcsCache::default(); 3561 + let root = PathBuf::from("/r"); 3562 + 3563 + // Empty cache: nothing to roll up. 3564 + assert_eq!(cache.rollup_under(&root), None); 3565 + 3566 + // Single descendant: that descendant's status wins. 3567 + cache.insert(PathBuf::from("/r/a.rs"), VcsStatus::Untracked); 3568 + assert_eq!(cache.rollup_under(&root), Some(VcsStatus::Untracked)); 3569 + 3570 + // Add a more severe descendant: it wins. 3571 + cache.insert(PathBuf::from("/r/b.rs"), VcsStatus::Modified); 3572 + assert_eq!(cache.rollup_under(&root), Some(VcsStatus::Modified)); 3573 + 3574 + // Add an even more severe descendant: it wins. 3575 + cache.insert(PathBuf::from("/r/sub/c.rs"), VcsStatus::Conflict); 3576 + assert_eq!(cache.rollup_under(&root), Some(VcsStatus::Conflict)); 3577 + 3578 + // A *non*-descendant doesn't influence the rollup. 3579 + cache.insert(PathBuf::from("/elsewhere/big.rs"), VcsStatus::Conflict); 3580 + let sub = PathBuf::from("/r/sub"); 3581 + // /r/sub's rollup considers only files under /r/sub. 3582 + assert_eq!(cache.rollup_under(&sub), Some(VcsStatus::Conflict)); 3583 + // /elsewhere isn't a descendant of /r, so a lower-severity rollup 3584 + // of /r/a.rs and /r/b.rs is unaffected by /elsewhere/big.rs. 3585 + 3586 + // A directory's own path is *not* considered its own descendant — 3587 + // important so the rollup doesn't double-count when the cache 3588 + // happens to contain a directory path itself. 3589 + let cache = VcsCache::default(); 3590 + cache.insert(PathBuf::from("/r"), VcsStatus::Conflict); 3591 + assert_eq!( 3592 + cache.rollup_under(&PathBuf::from("/r")), 3593 + None, 3594 + "directory's own path is excluded from its own rollup" 3595 + ); 3422 3596 } 3423 3597 }
+11
runtime/themes/darcula-solid.toml
··· 22 22 # to grey04 and visually merges with the context menu's highlight. 23 23 "ui.filetree.selected" = { bg = "grey03" } 24 24 25 + # Filetree VCS status decorations. Fg-only so the per-row bg from the 26 + # panel/cursor/hover highlights stays in charge — these tint only the 27 + # entry name, never the row band. Severity-mapped colors borrowed from 28 + # the parent darcula palette so they match the rest of the theme: 29 + # orange for modified (the same shade darcula uses for warnings), 30 + # green for untracked (a "newly arrived, not yet recorded" connotation), 31 + # the ANSI-named red for conflicts (matching darcula's "error" mapping). 32 + "ui.filetree.vcs.modified" = { fg = "orange" } 33 + "ui.filetree.vcs.untracked" = { fg = "green" } 34 + "ui.filetree.vcs.conflict" = { fg = "red" } 35 + 25 36 [palette] 26 37 grey00 = "#101010" 27 38 grey01 = "#1f1f1f"