Merge remote-tracking branch 'origin/main' into fix/fallback-glyph-scale

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
l0ng-ai
2026-09-25 10:26:22 +02:00
20 changed files with 958 additions and 22 deletions
+15
View File
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **The source control panel's changed files can be filtered and shown as a
tree** (#473). A filter field sits above the list: every word typed has to
appear somewhere in a file's path, in any order and any case, and the group
headers' counts and their stage/unstage/discard-all buttons follow what the
filter leaves on screen. The tile at its end switches the list to a
directory tree — directories before files, a chain of directories that hold
nothing but the next one compacted into a single row (`crates/core/src`),
each directory row foldable and counting the files beneath it. A filter
opens every folded directory while it is set. The choice between list and
tree is remembered in the config. Both are built from the status the host
already sends, so a remote repository gets them unchanged. Searching the
diff text itself is not part of this.
### Fixed
- **Nerd Font icons from a fallback font come out at the text's size** (#866).
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg>

After

Width:  |  Height:  |  Size: 311 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12h-8"/><path d="M21 6H8"/><path d="M21 18h-8"/><path d="M3 6v4c0 1.1.9 2 2 2h3"/><path d="M3 10v6c0 1.1.9 2 2 2h3"/></svg>

After

Width:  |  Height:  |  Size: 320 B

+7
View File
@@ -246,6 +246,12 @@ pub struct Config {
/// impression than one they asked for.
#[serde(default)]
pub scm_graph_expanded: bool,
/// Whether the source control panel lists changed files as a directory
/// tree rather than one flat run per group. Off by default: the flat list
/// is what a handful of changes reads best as, and the tree is for the
/// agent run that touched sixty files across a dozen modules.
#[serde(default)]
pub scm_changes_tree: bool,
/// Whether a file opens in the code panel with soft wrap on. Not a
/// setting anyone picks up front: it is whatever the status bar's Wrap
/// toggle (or `ToggleDocumentWrap`) was last left at, so the next file
@@ -676,6 +682,7 @@ impl Default for Config {
document_layout: DocumentLayout::default(),
document_ratio: default_document_ratio(),
scm_graph_expanded: false,
scm_changes_tree: false,
editor_soft_wrap: false,
editor_markdown_preview: false,
sidebar_grouping: SidebarGrouping::Repo,
+44 -1
View File
@@ -3057,6 +3057,14 @@ fn apply_agent_signals(
let before = st.agent_session.clone();
for event in &events {
// A hook names the agent that emitted it. Once this pane has a
// foreground agent, a different one's session id must not land here:
// omp forking with a Claude UUID is "session not found".
if let (Some(current), Some(from)) = (st.agent, event.agent)
&& current != from
{
continue;
}
if st.agent.is_none() && event.agent.is_some() {
st.agent = event.agent;
notify(st, DaemonMsg::Agent(st.agent));
@@ -3183,7 +3191,9 @@ fn apply_agent(
stamp_launch_argv(st, argv);
return;
}
if agent.is_none() && st.agent_session.is_some() {
// The session belongs to whoever was in the foreground. Switching from
// Claude to omp (or back to the shell) must not keep the previous id.
if st.agent_session.is_some() {
st.agent_session = None;
notify(st, DaemonMsg::AgentStatus(None));
}
@@ -5378,6 +5388,39 @@ mod tests {
assert!(matches!(rx.try_recv(), Ok(DaemonMsg::Agent(None))));
}
#[test]
fn a_foreign_agent_hook_does_not_replace_the_foreground_session() {
use crate::core::cli_agent::CLIAgent;
let mut st = test_state(true);
let mut sniffer = OscSniffer::new();
let omp = concat!(
"\x1b]777;notify;tty7://cli-agent;",
r#"{"v":1,"agent":"omp","event":"session-start","session_id":"01a0d21f-2f79-72e0-bb16-d6d908aaa6e0"}"#,
"\x07",
);
apply_signals(&mut st, sniffer.feed(omp.as_bytes()));
assert_eq!(st.agent, Some(CLIAgent::OhMyPi));
let claude = concat!(
"\x1b]777;notify;tty7://cli-agent;",
r#"{"v":1,"agent":"claude","event":"session-start","session_id":"e18a20ff-4c8c-4b94-867b-dd4b79032a6c"}"#,
"\x07",
);
apply_signals(&mut st, sniffer.feed(claude.as_bytes()));
assert_eq!(
st.agent_session.as_ref().unwrap().session_id.as_deref(),
Some("01a0d21f-2f79-72e0-bb16-d6d908aaa6e0"),
"a Claude hook on an omp pane must not steal the session id"
);
apply_agent(&mut st, Some((CLIAgent::Claude, vec!["claude".into()])));
assert!(
st.agent_session.is_none(),
"switching the foreground agent drops the previous session"
);
}
#[test]
fn opaque_notifications_only_fall_back_when_no_rich_state() {
use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent};
+74 -17
View File
@@ -225,23 +225,15 @@ fn walk(table: &HashMap<u32, Row>, shell_pid: u32, fg_pgid: Option<i32>) -> Vec<
#[cfg(target_os = "macos")]
fn process_table() -> HashMap<u32, Row> {
let mut table = HashMap::new();
let bytes = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) };
if bytes <= 0 {
return table;
}
let cap = (bytes as usize / std::mem::size_of::<libc::c_int>()) + 64;
let mut pids = vec![0 as libc::c_int; cap];
let written = unsafe {
libc::proc_listallpids(
pids.as_mut_ptr() as *mut libc::c_void,
(cap * std::mem::size_of::<libc::c_int>()) as libc::c_int,
)
};
if written <= 0 {
return table;
}
let n = written as usize / std::mem::size_of::<libc::c_int>();
for &pid in pids.iter().take(n.min(cap)) {
let pids = list_all_pids(|buf| {
let ptr = match buf.is_empty() {
true => std::ptr::null_mut(),
false => buf.as_mut_ptr() as *mut libc::c_void,
};
let bytes = std::mem::size_of_val(buf) as libc::c_int;
unsafe { libc::proc_listallpids(ptr, bytes) }
});
for &pid in &pids {
if pid <= 0 {
continue;
}
@@ -276,6 +268,37 @@ fn process_table() -> HashMap<u32, Row> {
table
}
/// Every pid on the machine, by way of `list`, which has the calling
/// convention of libproc's `proc_listallpids`: handed a buffer, it fills as
/// much of it as it can, and it answers with a *count of pids* — not a byte
/// count, although the size it is given is in bytes. An empty buffer asks
/// only for the count, padded by the kernel for processes born in between.
///
/// Reading that count as bytes was #731. It divided the answer by four twice
/// over, once sizing the buffer and once reading back how much was filled, so
/// only about one pid in sixteen made it into the table — and since the kernel
/// lists its newest processes first, the ones kept were whatever had started
/// most recently. A `go run` server just launched was in; the pane's shell,
/// started long before, was not, and a walk from a root the table has never
/// heard of comes back empty: no processes, no ports, and nothing to say why.
/// A process tree built moments earlier, which is what a quick check of the
/// probe builds, is exactly the case that happened to work.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn list_all_pids(mut list: impl FnMut(&mut [i32]) -> i32) -> Vec<i32> {
let count = list(&mut []);
if count <= 0 {
return Vec::new();
}
let mut pids = vec![0; count as usize + 64];
let written = list(&mut pids);
if written <= 0 {
return Vec::new();
}
pids.truncate(written as usize);
pids.retain(|&pid| pid > 0);
pids
}
#[cfg(target_os = "macos")]
fn cstr_field(buf: &[libc::c_char]) -> String {
let bytes: Vec<u8> = buf
@@ -968,6 +991,40 @@ mod tests {
);
}
/// `proc_listallpids` as libproc implements it: the kernel's newest
/// processes first, a padded estimate when asked with no buffer, and
/// always a count of pids back, never bytes.
fn fake_listallpids(live: &[i32]) -> impl FnMut(&mut [i32]) -> i32 + '_ {
move |buf: &mut [i32]| {
if buf.is_empty() {
return live.len() as i32 + 20;
}
let n = live.len().min(buf.len());
buf[..n].copy_from_slice(&live[..n]);
n as i32
}
}
/// #731 on macOS: a busy Mac has hundreds of processes, and the pane's
/// shell is an old one. Every pid has to reach the table, or the walk
/// starts from a root it cannot find and the pane shows no processes and
/// no ports at all.
#[test]
fn every_pid_is_listed_not_the_newest_sixteenth() {
// Newest first, the way the kernel lists them: the `go run` server
// just started, and the shell it runs under from an hour ago.
let live: Vec<i32> = (1..=700).rev().collect();
let got = list_all_pids(fake_listallpids(&live));
assert_eq!(got.len(), 700, "the whole machine, not the newest few");
assert!(got.contains(&1), "the oldest process is still there");
}
#[test]
fn a_pid_list_that_cannot_be_read_is_empty() {
assert!(list_all_pids(|_| -1).is_empty());
assert!(list_all_pids(|_| 0).is_empty());
}
#[test]
fn a_pathological_tree_still_stops() {
let mut table: HashMap<u32, Row> = [(100, row(1, "zsh"))].into_iter().collect();
+1
View File
@@ -705,6 +705,7 @@ fn main() {
.detach();
gpui_component::init(cx);
register_bundled_fonts(cx);
crate::ui::prompt::install(cx);
cx.activate(true);
#[cfg(target_os = "macos")]
set_dock_icon_for_bare_binary();
+4
View File
@@ -47,6 +47,8 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> {
"icons/machine-local.svg" => include_bytes!("../../assets/icons/machine-local.svg"),
"icons/machine-remote.svg" => include_bytes!("../../assets/icons/machine-remote.svg"),
"icons/refresh.svg" => include_bytes!("../../assets/icons/refresh.svg"),
"icons/list-tree.svg" => include_bytes!("../../assets/icons/list-tree.svg"),
"icons/list-flat.svg" => include_bytes!("../../assets/icons/list-flat.svg"),
"icons/agents/claude.svg" => include_bytes!("../../assets/icons/agents/claude.svg"),
"icons/agents/codex.svg" => include_bytes!("../../assets/icons/agents/codex.svg"),
"icons/agents/traecli.svg" => include_bytes!("../../assets/icons/agents/traecli.svg"),
@@ -114,6 +116,8 @@ mod tests {
"icons/git-branch.svg",
"icons/git-sync.svg",
"icons/git-commit.svg",
"icons/list-tree.svg",
"icons/list-flat.svg",
] {
assert!(
Assets.load(path).unwrap().is_some(),
+4
View File
@@ -1216,6 +1216,10 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::ScmShowMore => "Show more",
L10nKey::ScmShowLess => "Show less",
L10nKey::ScmCommitNotFound => "This commit is not in this repository.",
L10nKey::ScmFilterChanges => "Filter changed files…",
L10nKey::ScmViewAsTree => "View as Tree",
L10nKey::ScmViewAsList => "View as List",
L10nKey::ScmNoMatchingChanges => "No changed files match the filter.",
L10nKey::ScmTooManyChanges => "Showing the first {shown} of {total} changes.",
L10nKey::ScmOpenChanges => "Open Changes",
L10nKey::ScmDiscardAllConfirm => {
+4
View File
@@ -1290,6 +1290,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::ScmShowMore => "続きを表示",
L10nKey::ScmShowLess => "折りたたむ",
L10nKey::ScmCommitNotFound => "このリポジトリにそのコミットはありません。",
L10nKey::ScmFilterChanges => "変更ファイルを絞り込む…",
L10nKey::ScmViewAsTree => "ツリーで表示",
L10nKey::ScmViewAsList => "リストで表示",
L10nKey::ScmNoMatchingChanges => "条件に一致する変更ファイルはありません。",
L10nKey::ScmTooManyChanges => {
"変更が多いため、{total} 件のうち先頭 {shown} 件のみ表示しています。"
}
+4
View File
@@ -913,6 +913,10 @@ l10n_keys! {
ScmShowLess,
ScmCommitNotFound,
ScmTooManyChanges,
ScmFilterChanges,
ScmViewAsTree,
ScmViewAsList,
ScmNoMatchingChanges,
ScmOpenChanges,
ScmDiscardAllConfirm,
ScmAmendConfirm,
+4
View File
@@ -1156,6 +1156,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::ScmShowMore => "展开",
L10nKey::ScmShowLess => "收起",
L10nKey::ScmCommitNotFound => "本仓库中没有这个提交。",
L10nKey::ScmFilterChanges => "筛选变更文件…",
L10nKey::ScmViewAsTree => "以树形显示",
L10nKey::ScmViewAsList => "以列表显示",
L10nKey::ScmNoMatchingChanges => "没有匹配筛选的变更文件。",
L10nKey::ScmTooManyChanges => "改动过多,仅显示前 {shown} 项(共 {total} 项)。",
L10nKey::ScmOpenChanges => "查看改动",
L10nKey::ScmDiscardAllConfirm => {
+1
View File
@@ -27,6 +27,7 @@ pub mod pending_pane;
pub mod perf;
pub mod prefill;
pub mod presets;
pub mod prompt;
pub mod remote_connect;
pub mod remote_workspace;
pub mod reorder;
+180
View File
@@ -0,0 +1,180 @@
//! The confirmation dialog on platforms with no native one.
//!
//! macOS and Windows answer `window.prompt` with NSAlert and TaskDialog. Linux
//! has neither, so gpui falls back to a renderer of its own — and that one sets
//! the message and the detail as single unbreakable lines inside a fixed-width
//! box that clips its overflow. Any sentence longer than the box is cut off
//! mid-word, which for the quit-and-stop warning is exactly the part that says
//! what will be lost (#920). This is the same dialog laid out as a card that
//! wraps its text, in the app's own surfaces, with Return and Escape answering
//! the way they do on the native dialogs.
use gpui::{
App, Context, EventEmitter, FocusHandle, Focusable, PromptButton, PromptHandle, PromptLevel,
PromptResponse, RenderablePromptHandle, Window, div, prelude::*, px, relative,
};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::{ActiveTheme as _, Sizable as _, h_flex, v_flex};
/// Route every `window.prompt` through [`TextPrompt`] where the platform has
/// no dialog of its own to show.
pub(crate) fn install(cx: &mut App) {
if cfg!(any(target_os = "macos", target_os = "windows")) {
return;
}
cx.set_prompt_builder(build);
}
fn build(
_level: PromptLevel,
message: &str,
detail: Option<&str>,
answers: &[PromptButton],
handle: PromptHandle,
window: &mut Window,
cx: &mut App,
) -> RenderablePromptHandle {
let prompt = cx.new(|cx| TextPrompt::new(message, detail, answers, cx));
handle.with_view(prompt, window, cx)
}
pub(crate) struct TextPrompt {
message: String,
detail: Option<String>,
answers: Vec<PromptButton>,
focus: FocusHandle,
}
impl TextPrompt {
fn new(
message: &str,
detail: Option<&str>,
answers: &[PromptButton],
cx: &mut Context<Self>,
) -> Self {
Self {
message: message.to_string(),
detail: detail.map(str::to_string),
answers: answers.to_vec(),
focus: cx.focus_handle(),
}
}
/// The answer a key picks. Return is answer 0 — `confirm_answers` puts the
/// action there for exactly this — and Escape is the one marked cancel;
/// a prompt with no cancel answer has nothing Escape can safely mean.
fn answer_for_key(&self, key: &str) -> Option<usize> {
match key {
"enter" if !self.answers.is_empty() => Some(0),
"escape" => self.answers.iter().position(PromptButton::is_cancel),
_ => None,
}
}
}
impl EventEmitter<PromptResponse> for TextPrompt {}
impl Focusable for TextPrompt {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus.clone()
}
}
impl Render for TextPrompt {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let muted = cx.theme().muted_foreground;
// Answer 0 goes rightmost, where the native dialogs put it and where
// `confirm_answers` expects it to land.
let buttons = self.answers.iter().enumerate().rev().map(|(ix, answer)| {
Button::new(("prompt-answer", ix))
.label(answer.label().clone())
.small()
.when(ix == 0 && !answer.is_cancel(), |b| b.primary())
.on_click(cx.listener(move |_, _, _, cx| {
cx.emit(PromptResponse(ix));
cx.stop_propagation();
}))
});
let card = v_flex()
.occlude()
.w(px(420.))
.max_w(relative(0.9))
.gap_3()
.p_5()
.map(|card| crate::ui::theme::floating_surface(card, cx))
.child(
div()
.text_sm()
.font_weight(gpui::FontWeight::SEMIBOLD)
.child(self.message.clone()),
)
.children(
self.detail
.clone()
.map(|detail| div().text_sm().text_color(muted).child(detail)),
)
.child(
h_flex()
.pt_2()
.justify_end()
.flex_wrap()
.gap_2()
.children(buttons),
);
div()
.id("text-prompt")
.track_focus(&self.focus)
.size_full()
.cursor_default()
.bg(crate::ui::presets::scrim_fill(cx))
.on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, _, cx| {
if let Some(ix) = this.answer_for_key(&ev.keystroke.key) {
cx.emit(PromptResponse(ix));
cx.stop_propagation();
}
}))
.flex()
.items_center()
.justify_center()
.child(card)
}
}
#[cfg(test)]
mod tests {
use super::TextPrompt;
use gpui::{AppContext as _, PromptButton, TestAppContext};
fn prompt(cx: &mut TestAppContext, answers: &[PromptButton]) -> gpui::Entity<TextPrompt> {
cx.update(|cx| {
cx.new(|cx| {
TextPrompt::new(
"Quit and stop tty7 server?",
Some("a detail long enough to need more than one line"),
answers,
cx,
)
})
})
}
#[gpui::test]
fn return_confirms_and_escape_takes_the_cancel_answer(cx: &mut TestAppContext) {
let p = prompt(cx, &crate::ui::confirm_answers("Quit", "Cancel"));
p.read_with(cx, |p, _| {
assert_eq!(p.answer_for_key("enter"), Some(0));
assert_eq!(p.answer_for_key("escape"), Some(1));
assert_eq!(p.answer_for_key("a"), None);
});
}
#[gpui::test]
fn escape_does_nothing_without_a_cancel_answer(cx: &mut TestAppContext) {
let p = prompt(cx, &[PromptButton::ok("OK"), PromptButton::new("Retry")]);
p.read_with(cx, |p, _| {
assert_eq!(p.answer_for_key("escape"), None);
});
}
}
+14
View File
@@ -729,6 +729,18 @@ impl Tty7App {
input: &gpui::Entity<gpui_component::input::InputState>,
cx: &mut Context<Self>,
) -> AnyElement {
self.panel_search_with(input, None, cx)
}
/// [`Self::panel_search`] with a tile at its far end, inset the way the
/// branch row's tile is so the two stack in one column.
pub(crate) fn panel_search_with(
&self,
input: &gpui::Entity<gpui_component::input::InputState>,
trailing: Option<AnyElement>,
cx: &mut Context<Self>,
) -> AnyElement {
let has_trailing = trailing.is_some();
h_flex()
.flex_none()
.items_center()
@@ -753,6 +765,8 @@ impl Tty7App {
// still reads as one line of chrome.
.child(Input::new(input).appearance(false).xsmall().cleanable(true)),
)
.when(has_trailing, |row| row.pr(px(tile_trailing_inset_sm())))
.children(trailing)
.into_any_element()
}
+10
View File
@@ -71,6 +71,16 @@ impl Tty7App {
cx.notify();
}
/// Switch the changed-file list between flat and tree, and remember it.
///
/// Global like `diff_view`: someone who reads changes by directory reads
/// every repository's that way.
pub(crate) fn scm_toggle_changes_tree(&mut self, cx: &mut Context<Self>) {
let next = !cx.global::<crate::core::config::Config>().scm_changes_tree;
self.update_config(cx, |cfg| cfg.scm_changes_tree = next);
cx.notify();
}
/// Flip the diff overlay between side-by-side and unified.
///
/// Global rather than per-overlay, matching `diffEditor.renderSideBySide`:
+1
View File
@@ -14,6 +14,7 @@ pub(crate) mod path;
pub(crate) mod state;
#[allow(dead_code)]
pub(crate) mod status;
pub(crate) mod tree;
pub(crate) use actions::ScmIntent;
pub(crate) use state::{GraphState, ScmPanelState};
+275 -4
View File
@@ -39,6 +39,7 @@ use crate::ui::scm::ScmIntent;
use crate::ui::scm::path::split_display_path;
use crate::ui::scm::state::{RepoKey, ScmGroup};
use crate::ui::scm::status::{status_color, status_glyph};
use crate::ui::scm::tree::{TreeRow, normalize_query, path_matches, tree_rows};
/// Shared compact pitch for working-tree and commit-detail file rows.
pub(super) const ROW_H: f32 = 26.;
@@ -52,6 +53,10 @@ pub(super) const ROW_H: f32 = 26.;
/// different numbers.
const BADGE_W: f32 = 14.;
/// One level of the tree view, the file tree's step: the Files panel and this
/// one draw the same directories, and the same depth should read the same.
const TREE_INDENT: f32 = 14.;
/// The key context the message box installs, and the one `ScmCommit` is
/// bound inside. The two are the same string on purpose: a binding whose
/// context nothing attaches is a binding that never fires.
@@ -236,7 +241,12 @@ impl Tty7App {
Some(t(L10nKey::PanelNoChangesHint)),
cx,
),
None => self.scm_groups(&repo, &status, cx),
None => {
// Pinned with the rest, so the filter stays in reach however
// far down the list has been scrolled.
pinned.push(self.scm_filter_row(window, cx));
self.scm_groups(&repo, &status, cx)
}
};
let history = self.render_graph_section(&repo, window, cx);
self.scm_shell_full(title, pinned, body, history)
@@ -1291,24 +1301,63 @@ impl Tty7App {
status: &Arc<WorkingTreeStatus>,
cx: &mut Context<Self>,
) -> AnyElement {
let query = self.scm_filter_query(cx);
let tree = cx.global::<crate::core::config::Config>().scm_changes_tree;
let mut matched = 0;
let mut list = v_flex().px(px(CONTENT_INSET - ROW_INSET)).py(px(2.));
for group in ScmGroup::ORDER {
// Filtered before anything else reads it, so the header's count
// and its stage/discard-all act on what is on screen — a
// "discard all" that also reached the files the filter hid would
// be the one surprise this panel cannot take back.
let entries: Vec<&StatusEntry> = status
.entries
.iter()
.filter(|e| in_group(e, group))
.filter(|e| {
query
.as_deref()
.is_none_or(|q| path_matches(e.path.as_str(), q))
})
.collect();
if entries.is_empty() {
continue;
}
matched += entries.len();
let collapsed = self.scm.group_collapsed(group, entries.len());
list = list.child(self.scm_group_header(repo, group, &entries, collapsed, cx));
if collapsed {
continue;
}
let shown = entries.len().min(MAX_RENDERED_FILES);
for entry in entries.iter().take(shown) {
list = list.child(self.scm_file_row(repo, group, entry, cx));
if tree {
// A filter opens every directory: a match folded away under
// a closed row is a match the filter failed to show.
let filtering = query.is_some();
let folded = &self.scm.folded_dirs;
let rows = tree_rows(entries[..shown].iter().map(|e| e.path.as_str()), |key| {
!filtering && folded.contains(&(group, key.to_string()))
});
for row in rows {
list = list.child(match row {
TreeRow::Dir {
key,
label,
depth,
files,
collapsed,
} => self.scm_dir_row(
group, key, label, depth, files, collapsed, !filtering, cx,
),
TreeRow::File { index, depth } => {
self.scm_file_row(repo, group, entries[index], Some(depth), cx)
}
});
}
} else {
for entry in entries.iter().take(shown) {
list = list.child(self.scm_file_row(repo, group, entry, None, cx));
}
}
if entries.len() > shown {
list = list.child(self.scm_note(
@@ -1317,6 +1366,9 @@ impl Tty7App {
));
}
}
if query.is_some() && matched == 0 {
list = list.child(self.scm_note(t(L10nKey::ScmNoMatchingChanges).to_string(), cx));
}
if status.truncated {
list = list.child(self.scm_note(
t_fmt(
@@ -1332,6 +1384,142 @@ impl Tty7App {
list.into_any_element()
}
/// The filter over the changed files, with the list/tree switch at its
/// end.
///
/// A standing field, like the Files panel's, rather than one behind a
/// tile like the history's: the list is what this panel is for, and a
/// run that touched sixty files is exactly when nobody wants to go
/// looking for how to narrow it.
fn scm_filter_row(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let input = match self.scm.filter.clone() {
Some(input) => input,
None => {
let input = cx.new(|cx| {
InputState::new(window, cx).placeholder(t(L10nKey::ScmFilterChanges))
});
// An `InputState` is its own entity; without this the list
// would never hear about the text typed into it.
self.scm.filter_sub =
Some(
cx.subscribe_in(&input, window, |_this, _input, ev, _window, cx| {
if matches!(ev, gpui_component::input::InputEvent::Change) {
cx.notify();
}
}),
);
self.scm.filter = Some(input.clone());
input
}
};
let tree = cx.global::<crate::core::config::Config>().scm_changes_tree;
// The tile shows where it goes, not where you are — the rows under it
// already say which view this is.
let (glyph, tip) = match tree {
true => ("icons/list-flat.svg", t(L10nKey::ScmViewAsList)),
false => ("icons/list-tree.svg", t(L10nKey::ScmViewAsTree)),
};
let toggle = crate::ui::tab_strip::chrome_tile_sized(
Button::new("scm-changes-view").icon(Icon::empty().path(glyph)),
crate::ui::app::TILE_SIZE_SM,
crate::ui::app::TILE_GLYPH_SM,
false,
cx,
)
.rounded_md()
.tooltip(tip)
.on_click(cx.listener(|this, _, _window, cx| this.scm_toggle_changes_tree(cx)))
.into_any_element();
self.panel_search_with(&input, Some(toggle), cx)
}
/// The filter box's text, if it has any.
fn scm_filter_query(&self, cx: &gpui::App) -> Option<String> {
normalize_query(&self.scm.filter.as_ref()?.read(cx).value())
}
/// A directory in the tree view: a chevron in the status letters'
/// column, the (possibly compacted) name, and how many changed files
/// are under it.
#[allow(clippy::too_many_arguments)]
fn scm_dir_row(
&self,
group: ScmGroup,
key: String,
label: String,
depth: usize,
files: usize,
collapsed: bool,
foldable: bool,
cx: &mut Context<Self>,
) -> AnyElement {
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
let mono = cx.theme().mono_font_family.clone();
let muted = cx.theme().muted_foreground;
let id = SharedString::from(format!("scm-dir-{group:?}-{key}"));
let selector = id.clone();
let toggle = cx.listener({
let key = key.clone();
move |this, _, _window, cx| {
this.scm.toggle_dir(group, key.clone());
cx.notify();
}
});
h_flex()
.id(id)
.debug_selector(move || selector.to_string())
.items_center()
.gap(px(8.))
.min_h(rems(ROW_H / 16.))
.w_full()
.min_w_0()
.px(px(ROW_INSET))
.pl(px(ROW_INSET + depth as f32 * TREE_INDENT))
.rounded(px(5.))
.when(foldable, |row| {
row.cursor_pointer()
.hover(|s| s.bg(gpui::rgb(sf.hover)))
.on_click(toggle)
})
.tooltip(move |window, cx| {
gpui_component::tooltip::Tooltip::new(key.clone()).build(window, cx)
})
.child(
div()
.flex_none()
.w(px(BADGE_W))
.flex()
.justify_center()
.text_color(muted)
.child(
Icon::new(if collapsed {
IconName::ChevronRight
} else {
IconName::ChevronDown
})
.xsmall(),
),
)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_size(rems(crate::ui::right_panel::TEXT))
.text_color(gpui::rgb(sf.text_resting))
.child(label),
)
.child(
div()
.flex_none()
.text_size(rems(META_MONO))
.font_family(mono)
.text_color(muted)
.child(files.to_string()),
)
.into_any_element()
}
/// "…and 40 more", and the note about a status git had to truncate.
///
/// Secondary: it is prose about the list rather than a row of it, and the
@@ -1413,11 +1601,15 @@ impl Tty7App {
.into_any_element()
}
/// One changed file. `depth` is its level in the tree view, and `None` in
/// the flat list — where the row carries its directory beside the name,
/// since there is no directory row above it to say it.
fn scm_file_row(
&self,
repo: &RepoKey,
group: ScmGroup,
entry: &StatusEntry,
depth: Option<usize>,
cx: &mut Context<Self>,
) -> AnyElement {
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
@@ -1438,8 +1630,10 @@ impl Tty7App {
cx,
);
let selector = id.clone();
h_flex()
.id(id.clone())
.debug_selector(move || selector.to_string())
.group(id)
.relative()
.items_center()
@@ -1448,6 +1642,9 @@ impl Tty7App {
.w_full()
.min_w_0()
.px(px(ROW_INSET))
.when_some(depth, |row, depth| {
row.pl(px(ROW_INSET + depth as f32 * TREE_INDENT))
})
.py(px(3.))
.rounded(px(5.))
.cursor_pointer()
@@ -1503,7 +1700,7 @@ impl Tty7App {
// Cap the secondary directory while the filename fills the rest.
// A rem cap also gives short paths their intrinsic width inside
// the context-menu wrapper's flex layout.
.when(!dir.is_empty(), |this| {
.when(!dir.is_empty() && depth.is_none(), |this| {
this.child(
div()
.flex_none()
@@ -3012,6 +3209,80 @@ mod render_idle_gpui_tests {
assert_eq!(draws_while_idle(&mut vcx), 0);
let _ = std::fs::remove_dir_all(&root);
}
/// The tree view and the filter, end to end over a real repository: the
/// rows that come out are the ones the pure helpers promise, drawn.
#[gpui::test]
fn the_changes_list_folds_into_a_tree_and_filters_by_path(cx: &mut TestAppContext) {
let _serial = serial();
crate::core::config::pin_test_config_dir();
let root = scratch("tree");
git(&root, &["init", "--quiet"]);
std::fs::create_dir_all(root.join("src/ui")).unwrap();
std::fs::create_dir_all(root.join("docs")).unwrap();
std::fs::write(root.join("src/ui/a.rs"), "a\n").unwrap();
std::fs::write(root.join("src/ui/b.rs"), "b\n").unwrap();
std::fs::write(root.join("docs/readme.md"), "r\n").unwrap();
// Keyed off the root the panel resolved, not off `root`: on Windows
// `scratch` hands back a `\\?\` verbatim path that never compares
// equal to git's spelling of the same directory — see the note on
// `a_settled_source_control_panel_reaches_render_idle`.
let (app, mut vcx, _pane) = scm_panel_on(cx, &root, |app, cx| {
app.scm.repo.as_ref().is_some_and(|r| {
crate::terminal::git_data::status_of(cx, r.host, &r.root)
.is_some_and(|s| s.untracked().count() == 3)
})
});
let drawn = |vcx: &mut VisualTestContext, what: &'static str| {
app.update_in(vcx, |_, _, cx| cx.notify());
vcx.run_until_parked();
vcx.debug_bounds(what).is_some()
};
let a = "scm-row-Untracked-src/ui/a.rs";
let readme = "scm-row-Untracked-docs/readme.md";
let src_ui = "scm-dir-Untracked-src/ui";
// The flat list is the default, and it has no directory rows.
assert!(drawn(&mut vcx, a));
assert!(!drawn(&mut vcx, src_ui));
// `src` holds nothing but `ui`, so the two share one row.
app.update(&mut vcx, |app, cx| app.scm_toggle_changes_tree(cx));
assert!(vcx.update(|_, cx| cx.global::<crate::core::config::Config>().scm_changes_tree));
assert!(drawn(&mut vcx, src_ui));
assert!(!drawn(&mut vcx, "scm-dir-Untracked-src"));
assert!(drawn(&mut vcx, "scm-dir-Untracked-docs"));
assert!(drawn(&mut vcx, a));
// Folding the directory keeps its row and hides what is under it.
app.update(&mut vcx, |app, cx| {
app.scm.toggle_dir(ScmGroup::Untracked, "src/ui".into());
cx.notify();
});
assert!(drawn(&mut vcx, src_ui));
assert!(!drawn(&mut vcx, a));
assert!(drawn(&mut vcx, readme));
// A filter drops what does not match — and opens the folded
// directory the match is in.
app.update_in(&mut vcx, |app, window, cx| {
let input = app.scm.filter.clone().expect("the filter row was drawn");
input.update(cx, |state, cx| state.set_value("UI A", window, cx));
});
assert!(drawn(&mut vcx, a));
assert!(!drawn(&mut vcx, "scm-row-Untracked-src/ui/b.rs"));
assert!(!drawn(&mut vcx, readme));
assert!(!drawn(&mut vcx, "scm-dir-Untracked-docs"));
// The same filter narrows the flat list too.
app.update(&mut vcx, |app, cx| app.scm_toggle_changes_tree(cx));
assert!(drawn(&mut vcx, a));
assert!(!drawn(&mut vcx, readme));
assert!(!drawn(&mut vcx, src_ui));
let _ = std::fs::remove_dir_all(&root);
}
}
/// The action strip is the one place in the panel where a hover reveal sits
+16
View File
@@ -96,6 +96,14 @@ pub(crate) struct ScmPanelState {
/// opening one by hand has to outlast the next file landing in it.
pub(crate) collapsed: HashSet<ScmGroup>,
pub(crate) toggled: HashSet<ScmGroup>,
/// Directories folded shut in the tree view, per group — the same
/// directory can be open under Changes and shut under Staged. Keyed by
/// the full repo-relative path of the (possibly compacted) row.
pub(crate) folded_dirs: HashSet<(ScmGroup, String)>,
/// The filter over the changed files. Created on first render like
/// `commit_input`, with the subscription that turns typing into a repaint.
pub(crate) filter: Option<Entity<InputState>>,
pub(crate) filter_sub: Option<gpui::Subscription>,
/// Working directory → the repository root containing it, or `None` when
/// there is none, with when the answer was given. The root is what every
/// write runs from and what every cache is keyed by, so it is resolved
@@ -165,6 +173,14 @@ impl ScmPanelState {
}
}
/// Fold a tree directory shut, or open it again.
pub(crate) fn toggle_dir(&mut self, group: ScmGroup, key: String) {
let entry = (group, key);
if !self.folded_dirs.remove(&entry) {
self.folded_dirs.insert(entry);
}
}
pub(crate) fn set_group_collapsed(&mut self, group: ScmGroup, collapsed: bool) {
self.toggled.insert(group);
if collapsed {
+298
View File
@@ -0,0 +1,298 @@
//! The changed-file list as a tree, and the filter both views share.
//!
//! Pure, and over plain paths rather than `StatusEntry`: the panel hands in
//! one group's paths and gets back rows that point into that same slice, so
//! the file rows it draws are the ones the flat list would have drawn — same
//! letter, same click, same context menu — only indented under a directory.
//!
//! Everything here comes out of the status the host already sent. A remote
//! repository gets the same tree without a single extra read of its disk.
use std::collections::BTreeMap;
/// One line of the tree view.
#[derive(Clone, PartialEq, Eq, Debug)]
pub(crate) enum TreeRow {
/// A directory, possibly several compacted into one: `src/ui/scm` when
/// `src` and `ui` hold nothing but the next directory down. `key` is the
/// full repo-relative path, which is what the fold state is keyed by;
/// `label` is what the row reads, relative to its parent row.
Dir {
key: String,
label: String,
depth: usize,
/// Every file underneath, at any depth — what a folded row still
/// owes the reader.
files: usize,
collapsed: bool,
},
/// A file, by its position in the paths the tree was built from.
File { index: usize, depth: usize },
}
#[derive(Default)]
struct Node<'a> {
dirs: BTreeMap<&'a str, Node<'a>>,
files: Vec<(&'a str, usize)>,
}
impl Node<'_> {
fn file_count(&self) -> usize {
self.files.len() + self.dirs.values().map(Node::file_count).sum::<usize>()
}
}
/// Group `paths` by directory, directories before files at every level and
/// each run in byte order — the order git itself lists paths in.
///
/// A directory whose only content is one more directory is folded into it,
/// the way VS Code's compact folders do: an agent's change three levels down
/// `crates/tty7-core/src` should cost one row to reach, not three.
///
/// `collapsed` is asked once per directory row with its full path. A folded
/// directory still gets its own row; only what is under it is left out.
pub(crate) fn tree_rows<'a>(
paths: impl IntoIterator<Item = &'a str>,
collapsed: impl Fn(&str) -> bool,
) -> Vec<TreeRow> {
let mut root = Node::default();
for (index, path) in paths.into_iter().enumerate() {
// An untracked directory arrives as `dir/`. It is a leaf here, the
// same as it is a single row in the flat list.
let trimmed = path.strip_suffix('/').unwrap_or(path);
let (dir, name) = match trimmed.rsplit_once('/') {
Some((dir, name)) => (Some(dir), name),
None => (None, trimmed),
};
let mut node = &mut root;
for part in dir.into_iter().flat_map(|d| d.split('/')) {
node = node.dirs.entry(part).or_default();
}
node.files.push((name, index));
}
let mut rows = Vec::new();
emit(&root, "", 0, &collapsed, &mut rows);
rows
}
fn emit(
node: &Node<'_>,
prefix: &str,
depth: usize,
collapsed: &impl Fn(&str) -> bool,
rows: &mut Vec<TreeRow>,
) {
for (name, child) in &node.dirs {
let mut label = (*name).to_string();
let mut child = child;
while child.files.is_empty() && child.dirs.len() == 1 {
let (next, grandchild) = child.dirs.iter().next().expect("one entry");
label.push('/');
label.push_str(next);
child = grandchild;
}
let key = match prefix {
"" => label.clone(),
_ => format!("{prefix}/{label}"),
};
let folded = collapsed(&key);
rows.push(TreeRow::Dir {
key: key.clone(),
label,
depth,
files: child.file_count(),
collapsed: folded,
});
if !folded {
emit(child, &key, depth + 1, collapsed, rows);
}
}
let mut files = node.files.clone();
files.sort_by(|a, b| a.0.cmp(b.0));
rows.extend(
files
.into_iter()
.map(|(_, index)| TreeRow::File { index, depth }),
);
}
/// The filter box's text, normalised once: lowercased, trimmed, and `None`
/// when there is nothing left to filter by.
pub(crate) fn normalize_query(text: &str) -> Option<String> {
let q = text.trim().to_lowercase();
(!q.is_empty()).then_some(q)
}
/// Whether a changed path survives the filter.
///
/// Every whitespace-separated word has to appear somewhere in the path, in
/// any order: `scm panel` finds `src/ui/scm/panel.rs`, and so does `panel
/// scm`. `query` is what [`normalize_query`] returned.
pub(crate) fn path_matches(path: &str, query: &str) -> bool {
let hay = path.to_lowercase();
query.split_whitespace().all(|word| hay.contains(word))
}
#[cfg(test)]
mod tests {
use super::*;
fn open(paths: &[&str]) -> Vec<TreeRow> {
tree_rows(paths.iter().copied(), |_| false)
}
fn dir(key: &str, label: &str, depth: usize, files: usize) -> TreeRow {
TreeRow::Dir {
key: key.into(),
label: label.into(),
depth,
files,
collapsed: false,
}
}
fn file(index: usize, depth: usize) -> TreeRow {
TreeRow::File { index, depth }
}
#[test]
fn files_are_grouped_under_their_directories() {
let rows = open(&["src/a.rs", "README.md", "src/b.rs", "docs/x.md"]);
assert_eq!(
rows,
vec![
dir("docs", "docs", 0, 1),
file(3, 1),
dir("src", "src", 0, 2),
file(0, 1),
file(2, 1),
// Files after directories, at the root as everywhere else.
file(1, 0),
]
);
}
#[test]
fn a_directory_holding_only_a_directory_is_compacted() {
let rows = open(&[
"crates/core/src/git/status.rs",
"crates/core/src/git/diff.rs",
"crates/core/Cargo.toml",
]);
assert_eq!(
rows,
vec![
// `crates` holds only `core`, so they share a row; `core`
// holds a file, so the chain stops there.
dir("crates/core", "crates/core", 0, 3),
dir("crates/core/src/git", "src/git", 1, 2),
file(1, 2),
file(0, 2),
file(2, 1),
]
);
}
#[test]
fn a_directory_with_two_children_is_not_compacted() {
let rows = open(&["a/b/x.rs", "a/c/y.rs"]);
assert_eq!(
rows,
vec![
dir("a", "a", 0, 2),
dir("a/b", "b", 1, 1),
file(0, 2),
dir("a/c", "c", 1, 1),
file(1, 2),
]
);
}
#[test]
fn a_folded_directory_keeps_its_row_and_its_count() {
let rows = tree_rows(["src/ui/a.rs", "src/ui/b.rs", "src/main.rs"], |key| {
key == "src/ui"
});
assert_eq!(
rows,
vec![
dir("src", "src", 0, 3),
TreeRow::Dir {
key: "src/ui".into(),
label: "ui".into(),
depth: 1,
files: 2,
collapsed: true,
},
file(2, 1),
]
);
}
#[test]
fn folding_is_asked_about_the_compacted_path() {
let asked = std::cell::RefCell::new(Vec::new());
tree_rows(["a/b/c/x.rs"], |key| {
asked.borrow_mut().push(key.to_string());
false
});
assert_eq!(asked.into_inner(), vec!["a/b/c".to_string()]);
}
#[test]
fn an_untracked_directory_is_a_leaf() {
let rows = open(&["build/", "src/new/"]);
assert_eq!(rows, vec![dir("src", "src", 0, 1), file(1, 1), file(0, 0)]);
}
#[test]
fn every_path_lands_in_exactly_one_file_row() {
let paths = [
"z.rs",
"a/b/c.rs",
"a/b/d.rs",
"a/e.rs",
"f/g/h/i.rs",
"f/j.rs",
"a/b/",
];
let mut seen: Vec<usize> = open(&paths)
.into_iter()
.filter_map(|row| match row {
TreeRow::File { index, .. } => Some(index),
TreeRow::Dir { .. } => None,
})
.collect();
seen.sort_unstable();
assert_eq!(seen, (0..paths.len()).collect::<Vec<_>>());
}
#[test]
fn an_empty_list_is_an_empty_tree() {
assert!(open(&[]).is_empty());
}
#[test]
fn the_filter_needs_every_word_in_any_order_and_any_case() {
let q = normalize_query(" Scm PANEL ").unwrap();
assert!(path_matches("src/ui/scm/panel.rs", &q));
assert!(path_matches("src/ui/Panel/SCM.rs", &q));
assert!(!path_matches("src/ui/scm/state.rs", &q));
let q = normalize_query("panel scm").unwrap();
assert!(path_matches("src/ui/scm/panel.rs", &q));
}
#[test]
fn a_blank_filter_is_no_filter() {
assert_eq!(normalize_query(""), None);
assert_eq!(normalize_query(" "), None);
assert_eq!(normalize_query(" a "), Some("a".into()));
}
#[test]
fn the_filter_reads_non_ascii_paths() {
let q = normalize_query("设计").unwrap();
assert!(path_matches("文档/设计/方案.md", &q));
assert!(!path_matches("文档/实现.md", &q));
}
}