mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
feat(right-panel): docked detail panel with Info, Changes and Files tabs
Add a right-hand detail column showing what the active pane is, not what it prints: session facts plus its process tree and listening ports (daemon-side procinfo, pull-based via QueryProcs), the working-tree diff, and the file tree. Tab row lives in the title bar, body in right_panel. Also record OSC 133 command marks client-side so the panel's Outline can list a pane's commands and scroll back to one, keyed on row text since absolute scrollback indices drift once history fills.
This commit is contained in:
@@ -55,6 +55,22 @@ actions!(
|
||||
// Switch the tab bar between the horizontal title-bar strip and the
|
||||
// vertical left-side sidebar (persists `tab_bar_position`).
|
||||
ToggleTabSidebar,
|
||||
// Collapse/expand the left tab sidebar in place (persists
|
||||
// `sidebar_collapsed`). Unlike `ToggleTabSidebar` this does not switch
|
||||
// the tab bar to the horizontal strip — the rail just goes away and
|
||||
// comes back at the same width.
|
||||
ToggleLeftPanel,
|
||||
// Show/hide the right detail panel — session info, working-tree changes,
|
||||
// and the file tree (persists `right_panel_visible`).
|
||||
ToggleRightPanel,
|
||||
// Jump straight to one of the right panel's tabs, opening the panel if
|
||||
// it was closed. Unit actions rather than one parameterized action so
|
||||
// config/Settings can bind them by name; unbound by default, since the
|
||||
// panel's own tab row is the primary way in.
|
||||
ShowRightPanelInfo,
|
||||
ShowRightPanelOutline,
|
||||
ShowRightPanelChanges,
|
||||
ShowRightPanelFiles,
|
||||
OpenSettings,
|
||||
RestartDaemon,
|
||||
// Toggle the SFTP file panel for the focused native-SSH pane (WS5).
|
||||
|
||||
@@ -97,6 +97,25 @@ pub struct Config {
|
||||
/// the live layout re-clamps it to `[180, window_width/2]`.
|
||||
#[serde(default = "default_sidebar_width")]
|
||||
pub sidebar_width: f32,
|
||||
/// Whether the vertical tab sidebar is collapsed out of the layout (only
|
||||
/// meaningful when `tab_bar_position` is `left`). Distinct from
|
||||
/// `tab_bar_position`: collapsing hides the rail *without* falling back to
|
||||
/// the horizontal title-bar strip, so the terminal gets the full width and
|
||||
/// re-expanding restores the same rail. Toggled by `ToggleLeftPanel`.
|
||||
#[serde(default)]
|
||||
pub sidebar_collapsed: bool,
|
||||
/// Whether the right detail panel (session info / changes / files) is
|
||||
/// docked open. Toggled by `ToggleRightPanel`.
|
||||
#[serde(default)]
|
||||
pub right_panel_visible: bool,
|
||||
/// Width (px) of the right detail panel. Re-clamped by the live layout the
|
||||
/// same way `sidebar_width` is.
|
||||
#[serde(default = "default_right_panel_width")]
|
||||
pub right_panel_width: f32,
|
||||
/// Which tab the right detail panel last had selected, so reopening it lands
|
||||
/// where it was left.
|
||||
#[serde(default, deserialize_with = "de_lenient")]
|
||||
pub right_panel_tab: RightPanelTab,
|
||||
/// How the vertical tab sidebar arranges its rows (only meaningful when
|
||||
/// `tab_bar_position` is `left`): grouped under a header per git work tree
|
||||
/// (`repo`, the default), or one flat list (`none`).
|
||||
@@ -453,6 +472,10 @@ impl Default for Config {
|
||||
// `left` opts into the vertical sidebar.
|
||||
tab_bar_position: TabBarPosition::Top,
|
||||
sidebar_width: default_sidebar_width(),
|
||||
sidebar_collapsed: false,
|
||||
right_panel_visible: false,
|
||||
right_panel_width: default_right_panel_width(),
|
||||
right_panel_tab: RightPanelTab::Info,
|
||||
sidebar_grouping: SidebarGrouping::Repo,
|
||||
notify_on_command_finish: NotifyMode::Unfocused,
|
||||
// Opt-out, not opt-in: a stale terminal that never tells you it's
|
||||
@@ -557,6 +580,10 @@ impl Config {
|
||||
self.sidebar_width = default_sidebar_width();
|
||||
}
|
||||
self.sidebar_width = self.sidebar_width.clamp(100.0, 2000.0);
|
||||
if !self.right_panel_width.is_finite() || self.right_panel_width <= 0.0 {
|
||||
self.right_panel_width = default_right_panel_width();
|
||||
}
|
||||
self.right_panel_width = self.right_panel_width.clamp(100.0, 2000.0);
|
||||
}
|
||||
|
||||
/// Write the current config back to disk, creating the parent directory if
|
||||
@@ -756,6 +783,27 @@ fn default_prefix() -> String {
|
||||
"ctrl-b".to_string()
|
||||
}
|
||||
|
||||
/// Which tab the right detail panel shows.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RightPanelTab {
|
||||
/// Session facts: cwd, shell, branch, agent.
|
||||
#[default]
|
||||
Info,
|
||||
/// The pane's command history as a navigable outline (OSC 133 marks).
|
||||
Outline,
|
||||
/// The pane's working-tree diff.
|
||||
Changes,
|
||||
/// The file tree rooted at the pane's repository.
|
||||
Files,
|
||||
}
|
||||
|
||||
/// Serde default for [`Config::right_panel_width`]: wide enough for a file path
|
||||
/// plus its `+N −M` counts without the tree turning into an ellipsis parade.
|
||||
fn default_right_panel_width() -> f32 {
|
||||
260.
|
||||
}
|
||||
|
||||
/// Serde default for [`Config::sidebar_width`]: a comfortable rail width that
|
||||
/// clears the tab labels without eating too much of the terminal.
|
||||
fn default_sidebar_width() -> f32 {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
pub mod pane;
|
||||
pub mod pidfile;
|
||||
pub mod procinfo;
|
||||
pub mod protocol;
|
||||
pub(crate) mod remote;
|
||||
pub mod server;
|
||||
|
||||
@@ -1013,6 +1013,20 @@ impl DaemonPane {
|
||||
/// The local-PTY backend, or `None` for a native-SSH pane. PTY-only
|
||||
/// operations (resize via master, signal groups, foreground proc queries)
|
||||
/// short-circuit when this is `None`.
|
||||
/// The pane's process tree and listening ports, for the GUI's details panel
|
||||
/// (`QueryProcs`). A native-SSH pane has no local process tree at all — its
|
||||
/// commands run on the far side — so it answers empty rather than reporting
|
||||
/// the daemon's own descendants.
|
||||
pub fn procs(&self) -> crate::daemon::protocol::PaneProcs {
|
||||
let Some(pty) = self.pty() else {
|
||||
return Default::default();
|
||||
};
|
||||
let Some(shell_pid) = pty.shell_pid else {
|
||||
return Default::default();
|
||||
};
|
||||
crate::daemon::procinfo::snapshot(shell_pid, pty_foreground_pgid(&pty.master))
|
||||
}
|
||||
|
||||
fn pty(&self) -> Option<&PtyBackend> {
|
||||
match &self.backend {
|
||||
PaneBackend::Pty(p) => Some(p),
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
//! What a pane is *running*: the process tree under its shell, and the TCP ports
|
||||
//! that tree is listening on. Feeds the GUI's details panel (`QueryProcs`).
|
||||
//!
|
||||
//! Everything here is best-effort and read-only. A pid can exit between the
|
||||
//! table walk and the name lookup, `lsof` may be missing, `/proc` may be
|
||||
//! unreadable — each of those degrades to a shorter list, never an error. The
|
||||
//! panel showing one fewer row is a non-event; a details query that can fail is
|
||||
//! a support burden.
|
||||
//!
|
||||
//! Called on demand from the details panel, not on a timer — see the doc on
|
||||
//! [`ClientMsg::QueryProcs`](crate::daemon::protocol::ClientMsg::QueryProcs) for
|
||||
//! why this is pull-based when `Cwd` and `Agent` are pushed.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::daemon::protocol::{PaneProcs, PortEntry, ProcEntry};
|
||||
|
||||
/// Depth cap on the process walk. Deep trees are real (a shell running `make`
|
||||
/// running a compiler driver running the compiler), but past a handful of hops
|
||||
/// the rows stop being information and start being noise in a 260px column.
|
||||
const MAX_DEPTH: u8 = 6;
|
||||
|
||||
/// Hard cap on rows, so a pane that spawned a thousand workers can't turn a
|
||||
/// details query into a wire-format stress test.
|
||||
const MAX_PROCS: usize = 64;
|
||||
|
||||
/// The process tree under `shell_pid` plus its listening ports. `fg_pgid` is the
|
||||
/// PTY's foreground process group, used to mark the row the user is looking at;
|
||||
/// pass `None` when it isn't known.
|
||||
pub fn snapshot(shell_pid: u32, fg_pgid: Option<i32>) -> PaneProcs {
|
||||
let table = process_table();
|
||||
let procs = walk(&table, shell_pid, fg_pgid);
|
||||
let ports = listening_ports(&procs);
|
||||
PaneProcs { procs, ports }
|
||||
}
|
||||
|
||||
/// One row of the system process table, reduced to what the walk needs.
|
||||
struct Row {
|
||||
ppid: u32,
|
||||
pgid: u32,
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// Depth-first from the shell, so the caller can render in order and indent by
|
||||
/// `depth` without rebuilding a hierarchy.
|
||||
fn walk(table: &HashMap<u32, Row>, shell_pid: u32, fg_pgid: Option<i32>) -> Vec<ProcEntry> {
|
||||
// Children by parent, so the descent is a lookup rather than a table scan
|
||||
// per node. Sorted by pid: the process table's own order is unspecified, and
|
||||
// a list that reshuffles between two refreshes reads as churn.
|
||||
let mut children: HashMap<u32, Vec<u32>> = HashMap::new();
|
||||
for (pid, row) in table {
|
||||
children.entry(row.ppid).or_default().push(*pid);
|
||||
}
|
||||
for kids in children.values_mut() {
|
||||
kids.sort_unstable();
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![(shell_pid, 0u8)];
|
||||
while let Some((pid, depth)) = stack.pop() {
|
||||
let Some(row) = table.get(&pid) else { continue };
|
||||
if out.len() >= MAX_PROCS {
|
||||
break;
|
||||
}
|
||||
out.push(ProcEntry {
|
||||
pid,
|
||||
name: row.name.clone(),
|
||||
depth,
|
||||
foreground: fg_pgid.is_some_and(|g| g as u32 == row.pgid),
|
||||
});
|
||||
if depth + 1 > MAX_DEPTH {
|
||||
continue;
|
||||
}
|
||||
if let Some(kids) = children.get(&pid) {
|
||||
// Pushed in reverse so the pop order stays ascending by pid.
|
||||
for kid in kids.iter().rev() {
|
||||
stack.push((*kid, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ── Platform: the process table ─────────────────────────────────────────────
|
||||
|
||||
/// macOS: one `proc_listallpids` sweep, then `PROC_PIDTBSDINFO` per pid for
|
||||
/// parent/group. Cheaper than shelling out to `ps`, and it can't be defeated by
|
||||
/// a user's `ps` alias or a locale-dependent column layout.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn process_table() -> HashMap<u32, Row> {
|
||||
let mut table = HashMap::new();
|
||||
// Ask for the count first, then read into a buffer sized from it (plus slack,
|
||||
// since processes can appear between the two calls).
|
||||
// SAFETY: the documented "how big a buffer do I need" form — null buffer,
|
||||
// zero size — which only returns a byte count.
|
||||
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];
|
||||
// SAFETY: buffer and its true byte length; the call writes at most that many
|
||||
// bytes and returns how many it wrote.
|
||||
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)) {
|
||||
if pid <= 0 {
|
||||
continue;
|
||||
}
|
||||
let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
|
||||
let size = std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
|
||||
// SAFETY: zeroed buffer of the expected type, real size passed; the
|
||||
// result is read back only when the kernel filled exactly that many
|
||||
// bytes (a short return means the pid died mid-walk).
|
||||
let ret = unsafe {
|
||||
libc::proc_pidinfo(
|
||||
pid,
|
||||
libc::PROC_PIDTBSDINFO,
|
||||
0,
|
||||
&mut info as *mut _ as *mut libc::c_void,
|
||||
size,
|
||||
)
|
||||
};
|
||||
if ret != size {
|
||||
continue;
|
||||
}
|
||||
// `pbi_comm` is the kernel's truncated name (16 bytes). Prefer the full
|
||||
// executable basename, which is what the user typed.
|
||||
let name = proc_name(pid).unwrap_or_else(|| cstr_field(&info.pbi_comm));
|
||||
table.insert(
|
||||
pid as u32,
|
||||
Row {
|
||||
ppid: info.pbi_ppid,
|
||||
pgid: info.pbi_pgid,
|
||||
name,
|
||||
},
|
||||
);
|
||||
}
|
||||
table
|
||||
}
|
||||
|
||||
/// Read a fixed-size, NUL-padded C char array into a `String`.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn cstr_field(buf: &[libc::c_char]) -> String {
|
||||
let bytes: Vec<u8> = buf
|
||||
.iter()
|
||||
.take_while(|c| **c != 0)
|
||||
.map(|c| *c as u8)
|
||||
.collect();
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
/// Linux: `/proc/<pid>/stat` carries ppid and pgid in fixed positions. The
|
||||
/// comm field is parenthesized and may itself contain spaces and parens, so the
|
||||
/// fields after it are located from the *last* `)`, not by splitting the line.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn process_table() -> HashMap<u32, Row> {
|
||||
let mut table = HashMap::new();
|
||||
let Ok(dir) = std::fs::read_dir("/proc") else {
|
||||
return table;
|
||||
};
|
||||
for entry in dir.flatten() {
|
||||
let Some(pid) = entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
|
||||
continue;
|
||||
};
|
||||
let Some(close) = stat.rfind(')') else {
|
||||
continue;
|
||||
};
|
||||
let mut fields = stat[close + 1..].split_whitespace();
|
||||
// After `)`: state, ppid, pgrp, …
|
||||
let (Some(_state), Some(ppid), Some(pgid)) = (fields.next(), fields.next(), fields.next())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let (Ok(ppid), Ok(pgid)) = (ppid.parse::<u32>(), pgid.parse::<u32>()) else {
|
||||
continue;
|
||||
};
|
||||
let name = proc_name(pid as i32).unwrap_or_else(|| {
|
||||
// Fall back to the parenthesized comm already in hand.
|
||||
stat[..close]
|
||||
.rfind('(')
|
||||
.map_or_else(|| String::new(), |open| stat[open + 1..close].to_string())
|
||||
});
|
||||
table.insert(pid, Row { ppid, pgid, name });
|
||||
}
|
||||
table
|
||||
}
|
||||
|
||||
/// Windows: reuse the existing toolhelp snapshot. It carries no process-group
|
||||
/// concept, so nothing is ever marked foreground — matching how `foreground_title`
|
||||
/// already treats the platform.
|
||||
#[cfg(windows)]
|
||||
fn process_table() -> HashMap<u32, Row> {
|
||||
crate::daemon::winproc::snapshot()
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
(
|
||||
p.pid,
|
||||
Row {
|
||||
ppid: p.ppid,
|
||||
pgid: 0,
|
||||
name: p.name,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||
fn process_table() -> HashMap<u32, Row> {
|
||||
HashMap::new()
|
||||
}
|
||||
|
||||
/// Executable basename of `pid` (macOS).
|
||||
#[cfg(target_os = "macos")]
|
||||
fn proc_name(pid: i32) -> Option<String> {
|
||||
let mut buf = [0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
|
||||
// SAFETY: valid, correctly-sized buffer; `proc_pidpath` writes at most
|
||||
// `buf.len()` bytes and returns the count (<=0 on failure).
|
||||
let ret =
|
||||
unsafe { libc::proc_pidpath(pid, buf.as_mut_ptr() as *mut libc::c_void, buf.len() as u32) };
|
||||
if ret <= 0 {
|
||||
return None;
|
||||
}
|
||||
let path = std::str::from_utf8(&buf[..ret as usize]).ok()?;
|
||||
Some(path.rsplit('/').next().unwrap_or(path).to_string())
|
||||
}
|
||||
|
||||
/// Executable basename of `pid` via `/proc/<pid>/exe` (Linux). Unreadable for
|
||||
/// processes we don't own, hence the caller's `comm` fallback.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn proc_name(pid: i32) -> Option<String> {
|
||||
let path = std::fs::read_link(format!("/proc/{pid}/exe")).ok()?;
|
||||
let name = path.file_name()?.to_str()?;
|
||||
let name = name.strip_suffix(" (deleted)").unwrap_or(name);
|
||||
(!name.is_empty()).then(|| name.to_string())
|
||||
}
|
||||
|
||||
// ── Platform: listening ports ───────────────────────────────────────────────
|
||||
|
||||
/// TCP listeners owned by any pid in `procs`, via `lsof`.
|
||||
///
|
||||
/// Shelling out rather than reading the socket tables directly: on macOS the
|
||||
/// only supported route is a private `libproc` fd walk, and on Linux matching
|
||||
/// `/proc/net/tcp` inodes against every pid's fds costs more syscalls than the
|
||||
/// subprocess. `lsof` ships with macOS; where it's missing this returns empty,
|
||||
/// which just hides the row.
|
||||
#[cfg(unix)]
|
||||
fn listening_ports(procs: &[ProcEntry]) -> Vec<PortEntry> {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
if procs.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let pid_list = procs
|
||||
.iter()
|
||||
.map(|p| p.pid.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
// `-Fpn`: machine-readable output, pid (`p…`) and name (`n…`) fields only,
|
||||
// one per line. `-nP` skips DNS and /etc/services lookups — both can block.
|
||||
let out = Command::new("lsof")
|
||||
.args([
|
||||
"-nP",
|
||||
"-iTCP",
|
||||
"-sTCP:LISTEN",
|
||||
"-a",
|
||||
"-p",
|
||||
&pid_list,
|
||||
"-Fpn",
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.output();
|
||||
let Ok(out) = out else { return Vec::new() };
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
|
||||
let by_pid: HashMap<u32, &str> = procs.iter().map(|p| (p.pid, p.name.as_str())).collect();
|
||||
let mut ports: Vec<PortEntry> = Vec::new();
|
||||
let mut current = 0u32;
|
||||
for line in text.lines() {
|
||||
let Some((tag, rest)) = line.split_at_checked(1) else {
|
||||
continue;
|
||||
};
|
||||
match tag {
|
||||
"p" => current = rest.parse().unwrap_or(0),
|
||||
"n" => {
|
||||
let Some(port) = parse_listen_port(rest) else {
|
||||
continue;
|
||||
};
|
||||
// One listener commonly binds both v4 and v6, or several
|
||||
// addresses on the same port; the panel wants the port once.
|
||||
if ports.iter().any(|e| e.port == port && e.pid == current) {
|
||||
continue;
|
||||
}
|
||||
ports.push(PortEntry {
|
||||
port,
|
||||
pid: current,
|
||||
name: by_pid
|
||||
.get(¤t)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
ports.sort_by_key(|e| (e.port, e.pid));
|
||||
ports
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn listening_ports(_procs: &[ProcEntry]) -> Vec<PortEntry> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// The port out of an `lsof -Fn` name field: `*:3000`, `127.0.0.1:8080`,
|
||||
/// `[::1]:5173`, sometimes with a trailing ` (LISTEN)` despite `-F`.
|
||||
fn parse_listen_port(name: &str) -> Option<u16> {
|
||||
let name = name.split_whitespace().next()?;
|
||||
// Split on the *last* colon: an IPv6 literal is full of them.
|
||||
let (_, port) = name.rsplit_once(':')?;
|
||||
port.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn row(ppid: u32, name: &str) -> Row {
|
||||
Row {
|
||||
ppid,
|
||||
pgid: 0,
|
||||
name: name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_is_depth_first_from_the_shell() {
|
||||
let table: HashMap<u32, Row> = [
|
||||
(100, row(1, "zsh")),
|
||||
(200, row(100, "make")),
|
||||
(300, row(200, "cc")),
|
||||
(400, row(100, "vim")),
|
||||
// A sibling process outside the shell's tree must not appear.
|
||||
(500, row(1, "Finder")),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let got = walk(&table, 100, None);
|
||||
let names: Vec<_> = got.iter().map(|p| (p.name.as_str(), p.depth)).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![("zsh", 0), ("make", 1), ("cc", 2), ("vim", 1)],
|
||||
"depth-first, ascending pid, shell's tree only"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_marks_the_foreground_process_group() {
|
||||
let mut table: HashMap<u32, Row> = [(100, row(1, "zsh")), (200, row(100, "vim"))]
|
||||
.into_iter()
|
||||
.collect();
|
||||
table.get_mut(&100).unwrap().pgid = 100;
|
||||
table.get_mut(&200).unwrap().pgid = 200;
|
||||
|
||||
let got = walk(&table, 100, Some(200));
|
||||
assert!(
|
||||
!got[0].foreground,
|
||||
"the shell is backgrounded while vim runs"
|
||||
);
|
||||
assert!(got[1].foreground, "vim's group owns the terminal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_survives_a_cycle_in_the_table() {
|
||||
// Two processes claiming each other as parent — impossible on a live
|
||||
// kernel, but the table is a non-atomic sweep of pids that can be reused
|
||||
// mid-walk, so the descent must terminate regardless.
|
||||
let table: HashMap<u32, Row> = [(100, row(200, "a")), (200, row(100, "b"))]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let got = walk(&table, 100, None);
|
||||
assert!(got.len() <= MAX_PROCS, "bounded, not infinite");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_lsof_listen_addresses() {
|
||||
assert_eq!(parse_listen_port("*:3000"), Some(3000));
|
||||
assert_eq!(parse_listen_port("127.0.0.1:8080"), Some(8080));
|
||||
assert_eq!(parse_listen_port("[::1]:5173"), Some(5173));
|
||||
assert_eq!(parse_listen_port("*:5432 (LISTEN)"), Some(5432));
|
||||
assert_eq!(parse_listen_port("/tmp/some.sock"), None);
|
||||
}
|
||||
}
|
||||
@@ -445,6 +445,40 @@ pub struct ManagedForward {
|
||||
pub status: ForwardStatus,
|
||||
}
|
||||
|
||||
/// One process running under a pane's shell, for the details panel's process
|
||||
/// list. `depth` is hops from the shell (the shell itself is 0), which is all the
|
||||
/// UI needs to indent the tree — sending the parent pid would make the client
|
||||
/// rebuild a hierarchy the daemon already walked.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProcEntry {
|
||||
pub pid: u32,
|
||||
pub name: String,
|
||||
pub depth: u8,
|
||||
/// Whether this process (or its group) currently owns the terminal — the one
|
||||
/// the user is actually looking at.
|
||||
#[serde(default)]
|
||||
pub foreground: bool,
|
||||
}
|
||||
|
||||
/// A TCP port a pane's process tree is listening on. The pane that started a dev
|
||||
/// server is exactly the context in which "which port is this on?" gets asked, so
|
||||
/// the answer belongs next to the process list rather than in a global inspector.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PortEntry {
|
||||
pub port: u16,
|
||||
pub pid: u32,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Reply to `QueryProcs`: what a pane is running, and what it's listening on.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PaneProcs {
|
||||
/// Depth-first from the shell, so rendering in order gives a readable tree.
|
||||
pub procs: Vec<ProcEntry>,
|
||||
/// Ascending by port; deduped, since one listener can bind several addresses.
|
||||
pub ports: Vec<PortEntry>,
|
||||
}
|
||||
|
||||
fn default_term() -> String {
|
||||
"xterm-256color".to_string()
|
||||
}
|
||||
@@ -773,6 +807,15 @@ pub enum ClientMsg {
|
||||
/// Ask for the managed forwards attributed to `pane_id`. Control-connection
|
||||
/// message; the daemon replies with a `ForwardList`.
|
||||
ListForwards { pane_id: u64 },
|
||||
/// One-shot query for a pane's process tree and listening ports, over a
|
||||
/// short-lived control connection; the daemon replies with `PaneProcs`.
|
||||
///
|
||||
/// Deliberately pull-based, unlike `Cwd`/`Agent` which the daemon pushes:
|
||||
/// walking the process table and probing sockets costs far more than sniffing
|
||||
/// an OSC sequence, and the answer is only ever looked at while the details
|
||||
/// panel's Info tab is open. Pushing it on a timer would burn that cost for
|
||||
/// every pane, forever, to feed a view that's usually closed.
|
||||
QueryProcs { pane_id: u64 },
|
||||
/// Ask which protocol version the daemon speaks (control connection); the
|
||||
/// daemon replies `Version`. A daemon that predates versioning doesn't know
|
||||
/// this kind and drops the connection instead of replying — the client
|
||||
@@ -853,6 +896,8 @@ pub enum DaemonMsg {
|
||||
/// Reply to `AddForward` / `RemoveForward` / `ListForwards`: the managed
|
||||
/// forwards currently attributed to the requested pane (WS4).
|
||||
ForwardList(Vec<ManagedForward>),
|
||||
/// Reply to `QueryProcs`.
|
||||
Procs(PaneProcs),
|
||||
/// Reply to `Version`.
|
||||
Version(DaemonVersion),
|
||||
/// A request failed (e.g. `Attach` to an unknown/dead pane id).
|
||||
@@ -908,6 +953,9 @@ mod kind {
|
||||
/// `Version` — protocol-version handshake. 40 sits clear of every reserved
|
||||
/// range above (WS3 16–19, WS4 20–24, SFTP 30–36).
|
||||
pub const VERSION: u8 = 40;
|
||||
/// `QueryProcs` — a pane's process tree + listening ports, for the details
|
||||
/// panel. 50 sits clear of every range above and of `VERSION`.
|
||||
pub const QUERY_PROCS: u8 = 50;
|
||||
|
||||
// Daemon -> client
|
||||
pub const SPAWNED: u8 = 1;
|
||||
@@ -943,6 +991,8 @@ mod kind {
|
||||
/// `Version` — reply to the client-space `VERSION` request (same value by
|
||||
/// design; the spaces are independent).
|
||||
pub const VERSION_REPLY: u8 = 40;
|
||||
/// `Procs` — reply to the client-space `QUERY_PROCS` request.
|
||||
pub const PROCS: u8 = 50;
|
||||
}
|
||||
|
||||
/// Write one framed message: `[u32 LE len][u8 kind][payload]`.
|
||||
@@ -1084,6 +1134,9 @@ impl ClientMsg {
|
||||
pane_id,
|
||||
forward_id,
|
||||
} => write_frame(w, kind::REMOVE_FORWARD, &to_json(&(pane_id, forward_id))?),
|
||||
ClientMsg::QueryProcs { pane_id } => {
|
||||
write_frame(w, kind::QUERY_PROCS, &to_json(pane_id)?)
|
||||
}
|
||||
ClientMsg::ListForwards { pane_id } => {
|
||||
write_frame(w, kind::LIST_FORWARDS, &to_json(pane_id)?)
|
||||
}
|
||||
@@ -1149,6 +1202,9 @@ impl ClientMsg {
|
||||
kind::SFTP_TRANSFER_LIST => ClientMsg::SftpTransferList {
|
||||
pane_id: from_json(&payload)?,
|
||||
},
|
||||
kind::QUERY_PROCS => ClientMsg::QueryProcs {
|
||||
pane_id: from_json(&payload)?,
|
||||
},
|
||||
kind::ADD_FORWARD => {
|
||||
let (pane_id, rule) = from_json(&payload)?;
|
||||
ClientMsg::AddForward { pane_id, rule }
|
||||
@@ -1227,6 +1283,7 @@ impl DaemonMsg {
|
||||
write_frame(w, kind::SFTP_TRANSFER_PROGRESS, &to_json(jobs)?)
|
||||
}
|
||||
DaemonMsg::ForwardList(list) => write_frame(w, kind::FORWARD_LIST, &to_json(list)?),
|
||||
DaemonMsg::Procs(procs) => write_frame(w, kind::PROCS, &to_json(procs)?),
|
||||
DaemonMsg::Version(version) => write_frame(w, kind::VERSION_REPLY, &to_json(version)?),
|
||||
DaemonMsg::Error(msg) => write_frame(w, kind::ERROR, &to_json(msg)?),
|
||||
}
|
||||
@@ -1274,6 +1331,7 @@ impl DaemonMsg {
|
||||
},
|
||||
kind::SFTP_TRANSFER_PROGRESS => DaemonMsg::SftpTransferProgress(from_json(&payload)?),
|
||||
kind::FORWARD_LIST => DaemonMsg::ForwardList(from_json(&payload)?),
|
||||
kind::PROCS => DaemonMsg::Procs(from_json(&payload)?),
|
||||
kind::VERSION_REPLY => DaemonMsg::Version(from_json(&payload)?),
|
||||
kind::ERROR => DaemonMsg::Error(from_json(&payload)?),
|
||||
other => {
|
||||
|
||||
@@ -509,6 +509,16 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
ClientMsg::QueryProcs { pane_id } => {
|
||||
let mut w = write_stream;
|
||||
// An unknown/dead pane answers empty rather than `Error`: the details
|
||||
// panel polls while the user watches, and a pane closing mid-flight is
|
||||
// ordinary, not a failure worth surfacing.
|
||||
let procs = registry.get(pane_id).map(|p| p.procs()).unwrap_or_default();
|
||||
DaemonMsg::Procs(procs).encode(&mut w)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
ClientMsg::ListForwards { pane_id } => {
|
||||
let mut w = write_stream;
|
||||
let list = crate::daemon::ssh::SshManager::global().list_forwards(pane_id);
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
//! Command marks: where each shell prompt started in the scrollback, so the
|
||||
//! details panel's Outline can list a pane's commands and scroll back to one.
|
||||
//!
|
||||
//! Fed by the reader thread from OSC 133 (`A` prompt start, `C` command start,
|
||||
//! `D` command done — the same shell-integration marks the daemon sniffs for
|
||||
//! prompt state). The daemon reports only *whether* the shell is at its prompt;
|
||||
//! positions have to come from the client, because only the client holds the
|
||||
//! grid those positions are relative to.
|
||||
//!
|
||||
//! # Why a mark stores its text
|
||||
//!
|
||||
//! A grid row has no stable identity. Alacritty's `Line` is relative to the
|
||||
//! viewport, so anything recorded in those coordinates slides as output arrives.
|
||||
//! Converting to an absolute index from the top of history (`history_size -
|
||||
//! display_offset + line`) is stable — *until the scrollback fills*. After that
|
||||
//! alacritty discards the oldest row per new row, every surviving row's absolute
|
||||
//! index silently decreases, and the amount discarded is not observable from
|
||||
//! outside the emulator: `history_size` is pinned at the limit, and nothing else
|
||||
//! exposes the scroll count. (Counting it exactly would mean wrapping
|
||||
//! `vte::ansi::Handler` to intercept every line-producing sequence — 71 methods,
|
||||
//! all with no-op defaults, so a future `vte` upgrade that adds one would
|
||||
//! silently break rendering. Not worth it for this.)
|
||||
//!
|
||||
//! So the absolute index is treated as a *hint* and the row's text as the
|
||||
//! *truth*: each mark records what its row said when it was made, and a reader
|
||||
//! re-reads the row before trusting the position. A mark whose row no longer
|
||||
//! matches has drifted out from under us and is reported stale rather than
|
||||
//! silently scrolling somewhere wrong. Below the scrollback limit — which is
|
||||
//! where a pane spends most of its life — the hint is exact and the check always
|
||||
//! passes.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Cap on retained marks. Deep scrollback holds far more prompts than a panel
|
||||
/// list is useful at, and the oldest are the likeliest to have drifted anyway.
|
||||
const MAX_MARKS: usize = 500;
|
||||
|
||||
/// One shell prompt, and the command run from it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CommandMark {
|
||||
/// Row index from the top of the scrollback at record time — the position
|
||||
/// hint. See the module docs for when it stops being exact.
|
||||
pub row: i64,
|
||||
/// What the row said when the mark was made, used to detect drift. Empty
|
||||
/// while the prompt has been printed but nothing has been typed yet.
|
||||
pub text: String,
|
||||
/// Exit code from `OSC 133;D`, once the command finishes.
|
||||
pub exit: Option<i32>,
|
||||
/// Whether the command has finished (a `D` mark arrived). Distinct from
|
||||
/// `exit.is_some()`: a `D` without a code still means "done".
|
||||
pub done: bool,
|
||||
}
|
||||
|
||||
/// A pane's marks, shared between the reader thread (writer) and the UI (reader).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Marks(Arc<Mutex<Vec<CommandMark>>>);
|
||||
|
||||
impl Marks {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Begin a mark at `row` (`OSC 133;A` — the shell is about to print a
|
||||
/// prompt). `text` is the row's current content, which is normally empty at
|
||||
/// this point and gets filled in by [`set_text`](Self::set_text) once the
|
||||
/// command has been typed.
|
||||
pub fn begin(&self, row: i64, text: String) {
|
||||
let Ok(mut marks) = self.0.lock() else { return };
|
||||
// A prompt redraw (a resize, a `clear`, zle repainting the line) re-emits
|
||||
// `A` on the same row. Update in place rather than stacking duplicates.
|
||||
if marks.last().is_some_and(|m| m.row == row && !m.done) {
|
||||
if let Some(last) = marks.last_mut() {
|
||||
last.text = text;
|
||||
}
|
||||
return;
|
||||
}
|
||||
marks.push(CommandMark {
|
||||
row,
|
||||
text,
|
||||
exit: None,
|
||||
done: false,
|
||||
});
|
||||
// Trim from the front: oldest marks age out of the scrollback first.
|
||||
let overflow = marks.len().saturating_sub(MAX_MARKS);
|
||||
if overflow > 0 {
|
||||
marks.drain(..overflow);
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach the command line to the open mark (`OSC 133;C` — the user hit
|
||||
/// enter, so the prompt row now holds the command). Ignored when no mark is
|
||||
/// open, which is what a `C` without a preceding `A` means.
|
||||
pub fn set_text(&self, text: String) {
|
||||
let Ok(mut marks) = self.0.lock() else { return };
|
||||
if let Some(last) = marks.last_mut() {
|
||||
if !last.done {
|
||||
last.text = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the open mark (`OSC 133;D[;exit]`).
|
||||
pub fn finish(&self, exit: Option<i32>) {
|
||||
let Ok(mut marks) = self.0.lock() else { return };
|
||||
if let Some(last) = marks.last_mut() {
|
||||
last.done = true;
|
||||
last.exit = exit;
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot for rendering, newest last. Marks that never got a command are
|
||||
/// dropped: a bare prompt the user typed nothing at is not an outline entry.
|
||||
pub fn list(&self) -> Vec<CommandMark> {
|
||||
let Ok(marks) = self.0.lock() else {
|
||||
return Vec::new();
|
||||
};
|
||||
marks
|
||||
.iter()
|
||||
.filter(|m| !m.text.trim().is_empty())
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drop everything (the pane was cleared, so every position is meaningless).
|
||||
pub fn clear(&self) {
|
||||
if let Ok(mut marks) = self.0.lock() {
|
||||
marks.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the exit code out of an `OSC 133;D` payload: `D`, `D;0`, `D;1`, and
|
||||
/// zsh's `D;aborted` all occur. Anything unparseable is "done, code unknown".
|
||||
pub fn parse_done_exit(payload: &[u8]) -> Option<i32> {
|
||||
let rest = payload.strip_prefix(b"D")?;
|
||||
let rest = rest.strip_prefix(b";")?;
|
||||
std::str::from_utf8(rest).ok()?.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// What a recognized `OSC 133` mark means for the outline.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum MarkEvent {
|
||||
/// `A` — the shell is about to print a prompt.
|
||||
Prompt,
|
||||
/// `C;<cmd>` — the command was submitted and its output starts here. tty7's
|
||||
/// own shell integration always includes the command line, so the outline
|
||||
/// never has to guess it back out of the grid (where it would be tangled up
|
||||
/// with the user's prompt string).
|
||||
Command(String),
|
||||
/// `D[;exit]` — the command finished.
|
||||
Done(Option<i32>),
|
||||
}
|
||||
|
||||
/// Finds `OSC 133` marks in the output stream and reports *where* each one lands
|
||||
/// — the byte offset just past the sequence — so the caller can advance the
|
||||
/// emulator up to exactly that point and read the grid position there.
|
||||
///
|
||||
/// Separate from [`OscTokenizer`](crate::core::osc::OscTokenizer), which reports
|
||||
/// payloads but not offsets. Carries its state across feeds, so a mark split over
|
||||
/// two socket reads is still recognized (and attributed to the batch its
|
||||
/// terminator lands in, which is the correct row either way).
|
||||
#[derive(Default)]
|
||||
pub struct MarkScanner {
|
||||
state: ScanState,
|
||||
/// Payload bytes collected so far, possibly spanning feeds. Bounded: a
|
||||
/// "payload" that runs past any plausible command line is a desync, not a
|
||||
/// mark, so it's abandoned rather than grown without limit.
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Default, PartialEq, Eq)]
|
||||
enum ScanState {
|
||||
/// Ordinary output.
|
||||
#[default]
|
||||
Text,
|
||||
/// Saw `ESC`, waiting to see whether `]` follows.
|
||||
Esc,
|
||||
/// Inside an OSC payload, collecting until BEL or ST.
|
||||
Osc,
|
||||
/// Saw `ESC` inside an OSC payload — an ST (`ESC \`) if `\` follows.
|
||||
OscEsc,
|
||||
}
|
||||
|
||||
/// Ceiling on a collected OSC payload. Long enough for any real command line,
|
||||
/// short enough that a stream that never terminates its OSC can't grow a buffer
|
||||
/// unboundedly.
|
||||
const MAX_PAYLOAD: usize = 64 * 1024;
|
||||
|
||||
impl MarkScanner {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Feed one batch. `on_mark(offset, event)` fires for each recognized mark,
|
||||
/// where `offset` is an index into `bytes` just past the mark's terminator.
|
||||
pub fn feed(&mut self, bytes: &[u8], mut on_mark: impl FnMut(usize, MarkEvent)) {
|
||||
for (i, &b) in bytes.iter().enumerate() {
|
||||
match self.state {
|
||||
ScanState::Text => {
|
||||
if b == 0x1b {
|
||||
self.state = ScanState::Esc;
|
||||
}
|
||||
}
|
||||
ScanState::Esc => {
|
||||
if b == b']' {
|
||||
self.state = ScanState::Osc;
|
||||
self.payload.clear();
|
||||
} else {
|
||||
// Some other escape sequence; `ESC ESC` restarts.
|
||||
self.state = if b == 0x1b {
|
||||
ScanState::Esc
|
||||
} else {
|
||||
ScanState::Text
|
||||
};
|
||||
}
|
||||
}
|
||||
ScanState::Osc => match b {
|
||||
0x07 => {
|
||||
if let Some(ev) = self.take() {
|
||||
on_mark(i + 1, ev);
|
||||
}
|
||||
self.state = ScanState::Text;
|
||||
}
|
||||
0x1b => self.state = ScanState::OscEsc,
|
||||
_ => {
|
||||
if self.payload.len() < MAX_PAYLOAD {
|
||||
self.payload.push(b);
|
||||
} else {
|
||||
// Runaway payload: give up on this sequence rather
|
||||
// than buffer the rest of the stream into it.
|
||||
self.state = ScanState::Text;
|
||||
self.payload.clear();
|
||||
}
|
||||
}
|
||||
},
|
||||
ScanState::OscEsc => {
|
||||
if b == b'\\' {
|
||||
if let Some(ev) = self.take() {
|
||||
on_mark(i + 1, ev);
|
||||
}
|
||||
self.state = ScanState::Text;
|
||||
} else {
|
||||
// Not an ST after all — the ESC was payload.
|
||||
self.payload.push(0x1b);
|
||||
self.state = ScanState::Osc;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Interpret the collected payload, clearing it either way.
|
||||
fn take(&mut self) -> Option<MarkEvent> {
|
||||
let payload = std::mem::take(&mut self.payload);
|
||||
let body = payload.strip_prefix(b"133;")?;
|
||||
match body.first()? {
|
||||
b'A' => Some(MarkEvent::Prompt),
|
||||
b'C' => {
|
||||
// `C` alone (no command) still marks output start; the shells
|
||||
// that can't report the line send it bare.
|
||||
let cmd = body
|
||||
.strip_prefix(b"C;")
|
||||
.map(|c| String::from_utf8_lossy(c).into_owned())
|
||||
.unwrap_or_default();
|
||||
Some(MarkEvent::Command(cmd))
|
||||
}
|
||||
b'D' => Some(MarkEvent::Done(parse_done_exit(body))),
|
||||
// `B` (prompt end) and `V` (tty7's edit-mode extension) carry no
|
||||
// position the outline cares about.
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_prompt_with_no_command_is_not_an_entry() {
|
||||
let marks = Marks::new();
|
||||
marks.begin(10, String::new());
|
||||
assert!(
|
||||
marks.list().is_empty(),
|
||||
"an empty prompt the user walked away from isn't a command"
|
||||
);
|
||||
marks.set_text("cargo build".into());
|
||||
assert_eq!(marks.list().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prompt_redraw_updates_in_place() {
|
||||
let marks = Marks::new();
|
||||
marks.begin(10, String::new());
|
||||
marks.set_text("cargo t".into());
|
||||
// zle repaints the prompt on the same row (a resize, a completion menu
|
||||
// closing) and the shell re-emits `A`.
|
||||
marks.begin(10, "cargo test".into());
|
||||
let got = marks.list();
|
||||
assert_eq!(got.len(), 1, "a redraw is the same prompt, not a new one");
|
||||
assert_eq!(got[0].text, "cargo test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_prompt_after_a_finished_command_is_a_new_entry() {
|
||||
let marks = Marks::new();
|
||||
marks.begin(10, String::new());
|
||||
marks.set_text("ls".into());
|
||||
marks.finish(Some(0));
|
||||
// Same row is possible after a `clear`.
|
||||
marks.begin(10, String::new());
|
||||
marks.set_text("pwd".into());
|
||||
let got = marks.list();
|
||||
assert_eq!(got.len(), 2);
|
||||
assert_eq!(got[0].exit, Some(0));
|
||||
assert!(!got[1].done);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_are_capped_from_the_front() {
|
||||
let marks = Marks::new();
|
||||
for i in 0..(MAX_MARKS + 10) {
|
||||
marks.begin(i as i64, format!("cmd{i}"));
|
||||
marks.finish(Some(0));
|
||||
}
|
||||
let got = marks.list();
|
||||
assert_eq!(got.len(), MAX_MARKS);
|
||||
assert_eq!(got[0].text, "cmd10", "the oldest aged out, not the newest");
|
||||
}
|
||||
|
||||
/// Collect `(offset, event)` pairs from feeding `chunks` in order, so a test
|
||||
/// can assert on a stream split at arbitrary boundaries.
|
||||
fn scan(chunks: &[&[u8]]) -> Vec<(usize, MarkEvent)> {
|
||||
let mut scanner = MarkScanner::new();
|
||||
let mut out = Vec::new();
|
||||
for chunk in chunks {
|
||||
scanner.feed(chunk, |off, ev| out.push((off, ev)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_marks_just_past_their_terminator() {
|
||||
let got = scan(&[b"ab\x1b]133;A\x07cd"]);
|
||||
assert_eq!(got, vec![(10, MarkEvent::Prompt)]);
|
||||
// The offset must point past the BEL, so advancing `bytes[..offset]`
|
||||
// consumes the whole sequence and nothing of what follows.
|
||||
assert_eq!(&b"ab\x1b]133;A\x07cd"[10..], b"cd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn carries_a_mark_split_across_two_feeds() {
|
||||
let got = scan(&[b"out\x1b]13", b"3;C;cargo build\x07more"]);
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![(16, MarkEvent::Command("cargo build".into()))],
|
||||
"the mark is attributed to the batch its terminator lands in"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_st_terminated_marks() {
|
||||
// `ESC \` instead of BEL — both are legal OSC terminators and the
|
||||
// integrations use ST on some shells.
|
||||
let got = scan(&[b"\x1b]133;D;130\x1b\\"]);
|
||||
assert_eq!(got, vec![(13, MarkEvent::Done(Some(130)))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_other_osc_sequences() {
|
||||
let got = scan(&[b"\x1b]0;a title\x07\x1b]7;file://h/x\x07\x1b]133;B\x07"]);
|
||||
assert!(
|
||||
got.is_empty(),
|
||||
"titles, cwd reports and prompt-end carry nothing the outline wants"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_containing_semicolons_survives_intact() {
|
||||
let got = scan(&[b"\x1b]133;C;for i in a b; do echo $i; done\x07"]);
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![(
|
||||
39,
|
||||
MarkEvent::Command("for i in a b; do echo $i; done".into())
|
||||
)],
|
||||
"only the first two fields are structure; the rest is the command"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unterminated_payload_cannot_grow_without_bound() {
|
||||
let mut scanner = MarkScanner::new();
|
||||
let mut fired = 0;
|
||||
scanner.feed(b"\x1b]133;C;", |_, _| fired += 1);
|
||||
for _ in 0..40 {
|
||||
scanner.feed(&vec![b'x'; 4096], |_, _| fired += 1);
|
||||
}
|
||||
assert_eq!(fired, 0, "never terminated, so never reported");
|
||||
assert!(scanner.payload.len() <= MAX_PAYLOAD);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_done_payloads() {
|
||||
assert_eq!(parse_done_exit(b"D;0"), Some(0));
|
||||
assert_eq!(parse_done_exit(b"D;130"), Some(130));
|
||||
assert_eq!(parse_done_exit(b"D"), None, "done, code unknown");
|
||||
assert_eq!(parse_done_exit(b"D;aborted"), None);
|
||||
assert_eq!(parse_done_exit(b"C"), None, "not a done mark at all");
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ mod history;
|
||||
mod hold;
|
||||
pub mod input;
|
||||
mod loopback;
|
||||
pub(crate) mod marks;
|
||||
pub mod palette;
|
||||
mod remote;
|
||||
mod reverse_search;
|
||||
|
||||
+128
-3
@@ -33,6 +33,8 @@ use alacritty_terminal::sync::FairMutex;
|
||||
use alacritty_terminal::term::{Config, Term, TermMode};
|
||||
use alacritty_terminal::vte::ansi::{self, CursorShape, CursorStyle};
|
||||
|
||||
use crate::terminal::marks::{MarkEvent, MarkScanner};
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::core::cli_agent::{AgentSessionState, CLIAgent};
|
||||
@@ -41,8 +43,8 @@ use crate::core::osc::OscTokenizer;
|
||||
use crate::daemon::protocol::{
|
||||
AuthPromptKind, AuthResponse, ClientMsg, DaemonMsg, KnownHostEntry, KnownHostId,
|
||||
LoopbackForward, LoopbackForwardId, LoopbackForwardInfo, LoopbackForwardRequest,
|
||||
ManagedForward, NativeSshSpec, RemoteContext, SftpEntry, SftpJobProgress, SftpOp, SftpOpResult,
|
||||
SftpTransferSpec, ShellSpec, SshForwardRule, SshPhase, WinSize,
|
||||
ManagedForward, NativeSshSpec, PaneProcs, RemoteContext, SftpEntry, SftpJobProgress, SftpOp,
|
||||
SftpOpResult, SftpTransferSpec, ShellSpec, SshForwardRule, SshPhase, WinSize,
|
||||
};
|
||||
use crate::daemon::transport::{self, Stream};
|
||||
|
||||
@@ -122,6 +124,8 @@ struct ReaderSignals {
|
||||
/// Latest native-SSH spawn phase from `DaemonMsg::SshStatus`, for the status
|
||||
/// line. `None` until the first status frame (a plain shell pane never sets it).
|
||||
phase: Arc<Mutex<Option<SshPhase>>>,
|
||||
/// Command marks (OSC 133 prompt positions) for the details panel's Outline.
|
||||
marks: crate::terminal::marks::Marks,
|
||||
}
|
||||
|
||||
/// A terminal whose PTY lives in the daemon. Mirrors `backend::Terminal`'s public
|
||||
@@ -200,6 +204,10 @@ pub struct RemoteTerminal {
|
||||
/// session id), last reported by the daemon via `AgentStatus`. Drives the
|
||||
/// status dot, "needs your input" notifications, and session resume.
|
||||
agent_session: Arc<Mutex<Option<AgentSessionState>>>,
|
||||
/// Command marks recorded by the reader thread from OSC 133, for the details
|
||||
/// panel's Outline. Positions are grid rows, so they can only be taken here
|
||||
/// on the client — the daemon has no grid.
|
||||
marks: crate::terminal::marks::Marks,
|
||||
reader_thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
@@ -325,6 +333,7 @@ impl RemoteTerminal {
|
||||
let auth_prompts: Arc<Mutex<VecDeque<(u64, AuthPromptKind)>>> =
|
||||
Arc::new(Mutex::new(VecDeque::new()));
|
||||
let ssh_phase: Arc<Mutex<Option<SshPhase>>> = Arc::new(Mutex::new(None));
|
||||
let marks = crate::terminal::marks::Marks::new();
|
||||
|
||||
let reader_thread = Self::spawn_reader(
|
||||
term.clone(),
|
||||
@@ -342,6 +351,7 @@ impl RemoteTerminal {
|
||||
shell_vi_mode: shell_vi_mode.clone(),
|
||||
auth: auth_prompts.clone(),
|
||||
phase: ssh_phase.clone(),
|
||||
marks: marks.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -366,6 +376,7 @@ impl RemoteTerminal {
|
||||
auto_supplied_password: false,
|
||||
agent,
|
||||
agent_session,
|
||||
marks,
|
||||
reader_thread: Some(reader_thread),
|
||||
})
|
||||
}
|
||||
@@ -408,6 +419,7 @@ impl RemoteTerminal {
|
||||
shell_vi_mode,
|
||||
auth,
|
||||
phase,
|
||||
marks,
|
||||
} = signals;
|
||||
// The client end of the visible-output path: keep it off the
|
||||
// efficiency cores (see `core::threads`).
|
||||
@@ -433,6 +445,10 @@ impl RemoteTerminal {
|
||||
// zle is reading (see the `zle_reading` field docs). Historical
|
||||
// Snapshot replays deliberately do not feed this tokenizer.
|
||||
let mut zle_tok = OscTokenizer::new(&[b"133"]);
|
||||
// Positional OSC 133 marks for the details panel's Outline. Unlike
|
||||
// the tokenizers above this one reports byte *offsets*, because a
|
||||
// mark's value is the grid row it lands on — see `terminal::marks`.
|
||||
let mut mark_scan = MarkScanner::new();
|
||||
// Bytes read but not yet framed, plus the recorded geometry
|
||||
// waiting for its paired Snapshot: the attach replay is a
|
||||
// `Size` → `Snapshot` pair per ring segment, and each pair
|
||||
@@ -490,11 +506,29 @@ impl RemoteTerminal {
|
||||
macro_rules! flush_batch {
|
||||
() => {
|
||||
if !out_batch.is_empty() {
|
||||
// Where the batch's OSC 133 marks land, so the
|
||||
// advance can stop at each one and read the grid
|
||||
// row it fell on. Scanned before the lock (it's a
|
||||
// pure byte pass) and normally empty — a batch
|
||||
// with no marks takes the single-advance path
|
||||
// below, exactly as before.
|
||||
let mut cuts: Vec<(usize, MarkEvent)> = Vec::new();
|
||||
mark_scan.feed(&out_batch, |off, ev| cuts.push((off, ev)));
|
||||
{
|
||||
let t0 = trace.then(std::time::Instant::now);
|
||||
let mut term = term.lock();
|
||||
let t1 = trace.then(std::time::Instant::now);
|
||||
processor.advance(&mut *term, &out_batch);
|
||||
if cuts.is_empty() {
|
||||
processor.advance(&mut *term, &out_batch);
|
||||
} else {
|
||||
let mut at = 0usize;
|
||||
for (off, ev) in cuts {
|
||||
processor.advance(&mut *term, &out_batch[at..off]);
|
||||
at = off;
|
||||
record_mark(&term, &marks, ev);
|
||||
}
|
||||
processor.advance(&mut *term, &out_batch[at..]);
|
||||
}
|
||||
if let (Some(t0), Some(t1)) = (t0, t1) {
|
||||
tr_lock_t += t1 - t0;
|
||||
tr_adv_t += t1.elapsed();
|
||||
@@ -920,6 +954,12 @@ impl RemoteTerminal {
|
||||
/// The third-party CLI coding agent (Claude Code, Codex, …) running in the
|
||||
/// pane's foreground, as last reported by the daemon, or `None`. Cheap cache
|
||||
/// read — detection runs daemon-side. See [`crate::core::cli_agent`].
|
||||
/// Command marks recorded from OSC 133, oldest first — the Outline's source.
|
||||
/// Cheap clone of a shared handle; the caller snapshots via `Marks::list`.
|
||||
pub fn marks(&self) -> crate::terminal::marks::Marks {
|
||||
self.marks.clone()
|
||||
}
|
||||
|
||||
pub fn foreground_agent(&self) -> Option<CLIAgent> {
|
||||
self.agent.lock().ok().and_then(|g| *g)
|
||||
}
|
||||
@@ -1324,6 +1364,42 @@ impl RemoteTerminal {
|
||||
}
|
||||
query(pane_id).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A pane's process tree and listening ports, for the details panel. One-shot
|
||||
/// over a short-lived control connection, like the forward queries — this is
|
||||
/// polled only while the panel is open, so it never rides the pane's hot
|
||||
/// output connection.
|
||||
pub fn query_procs(pane_id: u64) -> PaneProcs {
|
||||
fn query(pane_id: u64) -> anyhow::Result<PaneProcs> {
|
||||
let mut stream = connect()?;
|
||||
ClientMsg::QueryProcs { pane_id }.encode(&mut stream)?;
|
||||
match DaemonMsg::read(&mut stream)? {
|
||||
DaemonMsg::Procs(procs) => Ok(procs),
|
||||
other => Err(anyhow::anyhow!("unexpected reply to QueryProcs: {other:?}")),
|
||||
}
|
||||
}
|
||||
query(pane_id).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply one OSC 133 mark at the emulator's current position.
|
||||
///
|
||||
/// Called with the terminal lock held and the parser advanced to exactly the
|
||||
/// mark's byte, so `cursor.point.line` is the row the mark fell on. That row is
|
||||
/// converted to an index from the top of the scrollback, which is stable as long
|
||||
/// as history hasn't saturated — see the `terminal::marks` module docs.
|
||||
fn record_mark(term: &Term<EventProxy>, marks: &crate::terminal::marks::Marks, event: MarkEvent) {
|
||||
use alacritty_terminal::grid::Dimensions as _;
|
||||
match event {
|
||||
MarkEvent::Prompt => {
|
||||
let grid = term.grid();
|
||||
let row = grid.history_size() as i64 - grid.display_offset() as i64
|
||||
+ i64::from(grid.cursor.point.line.0);
|
||||
marks.begin(row, String::new());
|
||||
}
|
||||
MarkEvent::Command(cmd) => marks.set_text(cmd),
|
||||
MarkEvent::Done(exit) => marks.finish(exit),
|
||||
}
|
||||
}
|
||||
|
||||
fn daemon_disconnected_before_spawn_reply(err: &anyhow::Error) -> bool {
|
||||
@@ -2347,6 +2423,55 @@ mod tests {
|
||||
assert!(poll(&|s| s.is_none()), "a None report clears the session");
|
||||
}
|
||||
|
||||
/// End-to-end check of the Outline's data path: OSC 133 marks arriving in the
|
||||
/// output stream must land in `Marks` with the *grid row they fell on*, not
|
||||
/// the row at the end of the batch. This is the whole reason the reader
|
||||
/// splits its advance at mark offsets, so it's worth an integration test —
|
||||
/// a regression here looks fine (marks appear) but scrolls to the wrong place.
|
||||
#[test]
|
||||
fn marks_record_the_row_each_one_landed_on() {
|
||||
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
|
||||
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
|
||||
let poll = |want: usize| {
|
||||
for _ in 0..200 {
|
||||
if term.marks().list().len() == want {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
false
|
||||
};
|
||||
|
||||
// Two full prompt cycles in ONE batch, separated by output lines. If the
|
||||
// reader advanced the batch in a single pass and read the cursor after,
|
||||
// both marks would report the same (final) row.
|
||||
let mut stream = Vec::new();
|
||||
stream.extend_from_slice(b"\x1b]133;A\x07"); // prompt 1 at row 0
|
||||
stream.extend_from_slice(b"\x1b]133;C;echo one\x07");
|
||||
stream.extend_from_slice(b"one\r\n");
|
||||
stream.extend_from_slice(b"\x1b]133;D;0\x07");
|
||||
stream.extend_from_slice(b"\x1b]133;A\x07"); // prompt 2, two rows down
|
||||
stream.extend_from_slice(b"\x1b]133;C;false\x07");
|
||||
stream.extend_from_slice(b"\r\n");
|
||||
stream.extend_from_slice(b"\x1b]133;D;1\x07");
|
||||
DaemonMsg::Output(stream).encode(&mut daemon_side).unwrap();
|
||||
daemon_side.flush().unwrap();
|
||||
|
||||
assert!(poll(2), "both commands recorded");
|
||||
let marks = term.marks().list();
|
||||
assert_eq!(marks[0].text, "echo one");
|
||||
assert_eq!(marks[0].exit, Some(0));
|
||||
assert_eq!(marks[1].text, "false");
|
||||
assert_eq!(marks[1].exit, Some(1), "a failure keeps its exit code");
|
||||
assert!(
|
||||
marks[1].row > marks[0].row,
|
||||
"the second prompt is further down the scrollback ({} vs {}) — equal rows \
|
||||
would mean the advance wasn't split at the marks",
|
||||
marks[0].row,
|
||||
marks[1].row
|
||||
);
|
||||
}
|
||||
|
||||
/// The typeahead wipe (^U) may only be written once zle actually reads the
|
||||
/// keyboard; the client learns that from a *live* `133;B` (prompt end) in
|
||||
/// the output stream. `133;D` (command done, but precmd hooks still running
|
||||
|
||||
@@ -2368,6 +2368,10 @@ impl TerminalView {
|
||||
pub fn clear_scrollback(&mut self, cx: &mut Context<Self>) {
|
||||
self.terminal.term.lock().grid_mut().clear_history();
|
||||
self.scroll_frac = 0.;
|
||||
// Every mark's row indexed into the history that just went away, so the
|
||||
// Outline's positions are now meaningless. Drop them rather than leave
|
||||
// rows that scroll somewhere arbitrary.
|
||||
self.terminal.marks().clear();
|
||||
self.terminal.write(vec![0x0c_u8]); // Ctrl+L
|
||||
cx.notify();
|
||||
}
|
||||
@@ -3785,6 +3789,40 @@ impl TerminalView {
|
||||
/// pixel-smooth: whole lines go to the emulator's `display_offset`, the
|
||||
/// remainder stays in `scroll_frac` and shifts the paint. The position may
|
||||
/// come to rest between line boundaries, like a native scroll view.
|
||||
/// The pane's OSC 133 command marks, newest last — the Outline's rows.
|
||||
pub fn command_marks(&self) -> Vec<crate::terminal::marks::CommandMark> {
|
||||
self.terminal.marks().list()
|
||||
}
|
||||
|
||||
/// Scroll so the command recorded at `row` sits near the top of the viewport.
|
||||
/// Returns `false` when the mark has aged out of the scrollback, so the
|
||||
/// caller can say so rather than leaving the user staring at an unchanged
|
||||
/// screen wondering whether the click registered.
|
||||
///
|
||||
/// `row` is an index from the top of history, which drifts once the
|
||||
/// scrollback saturates (see the `terminal::marks` docs). A drifted mark
|
||||
/// still scrolls *somewhere* — it just may not be the exact prompt — so the
|
||||
/// only failure reported here is a row that has fallen off entirely.
|
||||
pub fn scroll_to_mark(&mut self, row: i64, cx: &mut Context<Self>) -> bool {
|
||||
use alacritty_terminal::grid::Dimensions as _;
|
||||
let mut term = self.terminal.term.lock();
|
||||
let history = term.grid().history_size() as i64;
|
||||
if row < 0 || row > history + term.grid().screen_lines() as i64 {
|
||||
return false;
|
||||
}
|
||||
// `display_offset` counts *up* from the bottom of history, so the offset
|
||||
// that puts `row` at the viewport's top line is its distance from there.
|
||||
let target = (history - row).max(0);
|
||||
let current = term.grid().display_offset() as i64;
|
||||
term.scroll_display(Scroll::Delta((target - current) as i32));
|
||||
drop(term);
|
||||
// A jump lands wherever it lands; the fractional offset is a smooth-scroll
|
||||
// artifact and would otherwise shift the paint off the line boundary.
|
||||
self.scroll_frac = 0.;
|
||||
cx.notify();
|
||||
true
|
||||
}
|
||||
|
||||
fn smooth_scroll(&mut self, delta: f32, cx: &mut Context<Self>) {
|
||||
let mut term = self.terminal.term.lock();
|
||||
let offset = term.grid().display_offset();
|
||||
|
||||
+239
-18
@@ -81,6 +81,45 @@ const RECORD_COMMIT_DELAY_MS: u64 = 650;
|
||||
/// they all line up (and reach the very top of the window).
|
||||
pub(crate) const TITLE_BAR_HEIGHT: f32 = 40.;
|
||||
|
||||
/// The chrome tile rhythm: a 30px hit box around a 15px glyph, so the glyph sits
|
||||
/// [`TILE_PAD`] inside the box on every edge. Alignment is a property of what you
|
||||
/// can *see*, so anything lining a tile up with text or with the window edge
|
||||
/// subtracts `TILE_PAD` from the inset it wants — otherwise the invisible hit box
|
||||
/// lands on the line and the glyph reads 7.5px short of it.
|
||||
pub(crate) const TILE_SIZE: f32 = 30.;
|
||||
pub(crate) const TILE_GLYPH: f32 = 15.;
|
||||
pub(crate) const TILE_PAD: f32 = (TILE_SIZE - TILE_GLYPH) / 2.;
|
||||
|
||||
/// The one content inset the whole window aligns to: the rail's text and icons,
|
||||
/// the title bar's chrome glyphs, and the side panels all start (or end) here, so
|
||||
/// every vertical edge in the chrome falls on one of two lines rather than the
|
||||
/// five slightly different ones each surface used to pick for itself.
|
||||
pub(crate) const CONTENT_INSET: f32 = 12.;
|
||||
|
||||
/// What gpui-component's `TitleBar` already insets its content by, to clear the
|
||||
/// window controls: 80px on macOS (traffic lights on the left), 12px elsewhere
|
||||
/// (controls on the right). Anything laid out *inside* the bar therefore starts
|
||||
/// here, not at the window edge.
|
||||
pub(crate) const TITLE_BAR_LEAD: f32 = if cfg!(target_os = "macos") { 80. } else { 12. };
|
||||
|
||||
/// Left offset for the tile group that sits beside the window controls.
|
||||
///
|
||||
/// On macOS the thing that can collide with the traffic lights is the tile's
|
||||
/// *hit box* — it paints a background on hover and when selected, 7.5px wider
|
||||
/// than the glyph on each side — so this aligns the box, not the glyph, and the
|
||||
/// bar's own 80px lead is already exactly the clearance macOS defines for that.
|
||||
/// Hence zero: pulling back into the reserve to "hug" the lights only made the
|
||||
/// hover capsule touch them. Off macOS the controls are on the right, nothing is
|
||||
/// there to clear, and the group aligns its glyph to the content inset like the
|
||||
/// rest of the chrome.
|
||||
pub(crate) fn title_bar_hug_offset() -> f32 {
|
||||
if cfg!(target_os = "macos") {
|
||||
0.
|
||||
} else {
|
||||
CONTENT_INSET - TILE_PAD - TITLE_BAR_LEAD
|
||||
}
|
||||
}
|
||||
|
||||
/// One tab: a split-pane tree plus an optional user-assigned name. Settings is
|
||||
/// no longer a tab — it's a full-window overlay (`Tty7App::settings`), so every
|
||||
/// tab is a real terminal tab.
|
||||
@@ -121,6 +160,21 @@ pub struct Tab {
|
||||
/// flickering through the Scratch group and back. A `RefCell` because the
|
||||
/// sidebar refreshes it during render, which only has `&Tab`.
|
||||
pub(crate) sidebar_group: std::cell::RefCell<Option<std::path::PathBuf>>,
|
||||
/// Which of the two full-column overlays (code panel, diff) was raised last.
|
||||
/// They deliberately have no fixed precedence: whichever the user just acted
|
||||
/// on paints on top, so opening a diff over the editor shows the diff, and
|
||||
/// clicking a file in the tree behind it brings the editor back — the same
|
||||
/// "click it, it comes forward" rule as window stacking.
|
||||
pub(crate) overlay_top: OverlayTop,
|
||||
}
|
||||
|
||||
/// Stacking order for the two overlays that cover the whole column. See
|
||||
/// [`Tab::overlay_top`].
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub(crate) enum OverlayTop {
|
||||
#[default]
|
||||
Code,
|
||||
Diff,
|
||||
}
|
||||
|
||||
impl Tab {
|
||||
@@ -131,6 +185,7 @@ impl Tab {
|
||||
last_focused: None,
|
||||
diff_overlay: None,
|
||||
code: None,
|
||||
overlay_top: OverlayTop::default(),
|
||||
sidebar_group: std::cell::RefCell::new(None),
|
||||
}
|
||||
}
|
||||
@@ -144,6 +199,23 @@ impl Tab {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pane the right panel's detail should describe. Not simply the
|
||||
/// focused leaf: opening the panel, the diff overlay or the editor moves
|
||||
/// focus off the terminal entirely, and `focused_or_first` would then fall
|
||||
/// back to the *first* pane — so a split's second pane would silently swap
|
||||
/// the panel's cwd the moment you interacted with the panel. Falling back to
|
||||
/// `focus_target` uses the pane that held focus when it left instead, which
|
||||
/// is the one the user still thinks of as active.
|
||||
pub(crate) fn detail_pane(
|
||||
&self,
|
||||
window: &Window,
|
||||
cx: &gpui::App,
|
||||
) -> Option<Entity<TerminalView>> {
|
||||
self.pane
|
||||
.focused_leaf(window, cx)
|
||||
.or_else(|| self.focus_target())
|
||||
}
|
||||
|
||||
/// The title used to derive the tab label: the pane the tab is working in.
|
||||
/// Only the *active* tab has a live focused pane, so for an inactive tab
|
||||
/// (which holds no window focus) we fall back to the pane it last had
|
||||
@@ -354,6 +426,8 @@ pub struct Tty7App {
|
||||
pub(crate) loopback_panel: LoopbackForwardPanelState,
|
||||
/// Pane-contextual SFTP file panel (WS5), bound to a focused native-SSH pane.
|
||||
pub(crate) sftp_panel: crate::ui::sftp::SftpPanelState,
|
||||
/// Right detail panel (info / changes / files) docked beside the terminal.
|
||||
pub(crate) right_panel: crate::ui::right_panel::RightPanelState,
|
||||
/// Local project file tree (left column of the body).
|
||||
pub(crate) file_tree: crate::ui::file_tree::FileTreeState,
|
||||
/// Code-editor panel (right column of the body).
|
||||
@@ -365,14 +439,22 @@ pub struct Tty7App {
|
||||
pub(crate) sidebar_width: Rc<Cell<f32>>,
|
||||
/// Whether the sidebar's resize handle is currently held.
|
||||
pub(crate) sidebar_dragging: Rc<Cell<bool>>,
|
||||
/// Right detail panel width (px) and drag state, held in shared `Cell`s for
|
||||
/// exactly the reason `sidebar_width` is — see there.
|
||||
pub(crate) right_panel_width: Rc<Cell<f32>>,
|
||||
pub(crate) right_panel_dragging: Rc<Cell<bool>>,
|
||||
/// Scroll handle for the sidebar's row list, so activating a tab scrolls its
|
||||
/// row into view.
|
||||
pub(crate) sidebar_scroll: gpui::ScrollHandle,
|
||||
/// Filter box in the sidebar's top control bar ("Search tabs…"); its text
|
||||
/// narrows the visible rows by fuzzy-ish substring match on the tab label.
|
||||
pub(crate) sidebar_search: Entity<InputState>,
|
||||
/// Live filter for the detail panel's Files tab (its own box, so filtering
|
||||
/// the tree never disturbs the tab list's filter and vice versa).
|
||||
pub(crate) file_search: Entity<InputState>,
|
||||
/// Re-renders the sidebar on each search keystroke so results narrow live.
|
||||
_sidebar_search_sub: Subscription,
|
||||
_file_search_sub: Subscription,
|
||||
/// `Some` while the settings page is open. Settings is a full-window overlay
|
||||
/// (not a tab), so it covers the tab rail / title bar and never clutters the
|
||||
/// tab list. Holds all the settings widget state + its subscriptions.
|
||||
@@ -509,6 +591,7 @@ impl Tty7App {
|
||||
let mf_target_port = cx.new(|cx| InputState::new(window, cx).placeholder("80"));
|
||||
let mf_description = cx.new(|cx| InputState::new(window, cx).placeholder("description"));
|
||||
let sidebar_width = cx.global::<Config>().sidebar_width;
|
||||
let right_panel_width = cx.global::<Config>().right_panel_width;
|
||||
// Live-apply hot-reloaded config: the watcher in `main.rs` swaps the
|
||||
// `Config` global on every `config.json` change, which fires this. The
|
||||
// window-aware variant so the reload can re-run `apply_theme` with the
|
||||
@@ -526,6 +609,10 @@ impl Tty7App {
|
||||
let git_status_watch =
|
||||
cx.observe_global::<crate::terminal::git_status::GitStatusCache>(|this, cx| {
|
||||
this.maybe_refresh_diff_overlay(cx);
|
||||
// Same trigger, same freshness: the right panel's Changes list is
|
||||
// the sidebar's `+N −M` expanded, so it re-probes whenever those
|
||||
// numbers do rather than going stale behind them.
|
||||
this.right_panel_invalidate();
|
||||
cx.notify();
|
||||
});
|
||||
// Any real keypress means "chord, not a bare hold": cancel the held-⌘
|
||||
@@ -577,6 +664,12 @@ impl Tty7App {
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
let file_search = cx.new(|cx| InputState::new(window, cx).placeholder("Search files…"));
|
||||
let file_search_sub = cx.subscribe_in(&file_search, window, |_this, _i, ev, _w, cx| {
|
||||
if matches!(ev, InputEvent::Change) {
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
let app = Self {
|
||||
tabs,
|
||||
active,
|
||||
@@ -615,13 +708,18 @@ impl Tty7App {
|
||||
mf_editing: None,
|
||||
},
|
||||
sftp_panel,
|
||||
right_panel: Default::default(),
|
||||
file_tree,
|
||||
editor,
|
||||
sidebar_width: Rc::new(Cell::new(sidebar_width)),
|
||||
sidebar_dragging: Rc::new(Cell::new(false)),
|
||||
right_panel_width: Rc::new(Cell::new(right_panel_width)),
|
||||
right_panel_dragging: Rc::new(Cell::new(false)),
|
||||
sidebar_scroll: gpui::ScrollHandle::new(),
|
||||
sidebar_search,
|
||||
_sidebar_search_sub: sidebar_search_sub,
|
||||
file_search,
|
||||
_file_search_sub: file_search_sub,
|
||||
settings: None,
|
||||
ssh_prompt: crate::ui::ssh_prompt::SshPromptState::new(cx),
|
||||
ssh_close_confirm: None,
|
||||
@@ -767,6 +865,7 @@ impl Tty7App {
|
||||
last_focused: None,
|
||||
diff_overlay: None,
|
||||
code: None,
|
||||
overlay_top: OverlayTop::default(),
|
||||
// Keep the group it had when closed — the row reappears where
|
||||
// it lived instead of flashing through Scratch.
|
||||
sidebar_group: std::cell::RefCell::new(st.sidebar_group),
|
||||
@@ -1809,6 +1908,30 @@ impl Tty7App {
|
||||
self.set_tab_bar_position(next, cx);
|
||||
}
|
||||
|
||||
/// `ToggleLeftPanel` (⌘B): collapse/expand the left rail in place, persisting
|
||||
/// the choice. In `Top` mode there is no rail to collapse, so this switches to
|
||||
/// `Left` and shows it — the shortcut always means "give me the sidebar".
|
||||
pub(crate) fn toggle_left_panel(&mut self, cx: &mut Context<Self>) {
|
||||
let cfg = cx.global::<Config>();
|
||||
let (pos, collapsed) = match cfg.tab_bar_position {
|
||||
TabBarPosition::Top => (TabBarPosition::Left, false),
|
||||
TabBarPosition::Left => (TabBarPosition::Left, !cfg.sidebar_collapsed),
|
||||
};
|
||||
self.update_config(cx, |cfg| {
|
||||
cfg.tab_bar_position = pos;
|
||||
cfg.sidebar_collapsed = collapsed;
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the left rail is actually on screen: `Left` mode, not collapsed,
|
||||
/// and at least one tab (the home page has no rail). The layout, the title
|
||||
/// strip and the collapse button all derive from this one predicate.
|
||||
pub(crate) fn left_panel_open(&self, cx: &gpui::App) -> bool {
|
||||
matches!(cx.global::<Config>().tab_bar_position, TabBarPosition::Left)
|
||||
&& !cx.global::<Config>().sidebar_collapsed
|
||||
&& !self.tabs.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn set_notify_mode(
|
||||
&mut self,
|
||||
mode: crate::core::config::NotifyMode,
|
||||
@@ -2798,6 +2921,9 @@ impl Tty7App {
|
||||
ToggleMaximizePane => self.toggle_maximize(window, cx),
|
||||
ToggleFullscreen => window.toggle_fullscreen(),
|
||||
ToggleTabSidebar => self.toggle_tab_sidebar(cx),
|
||||
ToggleLeftPanel => self.toggle_left_panel(cx),
|
||||
ToggleRightPanel => self.toggle_right_panel(cx),
|
||||
ShowRightPanel(tab) => self.set_right_panel_tab(tab, cx),
|
||||
ResetFontSize => self.reset_font_size(cx),
|
||||
FindInTerminal => {
|
||||
// Open the search bar on the pane focus just returned to (the
|
||||
@@ -3401,6 +3527,8 @@ impl Tty7App {
|
||||
// Keep the runtime sidebar width in step with the config (an external
|
||||
// edit to `config.json`, or our own drag-end persist which re-fires this).
|
||||
self.sidebar_width.set(cx.global::<Config>().sidebar_width);
|
||||
self.right_panel_width
|
||||
.set(cx.global::<Config>().right_panel_width);
|
||||
if font_size != self.font_size {
|
||||
self.font_size = font_size;
|
||||
let px_size = px(font_size);
|
||||
@@ -4038,8 +4166,13 @@ impl Render for Tty7App {
|
||||
// rail never appears.
|
||||
let vertical = matches!(cx.global::<Config>().tab_bar_position, TabBarPosition::Left)
|
||||
&& !self.tabs.is_empty();
|
||||
// The rail can be collapsed away without leaving `Left` mode. When it is,
|
||||
// the layout below has no left column, so the title strip takes over the
|
||||
// rail's jobs: it reserves the traffic lights and carries the sidebar's
|
||||
// own controls (new tab + expand) at its left edge.
|
||||
let rail = vertical && !cx.global::<Config>().sidebar_collapsed;
|
||||
let strip = self.tab_strip(!vertical, window, cx);
|
||||
let sidebar = vertical.then(|| self.tab_sidebar(window, cx));
|
||||
let sidebar = rail.then(|| self.tab_sidebar(window, cx));
|
||||
// Gate the pane action buttons (tunnel / SFTP) + their panels to a
|
||||
// connected native-SSH pane; a foreground `ssh` or a still-connecting
|
||||
// session shows only the top-left status strip, no action buttons.
|
||||
@@ -4135,19 +4268,40 @@ impl Render for Tty7App {
|
||||
// "New Worktree Tab" confirmation sheet (from the tab context menu).
|
||||
.when_some(self.render_worktree_prompt_overlay(cx), |this, el| {
|
||||
this.child(el)
|
||||
})
|
||||
// Code panel: a full-body overlay ([file tree | editor], IDE-style)
|
||||
// that covers the terminal like the settings/diff overlays do — the
|
||||
// terminal underneath keeps its size, so toggling never reflows it.
|
||||
// Covers only the body: the tab sidebar stays visible and switches
|
||||
// tabs (which re-roots the tree) while the panel is up.
|
||||
.when_some(self.render_code_overlay(window, cx), |this, el| {
|
||||
this.child(el)
|
||||
})
|
||||
// Working-tree diff overlay (clicked from a sidebar git line) —
|
||||
// last child, so it paints over every pane-contextual element
|
||||
// above. It covers only the body: the sidebar stays interactive.
|
||||
.when_some(self.render_diff_overlay(cx), |this, el| this.child(el));
|
||||
});
|
||||
|
||||
// Working-tree diff overlay — mounted on the *column*, not on
|
||||
// `body_area`, so it covers the title strip too and reads as one
|
||||
// surface the way the code overlay does. Like that overlay it stops at
|
||||
// the rail and the right panel (both are siblings), which is the point:
|
||||
// the sidebar's git lines stay clickable to switch repo, and the
|
||||
// Changes list stays put so you can walk down it file by file.
|
||||
let diff_overlay = self.render_diff_overlay(cx);
|
||||
|
||||
// Code panel: an immersive overlay ([file tree | editor], IDE-style)
|
||||
// covering the title strip *and* the terminal — the whole column right
|
||||
// of the tab sidebar — so nothing of the terminal chrome distracts.
|
||||
// The terminal underneath keeps its size (no PTY resize/reflow), and
|
||||
// the sidebar stays visible: switching tabs re-roots the tree.
|
||||
let code_overlay = self.render_code_overlay(window, cx);
|
||||
|
||||
// The two column overlays, ordered so the one the user last acted on is
|
||||
// the later child and therefore paints on top. Neither outranks the
|
||||
// other by construction.
|
||||
let overlays: Vec<gpui::AnyElement> = {
|
||||
let mut pair = vec![
|
||||
(OverlayTop::Diff, diff_overlay),
|
||||
(OverlayTop::Code, code_overlay),
|
||||
];
|
||||
if self
|
||||
.tabs
|
||||
.get(self.active)
|
||||
.is_some_and(|t| t.overlay_top == OverlayTop::Diff)
|
||||
{
|
||||
pair.reverse();
|
||||
}
|
||||
pair.into_iter().filter_map(|(_, el)| el).collect()
|
||||
};
|
||||
|
||||
// The two layouts. Horizontal (default): a column of [title bar / body].
|
||||
// Vertical: the rail is a full-height *left column* that reaches the very
|
||||
@@ -4155,6 +4309,12 @@ impl Render for Tty7App {
|
||||
// title strip and terminal stacked in the right column. That way the rail
|
||||
// surface has no seam with the title bar and reads as one continuous
|
||||
// panel.
|
||||
// The right detail panel is a full-height column, not a box under the title
|
||||
// bar: it carries its own title-bar-height top zone (tab row + the window's
|
||||
// corner chrome) exactly like the rail does on the left, so its surface
|
||||
// runs unbroken from the very top of the window. Anything less leaves a
|
||||
// horizontal seam where the panel's grey starts under the terminal's bar.
|
||||
let right_panel = self.render_right_panel(window, cx);
|
||||
let main_layout = match sidebar {
|
||||
Some(sidebar) => div()
|
||||
.flex_1()
|
||||
@@ -4169,18 +4329,40 @@ impl Render for Tty7App {
|
||||
.min_w_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
// Anchor for the code overlay: it fills this column
|
||||
// (title strip + body) — and, since the panel is a sibling
|
||||
// rather than a child, stops short of the panel for free.
|
||||
.relative()
|
||||
.child(title_bar)
|
||||
.child(body_area),
|
||||
.child(body_area)
|
||||
.children(overlays),
|
||||
)
|
||||
.when_some(right_panel, |this, panel| this.child(panel))
|
||||
.into_any_element(),
|
||||
// Horizontal-tabs mode has no rail, but the panel is still a column
|
||||
// beside the stacked [title bar / body], for the same reason: it has to
|
||||
// own its own top zone to read as one surface.
|
||||
None => div()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(title_bar)
|
||||
.child(body_area)
|
||||
.flex_row()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.relative()
|
||||
.child(title_bar)
|
||||
.child(body_area)
|
||||
// Both overlays cover the whole window face here (their
|
||||
// content pads down past the traffic lights — see
|
||||
// `render_code_overlay` and `diff_header`).
|
||||
.children(overlays),
|
||||
)
|
||||
.when_some(right_panel, |this, panel| this.child(panel))
|
||||
.into_any_element(),
|
||||
};
|
||||
|
||||
@@ -4351,6 +4533,24 @@ impl Render for Tty7App {
|
||||
.on_action(
|
||||
cx.listener(|this, _: &ToggleTabSidebar, _window, cx| this.toggle_tab_sidebar(cx)),
|
||||
)
|
||||
.on_action(
|
||||
cx.listener(|this, _: &ToggleLeftPanel, _window, cx| this.toggle_left_panel(cx)),
|
||||
)
|
||||
.on_action(
|
||||
cx.listener(|this, _: &ToggleRightPanel, _window, cx| this.toggle_right_panel(cx)),
|
||||
)
|
||||
.on_action(cx.listener(|this, _: &ShowRightPanelInfo, _window, cx| {
|
||||
this.set_right_panel_tab(crate::core::config::RightPanelTab::Info, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &ShowRightPanelOutline, _window, cx| {
|
||||
this.set_right_panel_tab(crate::core::config::RightPanelTab::Outline, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &ShowRightPanelChanges, _window, cx| {
|
||||
this.set_right_panel_tab(crate::core::config::RightPanelTab::Changes, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &ShowRightPanelFiles, _window, cx| {
|
||||
this.set_right_panel_tab(crate::core::config::RightPanelTab::Files, cx)
|
||||
}))
|
||||
.on_action(
|
||||
cx.listener(|this, _: &OpenSettings, window, cx| this.toggle_settings(window, cx)),
|
||||
)
|
||||
@@ -4500,6 +4700,7 @@ fn tabs_from_session(
|
||||
last_focused: None,
|
||||
diff_overlay: None,
|
||||
code: None,
|
||||
overlay_top: OverlayTop::default(),
|
||||
// Seed the sticky group from the saved session so the sidebar
|
||||
// renders grouped on the first frame; the first landed probe
|
||||
// corrects it if the tab's repo changed while we were gone.
|
||||
@@ -4613,9 +4814,28 @@ fn new_terminal(
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
watch_pane_focus(&view, window, cx);
|
||||
view
|
||||
}
|
||||
|
||||
/// Re-render the app whenever `view` takes focus. Nothing else does this: a
|
||||
/// pane owns its own focus handle, so clicking between splits notifies the
|
||||
/// *pane*, not us, and any chrome that describes "the active pane" — the right
|
||||
/// panel's Info and Changes tabs — would keep showing the pane you left until
|
||||
/// some unrelated notify happened to repaint. Focus changes are user-paced, so
|
||||
/// the extra frames are free.
|
||||
fn watch_pane_focus(view: &Entity<TerminalView>, window: &mut Window, cx: &mut Context<Tty7App>) {
|
||||
let handle = view.read(cx).focus_handle.clone();
|
||||
let app = cx.weak_entity();
|
||||
window
|
||||
.on_focus_in(&handle, cx, move |_window, cx| {
|
||||
if let Some(app) = app.upgrade() {
|
||||
app.update(cx, |_, cx| cx.notify());
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Build a native (russh) SSH terminal view for `spec`, wiring the same
|
||||
/// per-pane subscriptions (`ChildExited`, `AuthPromptReady`) as [`new_terminal`]
|
||||
/// so it participates in auto-close and the in-pane auth sheets. Mirrors
|
||||
@@ -4647,6 +4867,7 @@ pub(crate) fn new_terminal_native(
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
watch_pane_focus(&view, window, cx);
|
||||
Ok(view)
|
||||
}
|
||||
|
||||
|
||||
+265
-146
@@ -68,6 +68,9 @@ pub(crate) struct OpenFile {
|
||||
/// timer) on every keystroke.
|
||||
change_task: Option<gpui::Task<()>>,
|
||||
_sub: Subscription,
|
||||
/// Repaints the app when the input notifies (cursor moves, scrolls…) so
|
||||
/// the status bar's Ln/Col stays live.
|
||||
_observe: Subscription,
|
||||
}
|
||||
|
||||
impl OpenFile {
|
||||
@@ -104,7 +107,13 @@ pub(crate) struct TabCode {
|
||||
impl TabCode {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
visible: true,
|
||||
// Born hidden. This state used to be created only by opening the
|
||||
// overlay, so defaulting to visible was harmless; now the right
|
||||
// panel's Files tab creates it just to hold the tree's roots and
|
||||
// expansion, and a default of `true` popped an empty editor open
|
||||
// ("No file open") the moment you looked at the tree. Every path
|
||||
// that actually wants the overlay sets `visible` itself.
|
||||
visible: false,
|
||||
files: Vec::new(),
|
||||
active: 0,
|
||||
references: None,
|
||||
@@ -257,6 +266,16 @@ impl Tty7App {
|
||||
self.tabs.get_mut(self.active)?.code.as_deref_mut()
|
||||
}
|
||||
|
||||
/// Like [`tab_code_mut`], but creates the state instead of returning `None`.
|
||||
/// The panel state used to be born with the code overlay, so anything that
|
||||
/// needed it could assume the overlay had been opened at least once — no
|
||||
/// longer true now that the right panel's Files tab renders the same tree
|
||||
/// without ever opening the overlay.
|
||||
pub(crate) fn tab_code_mut_or_init(&mut self) -> Option<&mut TabCode> {
|
||||
let tab = self.tabs.get_mut(self.active)?;
|
||||
Some(tab.code.get_or_insert_with(|| Box::new(TabCode::new())))
|
||||
}
|
||||
|
||||
/// Whether the active tab's code panel is currently shown.
|
||||
pub(crate) fn code_panel_visible(&self) -> bool {
|
||||
self.tab_code().is_some_and(|c| c.visible)
|
||||
@@ -317,12 +336,21 @@ impl Tty7App {
|
||||
if self.tabs.get(self.active).is_none() {
|
||||
return;
|
||||
}
|
||||
// Opening a file is an act on the editor, so it comes forward — the
|
||||
// file tree lives in the right panel and stays clickable even while the
|
||||
// diff overlay covers the column.
|
||||
self.raise_code_overlay();
|
||||
let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
if let Some(code) = self.tab_code_mut()
|
||||
&& let Some(ix) = code.files.iter().position(|f| f.path == path)
|
||||
{
|
||||
code.active = ix;
|
||||
code.visible = true;
|
||||
// Activating always surfaces to the front of the strip: the strip
|
||||
// is MRU-ordered and only its head fits on screen (see
|
||||
// `render_editor_tabs`), so the active file must live there.
|
||||
let f = code.files.remove(ix);
|
||||
code.files.insert(0, f);
|
||||
code.active = 0;
|
||||
self.focus_editor(window, cx);
|
||||
cx.notify();
|
||||
return;
|
||||
@@ -442,19 +470,25 @@ impl Tty7App {
|
||||
.get_mut(self.active)
|
||||
.expect("checked at function entry");
|
||||
let code = tab.code.get_or_insert_with(|| Box::new(TabCode::new()));
|
||||
code.files.push(OpenFile {
|
||||
path,
|
||||
input,
|
||||
dirty: false,
|
||||
disk_mtime: mtime,
|
||||
conflict: false,
|
||||
preview: false,
|
||||
wrap: false,
|
||||
lsp,
|
||||
change_task: None,
|
||||
_sub: sub,
|
||||
});
|
||||
code.active = code.files.len() - 1;
|
||||
let observe = cx.observe(&input, |_, _, cx| cx.notify());
|
||||
// New files join at the front of the MRU strip (always visible).
|
||||
code.files.insert(
|
||||
0,
|
||||
OpenFile {
|
||||
path,
|
||||
input,
|
||||
dirty: false,
|
||||
disk_mtime: mtime,
|
||||
conflict: false,
|
||||
preview: false,
|
||||
wrap: false,
|
||||
lsp,
|
||||
change_task: None,
|
||||
_sub: sub,
|
||||
_observe: observe,
|
||||
},
|
||||
);
|
||||
code.active = 0;
|
||||
code.visible = true;
|
||||
self.editor_rebuild_watcher();
|
||||
self.focus_editor(window, cx);
|
||||
@@ -467,6 +501,20 @@ impl Tty7App {
|
||||
/// drops it. Opening re-roots the file tree from the tab's panes and
|
||||
/// focuses the panel; closing hands focus back to the terminal.
|
||||
pub(crate) fn toggle_code_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.tabs.get_mut(self.active) else {
|
||||
return;
|
||||
};
|
||||
// Buried under the diff overlay, this shortcut means "come forward" —
|
||||
// hiding a panel the user can't see would look like it did nothing.
|
||||
let buried = tab.overlay_top == crate::ui::app::OverlayTop::Diff
|
||||
&& tab.diff_overlay.is_some()
|
||||
&& tab.code.as_ref().is_some_and(|c| c.visible);
|
||||
tab.overlay_top = crate::ui::app::OverlayTop::Code;
|
||||
if buried {
|
||||
self.focus_editor(window, cx);
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
let Some(tab) = self.tabs.get_mut(self.active) else {
|
||||
return;
|
||||
};
|
||||
@@ -488,6 +536,14 @@ impl Tty7App {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Bring the code overlay in front of the diff overlay. See
|
||||
/// [`Tab::overlay_top`](crate::ui::app::Tab).
|
||||
fn raise_code_overlay(&mut self) {
|
||||
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||
tab.overlay_top = crate::ui::app::OverlayTop::Code;
|
||||
}
|
||||
}
|
||||
|
||||
/// Focus the active file's text input (e.g. right after opening a file).
|
||||
fn focus_editor(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(f) = self.tab_code().and_then(|c| c.active_file()) {
|
||||
@@ -890,7 +946,10 @@ impl Tty7App {
|
||||
}
|
||||
Some(f) => {
|
||||
let input = f.input.clone();
|
||||
// `appearance(false)`: no border/background of its own — the
|
||||
// buffer sits flush in the panel instead of in a rounded box.
|
||||
Input::new(&input)
|
||||
.appearance(false)
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_size(cx.theme().mono_font_size)
|
||||
.size_full()
|
||||
@@ -908,15 +967,19 @@ impl Tty7App {
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.h_full()
|
||||
.child(self.render_editor_tabs(window, cx))
|
||||
.child(self.render_editor_header(cx))
|
||||
.when_some(conflict_banner, |this, b| this.child(b))
|
||||
.child(div().flex_1().min_h_0().child(body))
|
||||
.when_some(references, |this, drawer| this.child(drawer));
|
||||
|
||||
Some(
|
||||
h_flex()
|
||||
v_flex()
|
||||
.id("code-panel")
|
||||
.absolute()
|
||||
// Fills its column, which is now everything *except* the detail
|
||||
// panel — the panel is a sibling of that column, not a child of it,
|
||||
// so the tree that opens files stays visible beside the editor
|
||||
// without the overlay needing to know the panel's width.
|
||||
.inset_0()
|
||||
// The overlay must swallow input to the terminal behind it.
|
||||
.occlude()
|
||||
@@ -928,13 +991,185 @@ impl Tty7App {
|
||||
this.toggle_code_panel(window, cx);
|
||||
}
|
||||
}))
|
||||
.child(self.render_file_tree_column(window, cx))
|
||||
.child(self.render_tree_divider(cx))
|
||||
.child(editor_col)
|
||||
// No top inset: the header row below *is* the title bar's row, and
|
||||
// it clears the window controls itself (see `render_editor_header`).
|
||||
// Padding the whole overlay down would cost a blank 40px band and
|
||||
// still misalign the editor's top edge with the panel's tab row.
|
||||
// No tree column here: the right panel owns the file tree now, and
|
||||
// the overlay stops short of it (see the `right` inset above), so
|
||||
// the tree stays visible beside the editor instead of being
|
||||
// duplicated inside it.
|
||||
.child(h_flex().flex_1().min_h_0().w_full().child(editor_col))
|
||||
.child(self.render_code_status_bar(window, cx))
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The editor's one header row: which file is open, and a way back to the
|
||||
/// terminal. Not a tab strip — the file tree is the switcher now, so this only
|
||||
/// has to answer "what am I looking at" without earning a row of chrome for
|
||||
/// every buffer that was ever opened. Sits on the title bar's line and matches
|
||||
/// its height, so the editor's top edge lines up with the panel's tab row and
|
||||
/// the rail's controls across the window.
|
||||
fn render_editor_header(&self, cx: &mut Context<Self>) -> gpui::Div {
|
||||
let active = self.tab_code().and_then(|c| c.active_file());
|
||||
let name = active.map(|f| f.label());
|
||||
let dirty = active.is_some_and(|f| f.dirty);
|
||||
// The overlay fills the column left of the detail panel. With the rail out
|
||||
// that column starts after it, and the traffic lights sit on the rail's
|
||||
// surface — but with the rail collapsed (or in horizontal-tabs mode) the
|
||||
// column starts at the window's left edge and the lights are right where
|
||||
// the filename would go, so the header takes the window controls' reserve
|
||||
// as its inset instead.
|
||||
let lead = if self.left_panel_open(cx) {
|
||||
crate::ui::app::CONTENT_INSET
|
||||
} else {
|
||||
crate::ui::app::TITLE_BAR_LEAD
|
||||
};
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.h(px(crate::ui::app::TITLE_BAR_HEIGHT))
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.pl(px(lead))
|
||||
.pr(px(crate::ui::app::CONTENT_INSET - crate::ui::app::TILE_PAD))
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_ellipsis()
|
||||
.text_sm()
|
||||
.when(name.is_none(), |d| {
|
||||
d.text_color(cx.theme().muted_foreground)
|
||||
})
|
||||
.child(name.unwrap_or_else(|| SharedString::from("No file open"))),
|
||||
)
|
||||
// Same amber dot the tree marks unsaved files with.
|
||||
.when(dirty, |d| {
|
||||
d.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.size(px(6.))
|
||||
.rounded_full()
|
||||
.bg(cx.theme().warning),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
crate::ui::tab_strip::chrome_tile(
|
||||
Button::new("editor-panel-close")
|
||||
.icon(Icon::new(IconName::Close).size(px(15.))),
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.xsmall()
|
||||
.w(px(30.))
|
||||
.h(px(30.))
|
||||
.rounded_lg()
|
||||
.tooltip("Back to Terminal (Esc)")
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.toggle_code_panel(window, cx);
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// The Zed-style status bar along the panel bottom: repo-relative path on
|
||||
/// the left; preview/wrap toggles, cursor position, and the language
|
||||
/// server's presence on the right.
|
||||
fn render_code_status_bar(&self, _window: &Window, cx: &mut Context<Self>) -> gpui::Div {
|
||||
let code = self.tab_code();
|
||||
let muted = cx.theme().muted_foreground;
|
||||
// `repo › relative/path` for the active file; just the repo otherwise.
|
||||
let path_text: Option<SharedString> = code.map(|c| {
|
||||
let repo = c
|
||||
.roots
|
||||
.first()
|
||||
.and_then(|r| r.file_name())
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
match c.active_file() {
|
||||
Some(f) => {
|
||||
let rel = c
|
||||
.roots
|
||||
.iter()
|
||||
.find_map(|r| f.path.strip_prefix(r).ok())
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|| f.label().to_string());
|
||||
format!("{repo} › {rel}").into()
|
||||
}
|
||||
None => repo.into(),
|
||||
}
|
||||
});
|
||||
let active = code.and_then(|c| c.active_file());
|
||||
let cursor: Option<SharedString> = active.map(|f| {
|
||||
let pos = f.input.read(cx).cursor_position();
|
||||
format!("Ln {}, Col {}", pos.line + 1, pos.character + 1).into()
|
||||
});
|
||||
let wrap: Option<bool> = active.map(|f| f.wrap);
|
||||
let is_markdown = active.is_some_and(|f| language_for_path(&f.path) == "markdown");
|
||||
let preview = active.is_some_and(|f| f.preview);
|
||||
let lsp_name: Option<SharedString> = active
|
||||
.and_then(|f| f.lsp.as_ref())
|
||||
.map(|(client, _)| format!("{} ✓", client.name()).into());
|
||||
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.w_full()
|
||||
.h(px(26.))
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.px_3()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().border)
|
||||
.text_xs()
|
||||
.text_color(muted)
|
||||
.when_some(path_text, |this, t| {
|
||||
this.child(div().min_w_0().text_ellipsis().child(t))
|
||||
})
|
||||
.child(div().flex_1())
|
||||
.when(is_markdown, |this| {
|
||||
this.child(
|
||||
Button::new("status-md-preview")
|
||||
.label(if preview { "Edit" } else { "Preview" })
|
||||
.custom(crate::ui::tab_strip::chrome_tile_variant(cx))
|
||||
.xsmall()
|
||||
.on_click(cx.listener(|this, _, _w, cx| {
|
||||
if let Some(code) = this.tab_code_mut() {
|
||||
let ix = code.active;
|
||||
if let Some(f) = code.files.get_mut(ix) {
|
||||
f.preview = !f.preview;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
})),
|
||||
)
|
||||
})
|
||||
.when_some(wrap, |this, wrap| {
|
||||
this.child(
|
||||
Button::new("status-wrap")
|
||||
.label(if wrap { "Wrap: on" } else { "Wrap: off" })
|
||||
.custom(crate::ui::tab_strip::chrome_tile_variant(cx))
|
||||
.xsmall()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
let Some(code) = this.tab_code_mut() else {
|
||||
return;
|
||||
};
|
||||
let ix = code.active;
|
||||
if let Some(f) = code.files.get_mut(ix) {
|
||||
f.wrap = !f.wrap;
|
||||
let wrap = f.wrap;
|
||||
f.input.clone().update(cx, |st, cx| {
|
||||
st.set_soft_wrap(wrap, window, cx);
|
||||
});
|
||||
}
|
||||
})),
|
||||
)
|
||||
})
|
||||
.when_some(cursor, |this, t| this.child(div().child(t)))
|
||||
.when_some(lsp_name, |this, t| this.child(div().child(t)))
|
||||
}
|
||||
|
||||
/// Empty state: the panel is open with nothing loaded.
|
||||
fn render_editor_empty(&self, cx: &Context<Self>) -> gpui::Div {
|
||||
v_flex()
|
||||
@@ -955,126 +1190,6 @@ impl Tty7App {
|
||||
)
|
||||
}
|
||||
|
||||
/// The file tab strip along the panel top.
|
||||
fn render_editor_tabs(&self, _window: &Window, cx: &mut Context<Self>) -> gpui::Div {
|
||||
let active = self.tab_code().map(|c| c.active).unwrap_or(0);
|
||||
let files: &[OpenFile] = self.tab_code().map(|c| c.files.as_slice()).unwrap_or(&[]);
|
||||
let tabs = files.iter().enumerate().map(|(ix, f)| {
|
||||
let is_active = ix == active;
|
||||
let title = f.label();
|
||||
h_flex()
|
||||
.id(("editor-tab", ix))
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_sm()
|
||||
.cursor_pointer()
|
||||
.when(is_active, |d| d.bg(cx.theme().accent))
|
||||
.when(!is_active, |d| {
|
||||
d.text_color(cx.theme().muted_foreground)
|
||||
.hover(|s| s.bg(cx.theme().accent.opacity(0.5)))
|
||||
})
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
if let Some(code) = this.tab_code_mut() {
|
||||
code.active = ix;
|
||||
}
|
||||
this.focus_editor(window, cx);
|
||||
cx.notify();
|
||||
}),
|
||||
)
|
||||
.child(div().child(title))
|
||||
.when(f.dirty, |d| {
|
||||
d.child(div().size(px(7.)).rounded_full().bg(cx.theme().warning))
|
||||
})
|
||||
.child(
|
||||
Button::new(("editor-tab-close", ix))
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.editor_close_file(ix, window, cx);
|
||||
})),
|
||||
)
|
||||
});
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.px_1()
|
||||
.py_1()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.overflow_x_hidden()
|
||||
.children(tabs)
|
||||
.child(div().flex_1())
|
||||
// Markdown files get a preview toggle.
|
||||
.when_some(
|
||||
self.tab_code()
|
||||
.and_then(|c| c.active_file())
|
||||
.filter(|f| language_for_path(&f.path) == "markdown"),
|
||||
|this, f| {
|
||||
let preview = f.preview;
|
||||
this.child(
|
||||
Button::new("editor-md-preview-toggle")
|
||||
.label(if preview { "Edit" } else { "Preview" })
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(|this, _, _w, cx| {
|
||||
if let Some(code) = this.tab_code_mut() {
|
||||
let ix = code.active;
|
||||
if let Some(f) = code.files.get_mut(ix) {
|
||||
f.preview = !f.preview;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
})),
|
||||
)
|
||||
},
|
||||
)
|
||||
// Soft-wrap toggle for the active buffer.
|
||||
.when(
|
||||
self.tab_code().is_some_and(|c| c.active_file().is_some()),
|
||||
|this| {
|
||||
this.child(
|
||||
Button::new("editor-wrap-toggle")
|
||||
.label("Wrap")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tooltip("Toggle soft wrap")
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
let Some(code) = this.tab_code_mut() else {
|
||||
return;
|
||||
};
|
||||
let ix = code.active;
|
||||
if let Some(f) = code.files.get_mut(ix) {
|
||||
f.wrap = !f.wrap;
|
||||
let wrap = f.wrap;
|
||||
f.input.clone().update(cx, |st, cx| {
|
||||
st.set_soft_wrap(wrap, window, cx);
|
||||
});
|
||||
}
|
||||
})),
|
||||
)
|
||||
},
|
||||
)
|
||||
.child(
|
||||
Button::new("editor-panel-close")
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
.small()
|
||||
.tooltip("Back to Terminal (Esc)")
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.toggle_code_panel(window, cx);
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// The find-references drawer (⇧F12 results) under the editor body.
|
||||
fn render_editor_references(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let refs = self.tab_code()?.references.as_ref()?;
|
||||
@@ -1134,16 +1249,20 @@ impl Tty7App {
|
||||
.text_sm()
|
||||
.child(div().flex_1().child(format!("{} references", refs.len())))
|
||||
.child(
|
||||
Button::new("editor-refs-close")
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(|this, _, _w, cx| {
|
||||
crate::ui::tab_strip::chrome_tile(
|
||||
Button::new("editor-refs-close").icon(IconName::Close),
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.xsmall()
|
||||
.on_click(cx.listener(
|
||||
|this, _, _w, cx| {
|
||||
if let Some(code) = this.tab_code_mut() {
|
||||
code.references = None;
|
||||
}
|
||||
cx.notify();
|
||||
})),
|
||||
},
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
|
||||
+198
-49
@@ -22,7 +22,7 @@ use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gpui::{AnyElement, FocusHandle, FontWeight, KeyDownEvent, Window, div, prelude::*, px};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::button::Button;
|
||||
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex};
|
||||
|
||||
use crate::terminal::git_diff::{
|
||||
@@ -57,6 +57,13 @@ pub(crate) struct DiffOverlayState {
|
||||
/// files default open, big/binary ones closed). Keyed by path so the set
|
||||
/// survives a background refresh of the snapshot.
|
||||
pub(crate) toggled: HashSet<String>,
|
||||
/// When set, the overlay shows only this file (repo-relative path), always
|
||||
/// expanded — the "click a row in the Changes panel" entry point. `None` is
|
||||
/// the whole-tree view the git line opens. Kept as a path rather than an
|
||||
/// index so a background re-probe that reorders files doesn't swap which
|
||||
/// file is on screen; a path that vanishes from the diff falls back to the
|
||||
/// full list rather than showing an empty overlay.
|
||||
pub(crate) focus: Option<String>,
|
||||
}
|
||||
|
||||
impl Tty7App {
|
||||
@@ -69,19 +76,63 @@ impl Tty7App {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self
|
||||
self.toggle_diff_overlay_at(cwd, None, window, cx)
|
||||
}
|
||||
|
||||
/// The same toggle, scoped to one file: opens the overlay showing only
|
||||
/// `focus` (repo-relative), which is what the Changes panel's rows do. The
|
||||
/// toggle key is the pair — re-clicking the row that's already on screen
|
||||
/// closes, while clicking a *different* row swaps the shown file in place
|
||||
/// without the overlay blinking shut and re-probing.
|
||||
pub(crate) fn toggle_diff_overlay_at(
|
||||
&mut self,
|
||||
cwd: PathBuf,
|
||||
focus: Option<String>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let active = self.active;
|
||||
// Was the diff already the front overlay? If it was buried under the
|
||||
// code panel, this click means "bring it up", not "close it" — closing
|
||||
// something the user can't currently see would read as the click doing
|
||||
// nothing.
|
||||
let was_front = self.tabs.get(active).is_some_and(|t| {
|
||||
t.overlay_top == crate::ui::app::OverlayTop::Diff || !self.code_panel_visible()
|
||||
});
|
||||
// Acting on the diff raises it over the code panel, whether it was
|
||||
// already open or not.
|
||||
if let Some(tab) = self.tabs.get_mut(active) {
|
||||
tab.overlay_top = crate::ui::app::OverlayTop::Diff;
|
||||
}
|
||||
match self
|
||||
.tabs
|
||||
.get(self.active)
|
||||
.and_then(|t| t.diff_overlay.as_ref())
|
||||
.is_some_and(|o| o.cwd == cwd)
|
||||
.get_mut(active)
|
||||
.and_then(|t| t.diff_overlay.as_mut())
|
||||
.filter(|o| o.cwd == cwd)
|
||||
{
|
||||
self.close_diff_overlay(window, cx);
|
||||
return;
|
||||
// Already open on this repo showing this exact thing, and already on
|
||||
// top — toggle off.
|
||||
Some(o) if o.focus == focus && was_front => {
|
||||
self.close_diff_overlay(window, cx);
|
||||
return;
|
||||
}
|
||||
// Open on this repo, different file: retarget. The snapshot is
|
||||
// already loaded and covers every file, so there is nothing to
|
||||
// re-probe — this is a pure re-render.
|
||||
Some(o) => {
|
||||
o.focus = focus;
|
||||
// Take focus too, so Esc closes the diff rather than whatever
|
||||
// was focused before it came forward (often the editor).
|
||||
let handle = o.focus_handle.clone();
|
||||
window.focus(&handle, cx);
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
// The overlay steals focus (it needs Esc); snapshot the active pane so
|
||||
// closing lands back on the same terminal — same discipline as Settings.
|
||||
self.remember_active_pane(window, cx);
|
||||
let active = self.active;
|
||||
let Some(tab) = self.tabs.get_mut(active) else {
|
||||
return; // home page — no tab body to overlay
|
||||
};
|
||||
@@ -92,12 +143,21 @@ impl Tty7App {
|
||||
load: DiffLoad::Loading,
|
||||
loading: false,
|
||||
toggled: HashSet::new(),
|
||||
focus,
|
||||
});
|
||||
window.focus(&focus_handle, cx);
|
||||
self.spawn_diff_probe(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The file the active tab's overlay is currently scoped to, if any — the
|
||||
/// Changes panel reads it to mark the matching row as selected, so panel and
|
||||
/// overlay can't disagree about what's on screen.
|
||||
pub(crate) fn diff_overlay_focus(&self, cwd: &std::path::Path) -> Option<&str> {
|
||||
let overlay = self.tabs.get(self.active)?.diff_overlay.as_ref()?;
|
||||
(overlay.cwd == cwd).then(|| overlay.focus.as_deref())?
|
||||
}
|
||||
|
||||
/// Close the active tab's overlay (Esc, ✕, or the toggle) and give focus
|
||||
/// back to the active terminal.
|
||||
pub(crate) fn close_diff_overlay(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -208,7 +268,9 @@ impl Tty7App {
|
||||
DiffLoad::Ready(snap) if snap.files.is_empty() && snap.untracked.is_empty() => {
|
||||
self.diff_message("Working tree clean", cx)
|
||||
}
|
||||
DiffLoad::Ready(snap) => self.diff_file_list(snap, &overlay.toggled, cx),
|
||||
DiffLoad::Ready(snap) => {
|
||||
self.diff_file_list(snap, &overlay.toggled, focused_file(snap, overlay), cx)
|
||||
}
|
||||
};
|
||||
|
||||
let header = self.diff_header(overlay, cx);
|
||||
@@ -256,10 +318,21 @@ impl Tty7App {
|
||||
}
|
||||
_ => (String::new(), 0, 0, 0, 0),
|
||||
};
|
||||
// The overlay now covers the title strip, so its header *is* the title
|
||||
// bar for as long as it's up: same height, and the same left inset the
|
||||
// editor header uses — content clears the traffic lights whenever the
|
||||
// rail isn't there to hold that space for us.
|
||||
let lead = if self.left_panel_open(cx) {
|
||||
crate::ui::app::CONTENT_INSET
|
||||
} else {
|
||||
crate::ui::app::TITLE_BAR_LEAD
|
||||
};
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.h(px(40.))
|
||||
.px_3()
|
||||
.h(px(crate::ui::app::TITLE_BAR_HEIGHT))
|
||||
.pl(px(lead))
|
||||
// Trailing tile aligns on its glyph, like every other corner control.
|
||||
.pr(px(crate::ui::app::CONTENT_INSET - crate::ui::app::TILE_PAD))
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.border_b_1()
|
||||
@@ -277,38 +350,80 @@ impl Tty7App {
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.child(branch),
|
||||
)
|
||||
.when(matches!(overlay.load, DiffLoad::Ready(_)), |bar| {
|
||||
let mut summary = format!(
|
||||
"{} changed file{}",
|
||||
files,
|
||||
if files == 1 { "" } else { "s" }
|
||||
);
|
||||
if untracked > 0 {
|
||||
summary.push_str(&format!(" · {untracked} untracked"));
|
||||
}
|
||||
// Scoped to one file: the branch stays (it's still what we diff
|
||||
// against) but the totals give way to the file's own name, with a
|
||||
// click target back to the whole tree — otherwise the only way out
|
||||
// of a focused view would be to close and re-open the overlay.
|
||||
.when_some(focused_name(overlay), |bar, name| {
|
||||
bar.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(summary),
|
||||
h_flex()
|
||||
.id("diff-overlay-unfocus")
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.px_1p5()
|
||||
.py_0p5()
|
||||
.rounded_md()
|
||||
.cursor_pointer()
|
||||
.hover(|s| s.bg(cx.theme().list_hover))
|
||||
.on_click(cx.listener(|this, _, _window, cx| {
|
||||
let active = this.active;
|
||||
if let Some(overlay) = this
|
||||
.tabs
|
||||
.get_mut(active)
|
||||
.and_then(|t| t.diff_overlay.as_mut())
|
||||
{
|
||||
overlay.focus = None;
|
||||
cx.notify();
|
||||
}
|
||||
}))
|
||||
.child(
|
||||
Icon::new(IconName::ChevronLeft)
|
||||
.small()
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_family(self.font_family.clone())
|
||||
.child(name),
|
||||
),
|
||||
)
|
||||
.when(added > 0, |bar| {
|
||||
bar.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().success)
|
||||
.child(format!("+{added}")),
|
||||
)
|
||||
})
|
||||
.when(removed > 0, |bar| {
|
||||
bar.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(format!("−{removed}")),
|
||||
)
|
||||
})
|
||||
})
|
||||
.when(
|
||||
matches!(overlay.load, DiffLoad::Ready(_)) && overlay.focus.is_none(),
|
||||
|bar| {
|
||||
let mut summary = format!(
|
||||
"{} changed file{}",
|
||||
files,
|
||||
if files == 1 { "" } else { "s" }
|
||||
);
|
||||
if untracked > 0 {
|
||||
summary.push_str(&format!(" · {untracked} untracked"));
|
||||
}
|
||||
bar.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(summary),
|
||||
)
|
||||
.when(added > 0, |bar| {
|
||||
bar.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().success)
|
||||
.child(format!("+{added}")),
|
||||
)
|
||||
})
|
||||
.when(removed > 0, |bar| {
|
||||
bar.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(format!("−{removed}")),
|
||||
)
|
||||
})
|
||||
},
|
||||
)
|
||||
// A quiet "refreshing" hint while a re-probe flies over stale data.
|
||||
.when(
|
||||
overlay.loading && matches!(overlay.load, DiffLoad::Ready(_)),
|
||||
@@ -323,13 +438,16 @@ impl Tty7App {
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
Button::new("diff-overlay-close")
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.close_diff_overlay(window, cx);
|
||||
})),
|
||||
crate::ui::tab_strip::chrome_tile(
|
||||
Button::new("diff-overlay-close").icon(IconName::Close),
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.small()
|
||||
.tooltip("Close Diff (Esc)")
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.close_diff_overlay(window, cx);
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -351,14 +469,26 @@ impl Tty7App {
|
||||
&self,
|
||||
snap: &DiffSnapshot,
|
||||
toggled: &HashSet<String>,
|
||||
focused: Option<usize>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let mut list = v_flex().gap_3().p_4().w_full();
|
||||
for (idx, file) in snap.files.iter().enumerate() {
|
||||
let expanded = file_expanded(file, toggled);
|
||||
if focused.is_some_and(|f| f != idx) {
|
||||
continue;
|
||||
}
|
||||
// A file opened by name was asked for explicitly — show its body
|
||||
// even when it's over the auto-collapse threshold. The header still
|
||||
// toggles, so a huge file can be folded back down.
|
||||
let expanded = if focused == Some(idx) {
|
||||
!toggled.contains(&file.path)
|
||||
} else {
|
||||
file_expanded(file, toggled)
|
||||
};
|
||||
list = list.child(self.diff_file_card(idx, file, expanded, cx));
|
||||
}
|
||||
if !snap.untracked.is_empty() {
|
||||
// Untracked files are a property of the tree, not of the focused file.
|
||||
if focused.is_none() && !snap.untracked.is_empty() {
|
||||
list = list.child(self.diff_untracked_section(&snap.untracked, cx));
|
||||
}
|
||||
div()
|
||||
@@ -622,6 +752,25 @@ impl Tty7App {
|
||||
|
||||
/// Whether a file's body shows: small text diffs default open, big ones (and
|
||||
/// anything the user explicitly flipped) invert via the `toggled` set.
|
||||
/// Resolve the overlay's focused path to an index into `snap.files`. `None`
|
||||
/// means "show everything" — either nothing is focused, or the focused path is
|
||||
/// no longer in the diff (the user reverted it while the overlay was open), in
|
||||
/// which case falling back to the full list beats an empty screen.
|
||||
fn focused_file(snap: &DiffSnapshot, overlay: &DiffOverlayState) -> Option<usize> {
|
||||
let path = overlay.focus.as_deref()?;
|
||||
snap.files.iter().position(|f| f.path == path)
|
||||
}
|
||||
|
||||
/// The focused file's name for the header, only once it's known to be in the
|
||||
/// snapshot — so a stale focus doesn't label a list that shows every file.
|
||||
fn focused_name(overlay: &DiffOverlayState) -> Option<String> {
|
||||
let DiffLoad::Ready(snap) = &overlay.load else {
|
||||
return None;
|
||||
};
|
||||
let idx = focused_file(snap, overlay)?;
|
||||
Some(snap.files[idx].path.clone())
|
||||
}
|
||||
|
||||
fn file_expanded(file: &FileDiff, toggled: &HashSet<String>) -> bool {
|
||||
let default_open = file.added + file.removed <= AUTO_COLLAPSE_LINES;
|
||||
default_open != toggled.contains(&file.path)
|
||||
|
||||
+139
-150
@@ -14,7 +14,6 @@
|
||||
//! invalidated by `notify` events, so a huge repo only ever pays for the
|
||||
//! directories actually expanded.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
@@ -22,9 +21,8 @@ use std::rc::Rc;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, Context, Entity, ExternalPaths, FocusHandle, KeyDownEvent, MouseButton,
|
||||
MouseMoveEvent, MouseUpEvent, PromptLevel, SharedString, Subscription, Window, div, px,
|
||||
PromptLevel, SharedString, Subscription, Window, div, px,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::input::{Input, InputEvent, InputState};
|
||||
use gpui_component::menu::{ContextMenuExt as _, PopupMenu, PopupMenuItem};
|
||||
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex};
|
||||
@@ -32,11 +30,6 @@ use ignore::gitignore::Gitignore;
|
||||
|
||||
use crate::ui::app::Tty7App;
|
||||
|
||||
/// Width band (px) for the tree column.
|
||||
const MIN_WIDTH: f32 = 160.0;
|
||||
const MAX_WIDTH: f32 = 480.0;
|
||||
const DEFAULT_WIDTH: f32 = 240.0;
|
||||
|
||||
/// Per-level indent (px) for nested rows.
|
||||
const INDENT: f32 = 14.0;
|
||||
|
||||
@@ -108,8 +101,6 @@ pub(crate) struct FileTreeState {
|
||||
/// Invalidated when a `.gitignore` changes.
|
||||
gitignore: HashMap<PathBuf, Option<Rc<Gitignore>>>,
|
||||
pub(crate) show_hidden: bool,
|
||||
pub(crate) width: Rc<Cell<f32>>,
|
||||
dragging: Rc<Cell<bool>>,
|
||||
pub(crate) editing: Option<TreeEdit>,
|
||||
editing_subs: Vec<Subscription>,
|
||||
/// One recursive watcher over the union of every tab's roots; rebuilt
|
||||
@@ -143,8 +134,6 @@ impl FileTreeState {
|
||||
children: HashMap::new(),
|
||||
gitignore: HashMap::new(),
|
||||
show_hidden: false,
|
||||
width: Rc::new(Cell::new(DEFAULT_WIDTH)),
|
||||
dragging: Rc::new(Cell::new(false)),
|
||||
editing: None,
|
||||
editing_subs: Vec::new(),
|
||||
watcher: None,
|
||||
@@ -264,9 +253,71 @@ impl FileTreeState {
|
||||
state
|
||||
}
|
||||
|
||||
/// Flat, bounded search across the whole tree — not a filter over the rows
|
||||
/// that happen to be expanded, which would answer "no matches" for anything
|
||||
/// the user hasn't already drilled into. Walks breadth-first from the roots
|
||||
/// so shallow hits (the ones you usually mean) come first, skips ignored
|
||||
/// directories entirely — `.git`, `target`, `node_modules` are where the file
|
||||
/// count explodes and never where you're searching — and stops at `LIMIT`
|
||||
/// hits so a query like "e" can't walk a whole monorepo.
|
||||
fn search_rows(&mut self, roots: &[PathBuf], query: &str) -> Vec<TreeRow> {
|
||||
const LIMIT: usize = 200;
|
||||
/// Directories visited even if nothing matches, so a typo can't turn into
|
||||
/// a full-disk crawl.
|
||||
const MAX_DIRS: usize = 2000;
|
||||
|
||||
let needle = query.to_lowercase();
|
||||
let mut out: Vec<TreeRow> = Vec::new();
|
||||
let mut visited = 0usize;
|
||||
for root in roots {
|
||||
let mut queue: Vec<PathBuf> = vec![root.clone()];
|
||||
while let Some(dir) = queue.first().cloned() {
|
||||
queue.remove(0);
|
||||
if out.len() >= LIMIT || visited >= MAX_DIRS {
|
||||
break;
|
||||
}
|
||||
visited += 1;
|
||||
if !self.children.contains_key(&dir) {
|
||||
let listed = self.list_dir(&dir, root);
|
||||
self.children.insert(dir.clone(), listed);
|
||||
}
|
||||
let entries = self.children.get(&dir).cloned().unwrap_or_default();
|
||||
for e in entries {
|
||||
if e.ignored && !self.show_hidden {
|
||||
continue;
|
||||
}
|
||||
if !self.show_hidden && e.name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
if e.is_dir {
|
||||
queue.push(e.path.clone());
|
||||
}
|
||||
if e.name.to_lowercase().contains(&needle) {
|
||||
out.push(TreeRow {
|
||||
entry: e,
|
||||
// Flat: a match's own indentation would be meaningless
|
||||
// without its ancestors on screen.
|
||||
depth: 0,
|
||||
is_root: false,
|
||||
expanded: false,
|
||||
});
|
||||
if out.len() >= LIMIT {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Flatten `roots` + `expanded` directories into display order (both come
|
||||
/// from the active tab's panel state).
|
||||
pub(crate) fn visible_rows(&self, roots: &[PathBuf], expanded: &HashSet<PathBuf>) -> Vec<TreeRow> {
|
||||
pub(crate) fn visible_rows(
|
||||
&self,
|
||||
roots: &[PathBuf],
|
||||
expanded: &HashSet<PathBuf>,
|
||||
) -> Vec<TreeRow> {
|
||||
let mut rows = Vec::new();
|
||||
for root in roots {
|
||||
let name = root
|
||||
@@ -382,7 +433,7 @@ impl Tty7App {
|
||||
roots.push(PathBuf::from(home));
|
||||
}
|
||||
let _ = window;
|
||||
let Some(code) = self.tab_code_mut() else {
|
||||
let Some(code) = self.tab_code_mut_or_init() else {
|
||||
return;
|
||||
};
|
||||
if roots != code.roots {
|
||||
@@ -454,6 +505,18 @@ impl Tty7App {
|
||||
if let Some(code) = self.tab_code_mut() {
|
||||
code.selected = Some(row_path.to_path_buf());
|
||||
}
|
||||
// Search results are a flat list, so "expand" there has nothing to show.
|
||||
// Clicking a directory in them means "take me to it": drop the query and
|
||||
// open the real tree down to that directory, which is the only way the
|
||||
// click can produce a visible result.
|
||||
let searching = !self.file_search.read(cx).value().trim().is_empty();
|
||||
if is_dir && searching {
|
||||
self.file_tree_reveal(row_path, cx);
|
||||
self.file_search
|
||||
.update(cx, |st, cx| st.set_value("", window, cx));
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
if is_dir {
|
||||
self.file_tree_toggle_expand(row_path, cx);
|
||||
} else {
|
||||
@@ -462,6 +525,22 @@ impl Tty7App {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Expand `dir` and every ancestor of it up to its root, so a path buried
|
||||
/// several levels down becomes visible in one step.
|
||||
fn file_tree_reveal(&mut self, dir: &Path, cx: &mut Context<Self>) {
|
||||
let roots = self.tab_code().map(|c| c.roots.clone()).unwrap_or_default();
|
||||
let Some(root) = roots.iter().find(|r| dir.starts_with(r)).cloned() else {
|
||||
return;
|
||||
};
|
||||
let Some(code) = self.tab_code_mut() else {
|
||||
return;
|
||||
};
|
||||
for a in dir.ancestors().take_while(|a| a.starts_with(&root)) {
|
||||
code.expanded.insert(a.to_path_buf());
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Keyboard navigation over the flattened rows.
|
||||
fn file_tree_key_down(
|
||||
&mut self,
|
||||
@@ -472,9 +551,7 @@ impl Tty7App {
|
||||
let Some(code) = self.tab_code() else {
|
||||
return;
|
||||
};
|
||||
let rows = self
|
||||
.file_tree
|
||||
.visible_rows(&code.roots, &code.expanded);
|
||||
let rows = self.file_tree.visible_rows(&code.roots, &code.expanded);
|
||||
if rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -511,9 +588,7 @@ impl Tty7App {
|
||||
if let Some(code) = self.tab_code_mut() {
|
||||
if is_dir && expanded && !is_root {
|
||||
code.expanded.remove(&path);
|
||||
} else if parent_in_rows
|
||||
&& let Some(parent) = path.parent()
|
||||
{
|
||||
} else if parent_in_rows && let Some(parent) = path.parent() {
|
||||
// Jump to the parent row (stay put at a root).
|
||||
code.selected = Some(parent.to_path_buf());
|
||||
}
|
||||
@@ -764,91 +839,53 @@ enum TreeEditKind {
|
||||
impl Tty7App {
|
||||
/// The file-tree column: the code overlay's left side (the overlay
|
||||
/// renders the divider and the editor to its right).
|
||||
pub(crate) fn render_file_tree_column(
|
||||
/// Just the scrolling rows of the tree — no header, no fixed width, no
|
||||
/// surface of its own — so a host that already has those (the right detail
|
||||
/// panel) can drop the tree into its own column. Shares every bit of state
|
||||
/// with [`render_file_tree_column`]: same roots, same expand set, same
|
||||
/// click-to-open, so the panel and the code overlay are two views of one tree.
|
||||
pub(crate) fn render_file_tree_rows(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let roots_empty = self.tab_code().map(|c| c.roots.is_empty()).unwrap_or(true);
|
||||
// The tree is normally rooted when the code panel opens; the right panel
|
||||
// can be the first thing to ask for it, so root it here too when empty.
|
||||
if roots_empty {
|
||||
self.file_tree_refresh_roots(window, cx);
|
||||
}
|
||||
let (roots, expanded) = match self.tab_code() {
|
||||
Some(code) => (code.roots.clone(), code.expanded.clone()),
|
||||
None => (Vec::new(), std::collections::HashSet::new()),
|
||||
};
|
||||
self.file_tree.ensure_loaded(&roots, &expanded);
|
||||
let width = self.file_tree.width.get().clamp(MIN_WIDTH, MAX_WIDTH);
|
||||
let rows = self.file_tree.visible_rows(&roots, &expanded);
|
||||
|
||||
let list = v_flex()
|
||||
.id("file-tree-rows")
|
||||
let query = self.file_search.read(cx).value().trim().to_lowercase();
|
||||
let rows = if query.is_empty() {
|
||||
self.file_tree.ensure_loaded(&roots, &expanded);
|
||||
self.file_tree.visible_rows(&roots, &expanded)
|
||||
} else {
|
||||
self.file_tree.search_rows(&roots, &query)
|
||||
};
|
||||
v_flex()
|
||||
.id("right-panel-tree-rows")
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.overflow_y_scroll()
|
||||
.px_1()
|
||||
.py_1()
|
||||
.children(
|
||||
rows.iter()
|
||||
.flat_map(|row| self.render_tree_row(row, window, cx)),
|
||||
);
|
||||
|
||||
v_flex()
|
||||
.id("file-tree-panel")
|
||||
.flex_none()
|
||||
.h_full()
|
||||
.w(px(width))
|
||||
.bg(cx.theme().background)
|
||||
.pb_1()
|
||||
// Keyboard nav (arrows / enter / rename) followed the tree out of the
|
||||
// overlay: the rows still own the focus handle its key handler reads.
|
||||
.track_focus(&self.file_tree.focus_handle)
|
||||
.on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| {
|
||||
this.file_tree_key_down(ev, window, cx);
|
||||
}))
|
||||
.child(self.render_tree_header(cx))
|
||||
.child(list)
|
||||
.children(
|
||||
rows.iter()
|
||||
.flat_map(|row| self.render_tree_row(row, window, cx)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Panel header: title + refresh / new-file / hidden-files toggle.
|
||||
fn render_tree_header(&self, cx: &mut Context<Self>) -> gpui::Div {
|
||||
let show_hidden = self.file_tree.show_hidden;
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.gap_0p5()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.text_sm()
|
||||
.font_weight(gpui::FontWeight::MEDIUM)
|
||||
.child("Files"),
|
||||
)
|
||||
.child(
|
||||
Button::new("tree-refresh")
|
||||
.icon(IconName::LoaderCircle)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tooltip("Refresh")
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.file_tree_refresh_roots(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("tree-toggle-hidden")
|
||||
.icon(IconName::Eye)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tooltip(if show_hidden {
|
||||
"Hide dotfiles"
|
||||
} else {
|
||||
"Show dotfiles"
|
||||
})
|
||||
.on_click(cx.listener(|this, _, _w, cx| {
|
||||
this.file_tree.show_hidden = !this.file_tree.show_hidden;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// One row (plus, when an inline edit targets it, the edit input row).
|
||||
fn render_tree_row(
|
||||
&self,
|
||||
@@ -860,6 +897,12 @@ impl Tty7App {
|
||||
let is_dir = row.entry.is_dir;
|
||||
let selected = self.tab_code().and_then(|c| c.selected.as_deref()) == Some(&*path);
|
||||
let muted = cx.theme().muted_foreground;
|
||||
// Unsaved edits used to be visible on the editor's file tabs; with those
|
||||
// gone the tree is the only place an open buffer is represented, so it has
|
||||
// to carry the dirty marker or unsaved work becomes invisible.
|
||||
let dirty = self
|
||||
.tab_code()
|
||||
.is_some_and(|c| c.files.iter().any(|f| f.dirty && f.path == *path));
|
||||
|
||||
// Inline rename replaces the row's label with an input.
|
||||
let renaming = matches!(
|
||||
@@ -905,6 +948,7 @@ impl Tty7App {
|
||||
.py_0p5()
|
||||
.rounded(cx.theme().radius)
|
||||
.cursor_pointer()
|
||||
// Soft inset-pill highlight on the content surface.
|
||||
.when(selected, |d| d.bg(cx.theme().accent))
|
||||
.when(!selected, |d| {
|
||||
d.hover(|s| s.bg(cx.theme().accent.opacity(0.5)))
|
||||
@@ -915,6 +959,15 @@ impl Tty7App {
|
||||
muted
|
||||
}))
|
||||
.child(label)
|
||||
.when(dirty, |d| {
|
||||
d.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.size(px(6.))
|
||||
.rounded_full()
|
||||
.bg(cx.theme().warning),
|
||||
)
|
||||
})
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener({
|
||||
@@ -1086,70 +1139,6 @@ impl Tty7App {
|
||||
}
|
||||
menu
|
||||
}
|
||||
|
||||
/// The draggable divider on the tree's right edge.
|
||||
pub(crate) fn render_tree_divider(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let width = self.file_tree.width.clone();
|
||||
let dragging = self.file_tree.dragging.clone();
|
||||
let idle = cx.theme().border;
|
||||
let active = cx.theme().drag_border;
|
||||
let line = if dragging.get() { active } else { idle };
|
||||
|
||||
div()
|
||||
.id("file-tree-divider")
|
||||
.relative()
|
||||
.flex_none()
|
||||
.w(px(5.))
|
||||
.h_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.cursor_col_resize()
|
||||
.child(
|
||||
gpui::canvas(|_, _, _| (), {
|
||||
let width = width.clone();
|
||||
let dragging = dragging.clone();
|
||||
move |bounds, _, window, _cx| {
|
||||
let divider_x = bounds.origin.x;
|
||||
window.on_mouse_event({
|
||||
let width = width.clone();
|
||||
let dragging = dragging.clone();
|
||||
move |ev: &MouseMoveEvent, _phase, window, _cx| {
|
||||
if !dragging.get() {
|
||||
return;
|
||||
}
|
||||
// The tree's left edge = divider left minus
|
||||
// the current width; new width follows the
|
||||
// pointer from that fixed edge.
|
||||
let left = divider_x - px(width.get());
|
||||
let w = (ev.position.x - left).max(px(0.));
|
||||
width.set(w.as_f32().clamp(MIN_WIDTH, MAX_WIDTH));
|
||||
window.refresh();
|
||||
}
|
||||
});
|
||||
window.on_mouse_event({
|
||||
let dragging = dragging.clone();
|
||||
move |_ev: &MouseUpEvent, _phase, window, _cx| {
|
||||
if dragging.get() {
|
||||
dragging.set(false);
|
||||
window.refresh();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.absolute()
|
||||
.size_full(),
|
||||
)
|
||||
.child(div().w(px(1.)).h_full().bg(line))
|
||||
.on_mouse_down(MouseButton::Left, {
|
||||
move |_ev, window, _cx| {
|
||||
dragging.set(true);
|
||||
window.refresh();
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
/// The little drag ghost shown while a row is dragged toward a terminal.
|
||||
|
||||
@@ -159,6 +159,19 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
|
||||
// this steers clear of collisions) — reachable from the command palette
|
||||
// and Settings → Window & Tabs, and bindable there like any other action.
|
||||
("ToggleTabSidebar", ""),
|
||||
// Collapse/expand the left rail, on the ⌘B every editor uses for it.
|
||||
// Off macOS `secondary-b` is Ctrl+B, which is the tmux preset's default
|
||||
// prefix — leave it unbound there rather than fight the prefix.
|
||||
(
|
||||
"ToggleLeftPanel",
|
||||
if cfg!(target_os = "macos") {
|
||||
"secondary-b"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
),
|
||||
// The right detail panel, on the ⌘J every editor uses for a dock.
|
||||
("ToggleRightPanel", "secondary-j"),
|
||||
// Buffer search. ⌘F on macOS; elsewhere `secondary-f` (Ctrl+F) is
|
||||
// readline's forward-char, so follow the GUI-terminal convention and open
|
||||
// find on Ctrl+Shift+F, leaving Ctrl+F to the shell. Find-again is ⌘G/⌘⇧G
|
||||
@@ -479,6 +492,15 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
|
||||
"ToggleMaximizePane" => KeyBinding::new(keystroke, ToggleMaximizePane, None),
|
||||
"ToggleFullscreen" => KeyBinding::new(keystroke, ToggleFullscreen, None),
|
||||
"ToggleTabSidebar" => KeyBinding::new(keystroke, ToggleTabSidebar, None),
|
||||
"ToggleLeftPanel" => KeyBinding::new(keystroke, ToggleLeftPanel, None),
|
||||
"ToggleRightPanel" => KeyBinding::new(keystroke, ToggleRightPanel, None),
|
||||
// Right-panel tab jumps. No entry in the default table above — they ship
|
||||
// unbound and exist so a user *can* bind them; the palette reaches them
|
||||
// either way.
|
||||
"ShowRightPanelInfo" => KeyBinding::new(keystroke, ShowRightPanelInfo, None),
|
||||
"ShowRightPanelOutline" => KeyBinding::new(keystroke, ShowRightPanelOutline, None),
|
||||
"ShowRightPanelChanges" => KeyBinding::new(keystroke, ShowRightPanelChanges, None),
|
||||
"ShowRightPanelFiles" => KeyBinding::new(keystroke, ShowRightPanelFiles, None),
|
||||
// Terminal-scoped (the handler lives on the terminal surface): the "Terminal"
|
||||
// context keeps ⌘K inert in the settings tab / home page instead of binding a
|
||||
// dead global chord there.
|
||||
|
||||
@@ -210,6 +210,11 @@ impl LspClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// The server binary's name, for the status bar.
|
||||
pub(crate) fn name(&self) -> &str {
|
||||
&self.inner.name
|
||||
}
|
||||
|
||||
fn notify(&self, method: &str, params: Value) {
|
||||
let body = json!({ "jsonrpc": "2.0", "method": method, "params": params }).to_string();
|
||||
self.inner.send(body);
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod palette;
|
||||
pub mod pane;
|
||||
pub mod perf;
|
||||
pub mod presets;
|
||||
pub mod right_panel;
|
||||
pub mod settings;
|
||||
pub mod sftp;
|
||||
pub mod ssh_connect;
|
||||
|
||||
@@ -51,6 +51,11 @@ pub enum CommandKind {
|
||||
ToggleMaximizePane,
|
||||
ToggleFullscreen,
|
||||
ToggleTabSidebar,
|
||||
ToggleLeftPanel,
|
||||
ToggleRightPanel,
|
||||
/// Switch the right panel to a specific tab, opening it if it was closed —
|
||||
/// so the palette can land you on Changes without a toggle-then-click.
|
||||
ShowRightPanel(crate::core::config::RightPanelTab),
|
||||
ClearTerminal,
|
||||
FindInTerminal,
|
||||
ReopenClosedTab,
|
||||
@@ -133,6 +138,17 @@ impl CommandKind {
|
||||
ToggleMaximizePane => "ToggleMaximizePane",
|
||||
ToggleFullscreen => "ToggleFullscreen",
|
||||
ToggleTabSidebar => "ToggleTabSidebar",
|
||||
ToggleLeftPanel => "ToggleLeftPanel",
|
||||
ToggleRightPanel => "ToggleRightPanel",
|
||||
ShowRightPanel(tab) => {
|
||||
use crate::core::config::RightPanelTab as T;
|
||||
match tab {
|
||||
T::Info => "ShowRightPanelInfo",
|
||||
T::Outline => "ShowRightPanelOutline",
|
||||
T::Changes => "ShowRightPanelChanges",
|
||||
T::Files => "ShowRightPanelFiles",
|
||||
}
|
||||
}
|
||||
ClearTerminal => "ClearScrollback",
|
||||
ReopenClosedTab => "ReopenClosedTab",
|
||||
OpenSettings => "OpenSettings",
|
||||
@@ -214,6 +230,24 @@ impl Command {
|
||||
Command::new("Toggle Maximize Pane", ToggleMaximizePane),
|
||||
Command::new("Toggle Fullscreen", ToggleFullscreen),
|
||||
Command::new("Toggle Tab Sidebar", ToggleTabSidebar),
|
||||
Command::new("Toggle Left Sidebar", ToggleLeftPanel),
|
||||
Command::new("Toggle Right Panel", ToggleRightPanel),
|
||||
Command::new(
|
||||
"Right Panel: Info",
|
||||
ShowRightPanel(crate::core::config::RightPanelTab::Info),
|
||||
),
|
||||
Command::new(
|
||||
"Right Panel: Outline",
|
||||
ShowRightPanel(crate::core::config::RightPanelTab::Outline),
|
||||
),
|
||||
Command::new(
|
||||
"Right Panel: Changes",
|
||||
ShowRightPanel(crate::core::config::RightPanelTab::Changes),
|
||||
),
|
||||
Command::new(
|
||||
"Right Panel: Files",
|
||||
ShowRightPanel(crate::core::config::RightPanelTab::Files),
|
||||
),
|
||||
Command::new("Clear", ClearTerminal),
|
||||
Command::new("Find in Terminal…", FindInTerminal),
|
||||
Command::new("Reopen Closed Tab", ReopenClosedTab),
|
||||
|
||||
@@ -0,0 +1,954 @@
|
||||
//! The right detail panel: a docked column showing what the active pane *is*,
|
||||
//! rather than what it's printing — session facts, its working-tree diff, and
|
||||
//! its file tree.
|
||||
//!
|
||||
//! It splits across two hosts on purpose. The **tab row lives in the title bar**
|
||||
//! (built in [`tab_strip`](crate::ui::tab_strip)), so the panel's controls sit on
|
||||
//! the same line as the window's own chrome instead of stacking a second 40px bar
|
||||
//! under it; the **body** is this module's column inside `body_area`. The two are
|
||||
//! kept in register by both measuring from `Config::right_panel_width`, so the
|
||||
//! tabs sit exactly over the content they switch.
|
||||
//!
|
||||
//! No new source of truth: Info reads the same `TerminalView`/`Tab` accessors the
|
||||
//! sidebar row does, Changes probes the same `git_diff` the diff overlay does, and
|
||||
//! Files renders the same rows as the code panel's tree.
|
||||
|
||||
use gpui::{AnyElement, Context, Window, div, prelude::*, px};
|
||||
use gpui_component::button::Button;
|
||||
use gpui_component::input::Input;
|
||||
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::core::config::{Config, RightPanelTab};
|
||||
use crate::daemon::protocol::PaneProcs;
|
||||
use crate::terminal::git_diff::{self, DiffSnapshot};
|
||||
use crate::ui::app::{CONTENT_INSET, Tty7App};
|
||||
|
||||
/// Bounds for the panel's width, mirroring the rail's: a floor so the tree never
|
||||
/// becomes an ellipsis parade, and a ceiling as a fraction of the window so a
|
||||
/// persisted value can't swallow the terminal.
|
||||
pub(crate) const MIN_WIDTH: f32 = 200.;
|
||||
pub(crate) const MAX_WIDTH_RATIO: f32 = 0.5;
|
||||
|
||||
/// Width (px) of the resize handle's invisible hit-area, centered on the panel's
|
||||
/// left border — same geometry as the tab rail's.
|
||||
const RESIZE_HANDLE_WIDTH: f32 = 8.;
|
||||
|
||||
/// Panel state that isn't a user preference (those live in `Config`): the cached
|
||||
/// diff for the Changes tab and the body's scroll position.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct RightPanelState {
|
||||
/// The cwd `diff` was probed from — compared against the active pane's cwd to
|
||||
/// decide whether the cached snapshot is still about the right repository.
|
||||
pub(crate) diff_cwd: Option<PathBuf>,
|
||||
/// Last completed probe. `Some(None)` and `None` are different answers:
|
||||
/// "probed, not a work tree" versus "never probed".
|
||||
pub(crate) diff: Option<Option<DiffSnapshot>>,
|
||||
/// A probe is in flight; keeps the render path from spawning a second one.
|
||||
pub(crate) diff_loading: bool,
|
||||
/// The pane `procs` describes, so a pane switch invalidates it rather than
|
||||
/// showing the previous pane's processes under the new pane's name.
|
||||
pub(crate) procs_pane: Option<u64>,
|
||||
/// Last completed process/port query for `procs_pane`.
|
||||
pub(crate) procs: Option<PaneProcs>,
|
||||
/// A query is in flight. Also the poll loop's own guard: the loop reschedules
|
||||
/// itself from the completion handler, so this being set means "a tick is
|
||||
/// already on the way" and a re-render must not start a second chain.
|
||||
pub(crate) procs_loading: bool,
|
||||
}
|
||||
|
||||
/// How often the Info tab re-queries processes and ports while it's open. Fast
|
||||
/// enough that starting a dev server shows up as you tab over, slow enough that
|
||||
/// the process-table walk stays off the profile.
|
||||
const PROCS_POLL: std::time::Duration = std::time::Duration::from_millis(2000);
|
||||
|
||||
impl Tty7App {
|
||||
/// Whether the right panel is docked open. The title bar's tab row, the body
|
||||
/// column and the code overlay's right inset all derive from this.
|
||||
pub(crate) fn right_panel_open(&self, cx: &gpui::App) -> bool {
|
||||
cx.global::<Config>().right_panel_visible && !self.tabs.is_empty()
|
||||
}
|
||||
|
||||
/// The panel's live width, re-clamped to the window the same way the rail's
|
||||
/// is, so a persisted value from a larger display can't take over.
|
||||
/// Named `_px` rather than `_width` because the field it reads is
|
||||
/// `right_panel_width`; a method of the same name would shadow it awkwardly
|
||||
/// at every call site.
|
||||
pub(crate) fn right_panel_px(&self, window: &Window, _cx: &gpui::App) -> f32 {
|
||||
let max = (window.viewport_size().width.as_f32() * MAX_WIDTH_RATIO).max(MIN_WIDTH);
|
||||
// The live cell, not the config: a drag in progress writes only here, and
|
||||
// persists to the config on release.
|
||||
self.right_panel_width.get().clamp(MIN_WIDTH, max)
|
||||
}
|
||||
|
||||
/// `ToggleRightPanel` (⌘J).
|
||||
pub(crate) fn toggle_right_panel(&mut self, cx: &mut Context<Self>) {
|
||||
let next = !cx.global::<Config>().right_panel_visible;
|
||||
self.update_config(cx, |cfg| cfg.right_panel_visible = next);
|
||||
}
|
||||
|
||||
/// Select a tab. Opens the panel if it was closed, so the title bar's tab
|
||||
/// tiles double as "show me this" rather than being inert while hidden.
|
||||
pub(crate) fn set_right_panel_tab(&mut self, tab: RightPanelTab, cx: &mut Context<Self>) {
|
||||
self.update_config(cx, |cfg| {
|
||||
cfg.right_panel_tab = tab;
|
||||
cfg.right_panel_visible = true;
|
||||
});
|
||||
}
|
||||
|
||||
/// The docked column, or `None` while the panel is closed.
|
||||
pub(crate) fn render_right_panel(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<AnyElement> {
|
||||
if !self.right_panel_open(cx) {
|
||||
return None;
|
||||
}
|
||||
let width = self.right_panel_px(window, cx);
|
||||
let tab = cx.global::<Config>().right_panel_tab;
|
||||
|
||||
let body = match tab {
|
||||
RightPanelTab::Info => self.render_panel_info(window, cx),
|
||||
RightPanelTab::Outline => self.render_panel_outline(window, cx),
|
||||
RightPanelTab::Changes => self.render_panel_changes(window, cx),
|
||||
RightPanelTab::Files => self.render_panel_files(window, cx),
|
||||
};
|
||||
let (backing, handle) = self.right_panel_resize(cx);
|
||||
|
||||
Some(
|
||||
v_flex()
|
||||
.id("right-panel")
|
||||
.relative()
|
||||
.flex_none()
|
||||
.w(px(width))
|
||||
.h_full()
|
||||
.child(backing)
|
||||
// The sunk sidebar surface, like the tab rail: both are chrome
|
||||
// around the terminal, so they read as the same material.
|
||||
.bg(cx.theme().sidebar)
|
||||
.border_l_1()
|
||||
.border_color(cx.theme().sidebar_border)
|
||||
// A title-bar-height top zone of its own, exactly like the rail's.
|
||||
// This is what makes the panel read as one column instead of a box
|
||||
// bolted under the title bar: its surface runs the full height of
|
||||
// the window, and the tab row sits *on* it rather than on the
|
||||
// terminal's bar above a seam.
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.h(px(crate::ui::app::TITLE_BAR_HEIGHT))
|
||||
.items_center()
|
||||
.gap(px(2.))
|
||||
.pl(px(CONTENT_INSET - crate::ui::app::TILE_PAD))
|
||||
.children(self.right_panel_tabs(cx))
|
||||
.child(div().flex_1())
|
||||
// The panel is what reaches the window's right edge while
|
||||
// it's open, so it carries the corner chrome.
|
||||
.child(self.window_chrome(window, cx)),
|
||||
)
|
||||
.child(body)
|
||||
.child(handle)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The panel's resize drag: a measuring canvas that installs window-level
|
||||
/// mouse listeners while held, plus the handle itself. Mirrors the tab rail's
|
||||
/// (`tab_sidebar.rs`) with the axis flipped — this panel is anchored to the
|
||||
/// window's right edge, so width grows as the pointer moves *left*, measured
|
||||
/// from the panel's own right edge rather than its origin.
|
||||
fn right_panel_resize(&self, cx: &mut Context<Self>) -> (AnyElement, AnyElement) {
|
||||
use gpui::{Bounds, MouseButton, MouseMoveEvent, MouseUpEvent, Pixels, canvas};
|
||||
use std::cell::Cell as StdCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
let container: Rc<StdCell<Option<Bounds<Pixels>>>> = Rc::new(StdCell::new(None));
|
||||
let backing = canvas(
|
||||
{
|
||||
let container = container.clone();
|
||||
move |bounds, _window, _cx| container.set(Some(bounds))
|
||||
},
|
||||
{
|
||||
let container = container.clone();
|
||||
let width_cell = self.right_panel_width.clone();
|
||||
let dragging = self.right_panel_dragging.clone();
|
||||
move |_bounds, _state, window, _cx| {
|
||||
window.on_mouse_event({
|
||||
let container = container.clone();
|
||||
let width_cell = width_cell.clone();
|
||||
let dragging = dragging.clone();
|
||||
move |ev: &MouseMoveEvent, _phase, window, _cx| {
|
||||
if !dragging.get() {
|
||||
return;
|
||||
}
|
||||
let Some(b) = container.get() else {
|
||||
return;
|
||||
};
|
||||
let right = b.origin.x + b.size.width;
|
||||
let raw = (right - ev.position.x).as_f32();
|
||||
let max = (window.viewport_size().width.as_f32() * MAX_WIDTH_RATIO)
|
||||
.max(MIN_WIDTH);
|
||||
width_cell.set(raw.clamp(MIN_WIDTH, max));
|
||||
window.refresh();
|
||||
}
|
||||
});
|
||||
window.on_mouse_event({
|
||||
let width_cell = width_cell.clone();
|
||||
let dragging = dragging.clone();
|
||||
move |_ev: &MouseUpEvent, _phase, window, cx| {
|
||||
if !dragging.get() {
|
||||
return;
|
||||
}
|
||||
dragging.set(false);
|
||||
let w = width_cell.get();
|
||||
let cfg = cx.global_mut::<Config>();
|
||||
if cfg.right_panel_width != w {
|
||||
cfg.right_panel_width = w;
|
||||
cfg.save();
|
||||
}
|
||||
window.refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
)
|
||||
.absolute()
|
||||
.size_full()
|
||||
.into_any_element();
|
||||
|
||||
let active = self.right_panel_dragging.get();
|
||||
let handle = div()
|
||||
.group("right-panel-resize")
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left(px(-(RESIZE_HANDLE_WIDTH / 2.)))
|
||||
.w(px(RESIZE_HANDLE_WIDTH))
|
||||
.h_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.cursor_col_resize()
|
||||
.child(
|
||||
div()
|
||||
.w(px(1.))
|
||||
.h_full()
|
||||
.when(active, |d| d.bg(cx.theme().drag_border))
|
||||
.group_hover("right-panel-resize", |s| s.bg(cx.theme().drag_border)),
|
||||
)
|
||||
.on_mouse_down(MouseButton::Left, {
|
||||
let dragging = self.right_panel_dragging.clone();
|
||||
move |_ev, window, _cx| {
|
||||
dragging.set(true);
|
||||
window.refresh();
|
||||
}
|
||||
})
|
||||
.into_any_element();
|
||||
|
||||
(backing, handle)
|
||||
}
|
||||
|
||||
/// A section label inside the panel body — the small caps line that names
|
||||
/// what the icon-only tab row can't. `trailing` carries a tab's own controls
|
||||
/// where it has any, so they sit on the label's line rather than earning a
|
||||
/// second header row.
|
||||
fn panel_title(
|
||||
&self,
|
||||
text: &str,
|
||||
trailing: Option<AnyElement>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.h(px(28.))
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.pl(px(CONTENT_INSET))
|
||||
// Trailing tiles align on the glyph like every other control in the
|
||||
// window; a label-only header just takes the plain inset.
|
||||
.pr(px(if trailing.is_some() {
|
||||
CONTENT_INSET - crate::ui::app::TILE_PAD
|
||||
} else {
|
||||
CONTENT_INSET
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(10.))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(text.to_uppercase()),
|
||||
)
|
||||
.when_some(trailing, |this, t| this.child(t))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The Files header's one control. No refresh button: the tree runs a
|
||||
/// recursive filesystem watcher over its roots and invalidates its own caches,
|
||||
/// so a manual refresh is a button that does what already happened.
|
||||
fn files_controls(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let show_hidden = self.file_tree.show_hidden;
|
||||
crate::ui::tab_strip::chrome_tile(
|
||||
Button::new("panel-tree-hidden").icon(Icon::new(IconName::Eye).size(px(13.))),
|
||||
show_hidden,
|
||||
cx,
|
||||
)
|
||||
.xsmall()
|
||||
.w(px(24.))
|
||||
.h(px(24.))
|
||||
.rounded_md()
|
||||
.tooltip(if show_hidden {
|
||||
"Hide dotfiles"
|
||||
} else {
|
||||
"Show dotfiles"
|
||||
})
|
||||
.on_click(cx.listener(|this, _, _w, cx| {
|
||||
this.file_tree.show_hidden = !this.file_tree.show_hidden;
|
||||
cx.notify();
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The Files tab's filter box — the same borderless magnifier + input the tab
|
||||
/// rail uses, so the two panels search the same way. Sits under the header
|
||||
/// rather than in it: it's a full-width control, not a trailing tile.
|
||||
fn files_search(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.gap(px(8.))
|
||||
.h(px(30.))
|
||||
.px(px(CONTENT_INSET))
|
||||
.child(
|
||||
Icon::new(IconName::Search)
|
||||
.small()
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(Input::new(&self.file_search).appearance(false).xsmall()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The body's scrolling area, so every tab shares one scroll container and
|
||||
/// one content inset.
|
||||
fn panel_scroll(&self, inner: AnyElement, title: AnyElement) -> AnyElement {
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.child(title)
|
||||
.child(
|
||||
div()
|
||||
.id("right-panel-body")
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.overflow_y_scroll()
|
||||
.child(inner),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// A quiet "nothing to show" line, used wherever a tab has no data yet.
|
||||
fn panel_empty(&self, text: &str, cx: &mut Context<Self>) -> AnyElement {
|
||||
div()
|
||||
.px(px(CONTENT_INSET))
|
||||
.py(px(4.))
|
||||
.text_size(px(12.))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(text.to_string())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
// ── Info ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Session facts for the active pane, as a two-column key/value list. Every
|
||||
/// row comes from an accessor the sidebar already uses, so the panel can
|
||||
/// never disagree with the row that spawned it.
|
||||
fn render_panel_info(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
let title = self.panel_title("Info", None, cx);
|
||||
let mut rows: Vec<(&'static str, String)> = Vec::new();
|
||||
// Held aside from `rows` because they're not key/value lines: the actions
|
||||
// hang off the cwd, and the two lists get their own sub-headers below.
|
||||
let mut cwd_for_actions: Option<PathBuf> = None;
|
||||
let mut pane_id: Option<u64> = None;
|
||||
|
||||
if let Some(tab) = self.tabs.get(self.active) {
|
||||
if let Some(leaf) = tab.detail_pane(window, cx) {
|
||||
let view = leaf.read(cx);
|
||||
pane_id = Some(view.pane_id);
|
||||
if let Some(cwd) = view
|
||||
.git_status_cwd()
|
||||
.map(|p| p.to_path_buf())
|
||||
.or_else(|| view.cwd())
|
||||
{
|
||||
rows.push(("cwd", compact_path(&cwd)));
|
||||
cwd_for_actions = Some(cwd);
|
||||
}
|
||||
let shell = view.shell_spec().map(|s| s.program.clone());
|
||||
rows.push((
|
||||
"shell",
|
||||
crate::core::shells::default_shell_name(shell.as_deref()),
|
||||
));
|
||||
if let Some(ssh) = view.ssh_spec() {
|
||||
rows.push(("ssh", ssh.host.clone()));
|
||||
}
|
||||
}
|
||||
if let Some(git) = tab.git_status(Some(window), cx) {
|
||||
rows.push(("branch", git.branch.clone()));
|
||||
rows.push(("changes", format!("+{} −{}", git.added, git.removed)));
|
||||
}
|
||||
if let Some(agent) = tab.agent(cx) {
|
||||
let name = agent.display_name();
|
||||
let status = match tab.agent_status(cx) {
|
||||
Some(s) => format!("{name} · {}", agent_status_label(s)),
|
||||
None => name.to_string(),
|
||||
};
|
||||
rows.push(("agent", status));
|
||||
}
|
||||
}
|
||||
|
||||
if rows.is_empty() {
|
||||
return self.panel_scroll(self.panel_empty("No active session.", cx), title);
|
||||
}
|
||||
|
||||
// Keep the process/port query pointed at the pane on screen, and keep it
|
||||
// ticking while this tab is the one being looked at.
|
||||
self.sync_procs(pane_id, cx);
|
||||
|
||||
let mut list = v_flex().px(px(CONTENT_INSET)).py(px(2.)).gap(px(5.));
|
||||
for (k, v) in rows {
|
||||
list = list.child(
|
||||
h_flex()
|
||||
.items_baseline()
|
||||
.gap(px(8.))
|
||||
.text_size(px(11.5))
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.w(px(52.))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(k),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_color(cx.theme().foreground)
|
||||
.child(v),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let inner = v_flex()
|
||||
.child(list)
|
||||
.when_some(cwd_for_actions, |this, cwd| {
|
||||
this.child(self.cwd_actions(cwd, cx))
|
||||
})
|
||||
.children(self.procs_section(cx))
|
||||
.children(self.ports_section(cx))
|
||||
.into_any_element();
|
||||
self.panel_scroll(inner, title)
|
||||
}
|
||||
|
||||
/// The "open this cwd in…" row under the Info list. Deliberately only the
|
||||
/// destinations that need no configuration — a system reveal and the
|
||||
/// clipboard. An "open in $EDITOR" button would need a picker, a stored
|
||||
/// choice and a settings page to change it; that's a feature, not a row.
|
||||
fn cwd_actions(&self, cwd: PathBuf, cx: &mut Context<Self>) -> AnyElement {
|
||||
let reveal_label = if cfg!(target_os = "macos") {
|
||||
"Reveal in Finder"
|
||||
} else {
|
||||
"Open Folder"
|
||||
};
|
||||
h_flex()
|
||||
.gap(px(2.))
|
||||
.px(px(CONTENT_INSET - crate::ui::app::TILE_PAD))
|
||||
.pt(px(6.))
|
||||
.child(
|
||||
crate::ui::tab_strip::chrome_tile(
|
||||
Button::new("panel-info-reveal")
|
||||
.icon(Icon::new(IconName::FolderOpen).size(px(13.))),
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.xsmall()
|
||||
.w(px(24.))
|
||||
.h(px(24.))
|
||||
.rounded_md()
|
||||
.tooltip(reveal_label)
|
||||
.on_click({
|
||||
let cwd = cwd.clone();
|
||||
move |_, _window, cx| cx.reveal_path(&cwd)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
crate::ui::tab_strip::chrome_tile(
|
||||
Button::new("panel-info-copy-path")
|
||||
.icon(Icon::new(IconName::Copy).size(px(13.))),
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.xsmall()
|
||||
.w(px(24.))
|
||||
.h(px(24.))
|
||||
.rounded_md()
|
||||
.tooltip("Copy Path")
|
||||
.on_click(move |_, _window, cx| {
|
||||
cx.write_to_clipboard(gpui::ClipboardItem::new_string(
|
||||
cwd.display().to_string(),
|
||||
));
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// A small caps divider inside a tab's body, for the sub-lists that hang off
|
||||
/// the Info tab. Lighter than [`panel_title`], which is the tab's own header.
|
||||
fn panel_subtitle(&self, text: &str, cx: &mut Context<Self>) -> AnyElement {
|
||||
div()
|
||||
.px(px(CONTENT_INSET))
|
||||
.pt(px(12.))
|
||||
.pb(px(3.))
|
||||
.text_size(px(10.))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(text.to_uppercase())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The pane's process tree, indented by depth. Returns nothing at all when
|
||||
/// the pane is just a shell sitting at its prompt: a one-row "processes"
|
||||
/// section that always says `zsh` is a header earning its keep zero times.
|
||||
fn procs_section(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let procs = &self.procs()?.procs;
|
||||
if procs.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut list = v_flex().px(px(CONTENT_INSET)).gap(px(1.));
|
||||
for p in procs {
|
||||
list = list.child(
|
||||
h_flex()
|
||||
.items_baseline()
|
||||
.gap(px(6.))
|
||||
.text_size(px(11.5))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
// Indent by depth so the tree reads without drawing
|
||||
// connector glyphs into a 260px column.
|
||||
.pl(px(f32::from(p.depth) * 10.))
|
||||
.text_color(if p.foreground {
|
||||
cx.theme().foreground
|
||||
} else {
|
||||
cx.theme().muted_foreground
|
||||
})
|
||||
.child(p.name.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.text_size(px(10.5))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(p.pid.to_string()),
|
||||
),
|
||||
);
|
||||
}
|
||||
Some(
|
||||
v_flex()
|
||||
.child(self.panel_subtitle("Processes", cx))
|
||||
.child(list)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
/// TCP ports the pane's processes are listening on — the answer to "what
|
||||
/// port did that dev server pick?", next to the pane that started it.
|
||||
fn ports_section(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let ports = &self.procs()?.ports;
|
||||
if ports.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut list = v_flex().px(px(CONTENT_INSET)).gap(px(1.));
|
||||
for p in ports {
|
||||
list = list.child(
|
||||
h_flex()
|
||||
.items_baseline()
|
||||
.gap(px(8.))
|
||||
.text_size(px(11.5))
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.w(px(52.))
|
||||
.text_color(cx.theme().foreground)
|
||||
.child(p.port.to_string()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(p.name.clone()),
|
||||
),
|
||||
);
|
||||
}
|
||||
Some(
|
||||
v_flex()
|
||||
.child(self.panel_subtitle("Ports", cx))
|
||||
.child(list)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The cached query, but only when it describes the pane currently on screen.
|
||||
fn procs(&self) -> Option<&PaneProcs> {
|
||||
self.right_panel.procs.as_ref()
|
||||
}
|
||||
|
||||
/// Point the process query at `pane_id` and make sure the poll is running.
|
||||
/// Called from the Info tab's render, so the loop starts when the tab is
|
||||
/// looked at and dies when it isn't — see [`spawn_procs_query`].
|
||||
fn sync_procs(&mut self, pane_id: Option<u64>, cx: &mut Context<Self>) {
|
||||
let Some(pane_id) = pane_id else { return };
|
||||
if self.right_panel.procs_pane != Some(pane_id) {
|
||||
self.right_panel.procs_pane = Some(pane_id);
|
||||
// Drop the previous pane's answer rather than showing it under the new
|
||||
// pane's heading until the first tick lands.
|
||||
self.right_panel.procs = None;
|
||||
}
|
||||
if !self.right_panel.procs_loading {
|
||||
self.spawn_procs_query(pane_id, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// One query, then reschedule — the poll loop. It reschedules only while the
|
||||
/// panel is open on Info, so the loop is self-terminating: close the panel or
|
||||
/// switch tabs and the next completion simply doesn't queue another.
|
||||
fn spawn_procs_query(&mut self, pane_id: u64, cx: &mut Context<Self>) {
|
||||
self.right_panel.procs_loading = true;
|
||||
cx.spawn(async move |this, cx| {
|
||||
let procs = cx
|
||||
.background_executor()
|
||||
.spawn(async move { crate::terminal::RemoteTerminal::query_procs(pane_id) })
|
||||
.await;
|
||||
let keep_polling = this
|
||||
.update(cx, |app, cx| {
|
||||
app.right_panel.procs_loading = false;
|
||||
// A pane switch while we flew makes this answer stale; drop it
|
||||
// and let the new pane's own query land.
|
||||
if app.right_panel.procs_pane != Some(pane_id) {
|
||||
return false;
|
||||
}
|
||||
app.right_panel.procs = Some(procs);
|
||||
cx.notify();
|
||||
let cfg = cx.global::<Config>();
|
||||
cfg.right_panel_visible && cfg.right_panel_tab == RightPanelTab::Info
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !keep_polling {
|
||||
return;
|
||||
}
|
||||
cx.background_executor().timer(PROCS_POLL).await;
|
||||
let _ = this.update(cx, |app, cx| {
|
||||
// Re-check rather than trusting the pre-sleep decision: two seconds
|
||||
// is plenty of time to close the panel.
|
||||
let cfg = cx.global::<Config>();
|
||||
let wanted = cfg.right_panel_visible && cfg.right_panel_tab == RightPanelTab::Info;
|
||||
if wanted && app.right_panel.procs_pane == Some(pane_id) {
|
||||
app.spawn_procs_query(pane_id, cx);
|
||||
}
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
// ── Outline ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// The pane's commands, newest first, each scrolling the terminal back to
|
||||
/// where it ran. Positions come from the OSC 133 marks the reader thread
|
||||
/// records — see [`crate::terminal::marks`].
|
||||
///
|
||||
/// Newest first because that's the end you came from: you scrolled past the
|
||||
/// thing you want, and the list should start where your attention is.
|
||||
fn render_panel_outline(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
let title = self.panel_title("Outline", None, cx);
|
||||
let Some(leaf) = self
|
||||
.tabs
|
||||
.get(self.active)
|
||||
.and_then(|t| t.detail_pane(window, cx))
|
||||
else {
|
||||
return self.panel_scroll(self.panel_empty("No active session.", cx), title);
|
||||
};
|
||||
let marks = leaf.read(cx).command_marks();
|
||||
if marks.is_empty() {
|
||||
// Two very different causes, one honest sentence: nothing has run
|
||||
// yet, or this shell never reported OSC 133 (no integration, a bare
|
||||
// `sh`, a nested PTY that eats the marks).
|
||||
return self.panel_scroll(
|
||||
self.panel_empty("No commands recorded for this pane.", cx),
|
||||
title,
|
||||
);
|
||||
}
|
||||
|
||||
let mut list = v_flex().px(px(CONTENT_INSET - 4.)).py(px(2.)).gap(px(1.));
|
||||
for mark in marks.iter().rev() {
|
||||
let row = mark.row;
|
||||
let leaf = leaf.clone();
|
||||
// A command that failed is the one you're most often looking for, so
|
||||
// it gets the only color in the list.
|
||||
let failed = mark.exit.is_some_and(|c| c != 0);
|
||||
list = list.child(
|
||||
h_flex()
|
||||
.id(gpui::SharedString::from(format!("panel-mark-{row}")))
|
||||
.items_baseline()
|
||||
.gap(px(6.))
|
||||
.px(px(4.))
|
||||
.py(px(2.))
|
||||
.rounded(px(4.))
|
||||
.text_size(px(11.5))
|
||||
.cursor_pointer()
|
||||
.hover(|s| s.bg(cx.theme().sidebar_accent.opacity(0.55)))
|
||||
.on_click(cx.listener(move |_this, _, _window, cx| {
|
||||
leaf.update(cx, |view, cx| {
|
||||
view.scroll_to_mark(row, cx);
|
||||
});
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_color(if failed {
|
||||
cx.theme().danger
|
||||
} else {
|
||||
cx.theme().foreground
|
||||
})
|
||||
// Commands wrap in the shell but must not here: one
|
||||
// row per command is what makes the list scannable.
|
||||
.child(one_line(&mark.text)),
|
||||
)
|
||||
// Only nonzero exits earn a badge. Annotating every success
|
||||
// with a `0` would make the failures harder to spot, not
|
||||
// easier — the whole point of the column.
|
||||
.when_some(mark.exit.filter(|c| *c != 0), |this, code| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.text_size(px(10.5))
|
||||
.text_color(cx.theme().danger)
|
||||
.child(code.to_string()),
|
||||
)
|
||||
})
|
||||
// A command still running is worth marking: it's why the
|
||||
// pane is busy.
|
||||
.when(!mark.done, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.text_size(px(10.5))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("…"),
|
||||
)
|
||||
}),
|
||||
);
|
||||
}
|
||||
self.panel_scroll(list.into_any_element(), title)
|
||||
}
|
||||
|
||||
// ── Changes ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// The working-tree diff as a compact file list — path plus `+N −M` — not the
|
||||
/// diff overlay's hunk cards, which need far more than 260px to be readable.
|
||||
/// Clicking a row opens the full overlay on that repo.
|
||||
fn render_panel_changes(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
let title = self.panel_title("Changes", None, cx);
|
||||
let cwd = self
|
||||
.tabs
|
||||
.get(self.active)
|
||||
.and_then(|t| t.detail_pane(window, cx))
|
||||
.and_then(|leaf| {
|
||||
let v = leaf.read(cx);
|
||||
v.git_status_cwd()
|
||||
.map(|p| p.to_path_buf())
|
||||
.or_else(|| v.cwd())
|
||||
});
|
||||
|
||||
let Some(cwd) = cwd else {
|
||||
return self.panel_scroll(self.panel_empty("No working directory.", cx), title);
|
||||
};
|
||||
// Probe on first paint for this cwd, and whenever the pane moves to a
|
||||
// different repository. Refreshes ride the same git-status observer the
|
||||
// sidebar counts do, which clears the cache (see `right_panel_invalidate`).
|
||||
if self.right_panel.diff_cwd.as_ref() != Some(&cwd) {
|
||||
self.right_panel.diff_cwd = Some(cwd.clone());
|
||||
self.right_panel.diff = None;
|
||||
self.spawn_right_panel_diff(cwd.clone(), cx);
|
||||
}
|
||||
|
||||
let inner = match &self.right_panel.diff {
|
||||
None => self.panel_empty("Loading…", cx),
|
||||
Some(None) => self.panel_empty("Not a git work tree.", cx),
|
||||
Some(Some(snap)) if snap.files.is_empty() && snap.untracked.is_empty() => {
|
||||
self.panel_empty("No changes.", cx)
|
||||
}
|
||||
Some(Some(snap)) => {
|
||||
let files: Vec<(String, u32, u32)> = snap
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| (f.path.clone(), f.added, f.removed))
|
||||
.collect();
|
||||
let untracked = snap.untracked.clone();
|
||||
let focused = self.diff_overlay_focus(&cwd).map(str::to_string);
|
||||
// Rows inset themselves rather than the list, so the hover and
|
||||
// selected capsules bleed a little past the text into the same
|
||||
// 12px gutter the tab rail's rows use.
|
||||
let mut list = v_flex().px(px(CONTENT_INSET - 4.)).py(px(2.)).gap(px(1.));
|
||||
for (path, added, removed) in files {
|
||||
let selected = focused.as_deref() == Some(path.as_str());
|
||||
list = list.child(
|
||||
h_flex()
|
||||
.id(gpui::SharedString::from(format!("panel-change-{path}")))
|
||||
.items_baseline()
|
||||
.gap(px(8.))
|
||||
.px(px(4.))
|
||||
.py(px(2.))
|
||||
.rounded(px(4.))
|
||||
.text_size(px(11.5))
|
||||
.cursor_pointer()
|
||||
.hover(|s| s.bg(cx.theme().sidebar_accent.opacity(0.55)))
|
||||
.when(selected, |s| s.bg(cx.theme().sidebar_accent))
|
||||
.on_click({
|
||||
let cwd = cwd.clone();
|
||||
let path = path.clone();
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
// Toggling on the same row closes the overlay,
|
||||
// so a row is a switch for "show me this diff",
|
||||
// not a one-way door.
|
||||
this.toggle_diff_overlay_at(
|
||||
cwd.clone(),
|
||||
Some(path.clone()),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_color(cx.theme().foreground)
|
||||
.child(path),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.text_color(cx.theme().success)
|
||||
.child(format!("+{added}")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(format!("−{removed}")),
|
||||
),
|
||||
);
|
||||
}
|
||||
if !untracked.is_empty() {
|
||||
list = list.child(
|
||||
div()
|
||||
.pt(px(4.))
|
||||
.px(px(4.))
|
||||
.text_size(px(11.))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(format!("{} untracked", untracked.len())),
|
||||
);
|
||||
}
|
||||
list.into_any_element()
|
||||
}
|
||||
};
|
||||
self.panel_scroll(inner, title)
|
||||
}
|
||||
|
||||
/// Off-thread `git diff` for the panel, mirroring the diff overlay's probe.
|
||||
fn spawn_right_panel_diff(&mut self, cwd: PathBuf, cx: &mut Context<Self>) {
|
||||
if self.right_panel.diff_loading {
|
||||
return;
|
||||
}
|
||||
self.right_panel.diff_loading = true;
|
||||
cx.spawn(async move |this, cx| {
|
||||
let result = cx
|
||||
.background_executor()
|
||||
.spawn({
|
||||
let cwd = cwd.clone();
|
||||
async move { git_diff::probe(&cwd) }
|
||||
})
|
||||
.await;
|
||||
let _ = this.update(cx, |app, cx| {
|
||||
app.right_panel.diff_loading = false;
|
||||
// Drop the result if the panel moved on to another repo while we
|
||||
// flew — otherwise a slow probe would overwrite a newer one.
|
||||
if app.right_panel.diff_cwd.as_ref() == Some(&cwd) {
|
||||
app.right_panel.diff = Some(result);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Drop the cached diff so the next paint re-probes. Called from the same
|
||||
/// git-status observer that refreshes the sidebar's `+N −M`.
|
||||
pub(crate) fn right_panel_invalidate(&mut self) {
|
||||
self.right_panel.diff_cwd = None;
|
||||
}
|
||||
|
||||
// ── Files ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// The project tree, reusing the code panel's rows verbatim — same expand
|
||||
/// state, same click-to-open, so the panel and the editor overlay are two
|
||||
/// views of one tree rather than two trees.
|
||||
fn render_panel_files(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
let controls = self.files_controls(cx);
|
||||
let title = self.panel_title("Files", Some(controls), cx);
|
||||
let search = self.files_search(cx);
|
||||
let rows = self.render_file_tree_rows(window, cx);
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.child(title)
|
||||
.child(search)
|
||||
.child(rows)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-word status the Info row shows next to the agent's name.
|
||||
fn agent_status_label(status: crate::core::cli_agent::AgentStatus) -> &'static str {
|
||||
use crate::core::cli_agent::AgentStatus::*;
|
||||
match status {
|
||||
Idle => "idle",
|
||||
Working => "working",
|
||||
Waiting => "waiting",
|
||||
Done => "done",
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten a possibly-multiline command to one row: newlines and tabs become
|
||||
/// spaces, runs of whitespace collapse. A heredoc or a `for` loop typed across
|
||||
/// lines is still recognizable, and the list keeps one row per command.
|
||||
fn one_line(text: &str) -> String {
|
||||
text.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// `~`-shorten a path for the Info list, which has ~180px to play with.
|
||||
fn compact_path(path: &std::path::Path) -> String {
|
||||
let s = path.to_string_lossy().to_string();
|
||||
match std::env::var("HOME") {
|
||||
Ok(home) if !home.is_empty() && s.starts_with(&home) => s.replacen(&home, "~", 1),
|
||||
_ => s,
|
||||
}
|
||||
}
|
||||
+52
-19
@@ -74,7 +74,12 @@ impl Tty7App {
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.overflow_y_scroll()
|
||||
.p_1p5()
|
||||
// 4px horizontal, so a row's own `pl_2` puts its content on the rail's
|
||||
// 12px content inset — the same line the search row and the top
|
||||
// controls use. The 8px the capsule stops short of the rail edge is
|
||||
// what makes the active row read as inset rather than full-bleed.
|
||||
.px_1()
|
||||
.py_1p5()
|
||||
// Tight row-to-row spacing so the tabs read as one dense list, not a
|
||||
// set of far-apart cards (each row already has its own padding).
|
||||
.gap_0p5();
|
||||
@@ -272,7 +277,7 @@ impl Tty7App {
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.pl_2()
|
||||
.pr_1p5()
|
||||
.pr_2()
|
||||
.rounded_lg()
|
||||
// Sidebar-surface token scheme (gpui-component's Sidebar
|
||||
// semantics), so the rows sit cohesively on the sunk rail rather
|
||||
@@ -381,7 +386,7 @@ impl Tty7App {
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.pl_2()
|
||||
.pr_1p5()
|
||||
.pr_2()
|
||||
.pt_1p5()
|
||||
.pb_0p5()
|
||||
.text_size(px(11.))
|
||||
@@ -410,20 +415,47 @@ impl Tty7App {
|
||||
}
|
||||
}
|
||||
|
||||
// Top control bar: a right-aligned "+" new-tab button (the same shell
|
||||
// picker the strip uses), with new-tab at the top of the rail rather than
|
||||
// in a bottom button. A hairline under it separates the control row from
|
||||
// the tab list.
|
||||
let add_button = self.attach_new_tab_menu(
|
||||
Button::new("sidebar-add")
|
||||
.icon(Icon::new(IconName::Plus).size(px(15.)))
|
||||
.ghost()
|
||||
// The rail's own controls — new tab, and collapse — live in the top zone
|
||||
// beside the traffic lights, right-aligned to the rail's content edge
|
||||
// rather than sitting in the search row. Two reasons: the search row is
|
||||
// for searching, and a collapse button that lives *inside* the rail would
|
||||
// disappear along with it (its counterpart then appears in the title
|
||||
// strip, see `tab_strip`). Right-aligned, they ride the rail's right edge,
|
||||
// which is what says "these belong to this panel" when it's resized.
|
||||
let controls = h_flex()
|
||||
.flex_shrink_0()
|
||||
.h(px(TITLE_BAR_HEIGHT))
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.gap(px(2.))
|
||||
// Glyph, not hit box, on the content edge — see `TILE_PAD`.
|
||||
.pr(px(crate::ui::app::CONTENT_INSET - crate::ui::app::TILE_PAD))
|
||||
.child(
|
||||
self.attach_new_tab_menu(
|
||||
Button::new("sidebar-add")
|
||||
.icon(Icon::new(IconName::Plus).size(px(15.)))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.w(px(30.))
|
||||
.h(px(30.))
|
||||
.rounded_lg(),
|
||||
cx,
|
||||
),
|
||||
)
|
||||
.child(
|
||||
crate::ui::tab_strip::chrome_tile(
|
||||
Button::new("sidebar-collapse")
|
||||
.icon(Icon::new(IconName::PanelLeft).size(px(15.))),
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.xsmall()
|
||||
.w(px(30.))
|
||||
.h(px(30.))
|
||||
.rounded_lg(),
|
||||
cx,
|
||||
);
|
||||
.rounded_lg()
|
||||
.tooltip("Hide Sidebar")
|
||||
.on_click(cx.listener(|this, _, _window, cx| this.toggle_left_panel(cx))),
|
||||
);
|
||||
// Borderless "Search tabs…" that sits directly on the sunk surface: a
|
||||
// leading magnifier + an appearance-less input, no box and no divider
|
||||
// under the bar, so the control row and list read as one continuous rail
|
||||
@@ -433,7 +465,7 @@ impl Tty7App {
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.h(px(44.))
|
||||
.px_3()
|
||||
.px(px(crate::ui::app::CONTENT_INSET))
|
||||
.child(
|
||||
Icon::new(IconName::Search)
|
||||
.small()
|
||||
@@ -444,8 +476,7 @@ impl Tty7App {
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(Input::new(&self.sidebar_search).appearance(false)),
|
||||
)
|
||||
.child(add_button);
|
||||
);
|
||||
|
||||
// ── Resize drag (mirrors the split divider in `pane.rs`) ──────────────
|
||||
// A backing canvas measures the rail's bounds into a per-frame cell and,
|
||||
@@ -558,8 +589,10 @@ impl Tty7App {
|
||||
// A title-bar-height top zone: on macOS the traffic lights
|
||||
// sit on the rail's surface here, and it aligns the search box
|
||||
// with the terminal's top (which starts below the title bar),
|
||||
// so the rail reads as one panel from the very top edge.
|
||||
.child(div().h(px(TITLE_BAR_HEIGHT)).flex_shrink_0())
|
||||
// so the rail reads as one panel from the very top edge. The
|
||||
// rail's controls ride its right end, on the title bar's own
|
||||
// center line — same row as the "⋯" across the window.
|
||||
.child(controls)
|
||||
.child(top_bar)
|
||||
.child(list),
|
||||
)
|
||||
|
||||
+202
-68
@@ -5,16 +5,16 @@
|
||||
//! orchestration rather than chrome rendering.
|
||||
|
||||
use gpui::{
|
||||
App, Axis, Context, FontWeight, MouseButton, MouseDownEvent, SharedString, Window, div,
|
||||
prelude::*, px,
|
||||
AnyElement, App, Axis, Context, FontWeight, MouseButton, MouseDownEvent, SharedString, Window,
|
||||
div, prelude::*, px,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::button::{Button, ButtonCustomVariant, ButtonVariants as _};
|
||||
use gpui_component::input::Input;
|
||||
use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem};
|
||||
use gpui_component::{ActiveTheme as _, Icon, IconName, Selectable as _, Sizable as _, h_flex};
|
||||
|
||||
use crate::core::actions::{OpenSettings, TogglePalette};
|
||||
use crate::core::config::Config;
|
||||
use crate::core::config::{Config, RightPanelTab};
|
||||
use crate::daemon::protocol::ShellSpec;
|
||||
use crate::ui::app::{Tab, Tty7App};
|
||||
use crate::ui::hints::tab_badge_label;
|
||||
@@ -146,7 +146,144 @@ impl Render for DragTab {
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared styling for every icon tile in the window's chrome — title bar,
|
||||
/// rail controls, detail-panel tabs, the editor's close button.
|
||||
///
|
||||
/// `ghost()` can't be used: its hover is `secondary_hover` and its selected state
|
||||
/// `secondary_active`, both solid mid-greys that read far heavier than anything
|
||||
/// else here. So this spells out all four states in the tab rail's language —
|
||||
/// nothing at rest, a soft grey capsule on hover, the same grey opaque when
|
||||
/// selected — which is the same "inset soft-grey capsule" the sidebar rows and
|
||||
/// the popups use. (Overriding just the hover from outside doesn't work: `Button`
|
||||
/// applies its own `.hover()` during render, after any the caller set.)
|
||||
pub(crate) fn chrome_tile_variant(cx: &gpui::App) -> ButtonCustomVariant {
|
||||
let accent = cx.theme().sidebar_accent;
|
||||
ButtonCustomVariant::new(cx)
|
||||
.color(cx.theme().transparent)
|
||||
.foreground(cx.theme().secondary_foreground)
|
||||
.hover(accent.opacity(0.55))
|
||||
.active(accent)
|
||||
}
|
||||
|
||||
pub(crate) fn chrome_tile(button: Button, selected: bool, cx: &gpui::App) -> Button {
|
||||
button.custom(chrome_tile_variant(cx)).selected(selected)
|
||||
}
|
||||
|
||||
impl Tty7App {
|
||||
/// The window's right-corner chrome: the detail-panel toggle and the overflow
|
||||
/// "⋯". Built here rather than inline because it has two hosts — the title
|
||||
/// strip while the panel is closed, and the panel's own top zone while it's
|
||||
/// open, since whichever of the two reaches the window's right edge should
|
||||
/// carry it. (Same arrangement as the rail: its controls sit on the rail when
|
||||
/// it's out, and move into the strip when it's collapsed.)
|
||||
pub(crate) fn window_chrome(
|
||||
&self,
|
||||
window: &Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement + use<> {
|
||||
let panel_open = self.right_panel_open(cx);
|
||||
// `.menu(label, Action)` dispatches the real action, so a click and the
|
||||
// shortcut travel one path and the row auto-renders the shortcut hint; it
|
||||
// needs an `action_context` inside the app's element tree to land on the
|
||||
// root `on_action` handlers, so we hand it the focused pane (falling back
|
||||
// to the home page's handle when no tab is open).
|
||||
let action_ctx = self
|
||||
.tabs
|
||||
.get(self.active)
|
||||
.and_then(|t| t.pane.focused_or_first(window, cx))
|
||||
.map(|leaf| leaf.read(cx).focus_handle.clone())
|
||||
.unwrap_or_else(|| self.home_focus.clone());
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.items_center()
|
||||
.gap(px(2.))
|
||||
// The "⋯" glyph ends on the window's content inset like every other
|
||||
// right edge in the chrome — hence `inset - TILE_PAD`, which puts the
|
||||
// *glyph* there instead of its 30px hit box.
|
||||
.pr(px(crate::ui::app::CONTENT_INSET - crate::ui::app::TILE_PAD))
|
||||
// On Windows/Linux the window controls (─ ▢ ✕) sit on the right, right
|
||||
// where the "⋯" lands; give it extra breathing room there so it reads
|
||||
// as a menu affordance, not a fourth window control.
|
||||
.when(!cfg!(target_os = "macos"), |this| this.pr_3())
|
||||
.child(
|
||||
div().occlude().flex_shrink_0().child(
|
||||
chrome_tile(
|
||||
Button::new("titlebar-right-panel")
|
||||
.icon(Icon::new(IconName::PanelRight).size(px(15.))),
|
||||
panel_open,
|
||||
cx,
|
||||
)
|
||||
.xsmall()
|
||||
.w(px(30.))
|
||||
.h(px(30.))
|
||||
.rounded_lg()
|
||||
.tooltip("Detail Panel")
|
||||
.on_click(cx.listener(|this, _, _window, cx| {
|
||||
this.toggle_right_panel(cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div().occlude().flex_shrink_0().child(
|
||||
chrome_tile(
|
||||
Button::new("titlebar-menu")
|
||||
.icon(Icon::new(IconName::Ellipsis).size(px(15.))),
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.xsmall()
|
||||
.w(px(30.))
|
||||
.h(px(30.))
|
||||
.rounded_lg()
|
||||
.dropdown_menu_with_anchor(
|
||||
gpui::Anchor::TopRight,
|
||||
move |menu, _window, _cx| {
|
||||
menu.min_w(px(220.))
|
||||
.action_context(action_ctx.clone())
|
||||
.menu("Command Palette", Box::new(TogglePalette))
|
||||
.menu("Settings…", Box::new(OpenSettings))
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// The detail panel's tab tiles — icon-only, one per view. Lives here beside
|
||||
/// the rest of the chrome tiles so all of them share one styling helper.
|
||||
pub(crate) fn right_panel_tabs(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
|
||||
let active_tab = cx.global::<Config>().right_panel_tab;
|
||||
[
|
||||
(RightPanelTab::Info, IconName::Info, "Info"),
|
||||
(RightPanelTab::Outline, IconName::SquareTerminal, "Outline"),
|
||||
(RightPanelTab::Changes, IconName::Replace, "Changes"),
|
||||
(RightPanelTab::Files, IconName::FolderClosed, "Files"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(tab, icon, label)| {
|
||||
div()
|
||||
.occlude()
|
||||
.flex_shrink_0()
|
||||
.child(
|
||||
chrome_tile(
|
||||
Button::new(("right-panel-tab", tab as usize))
|
||||
.icon(Icon::new(icon).size(px(15.))),
|
||||
active_tab == tab,
|
||||
cx,
|
||||
)
|
||||
.xsmall()
|
||||
.w(px(30.))
|
||||
.h(px(30.))
|
||||
.rounded_lg()
|
||||
.tooltip(label)
|
||||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||
this.set_right_panel_tab(tab, cx);
|
||||
})),
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The status dot pinned to a tab avatar's bottom-right corner (an agent's
|
||||
/// live status, or an SSH pane's connection phase): a solid
|
||||
/// `rgb` disc with a surface-colored separator ring so it reads as sitting
|
||||
@@ -782,9 +919,12 @@ impl Tty7App {
|
||||
// area doesn't swallow the click.
|
||||
div().occlude().flex_shrink_0().child(
|
||||
self.attach_new_tab_menu(
|
||||
Button::new("tab-add")
|
||||
.icon(Icon::new(IconName::Plus).size(px(15.)))
|
||||
.ghost()
|
||||
chrome_tile(
|
||||
Button::new("tab-add")
|
||||
.icon(Icon::new(IconName::Plus).size(px(15.))),
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.xsmall()
|
||||
.w(px(30.))
|
||||
.h(px(30.))
|
||||
@@ -793,61 +933,60 @@ impl Tty7App {
|
||||
),
|
||||
);
|
||||
|
||||
// Right-edge overflow menu: the low-frequency *global* entries (command
|
||||
// palette, settings) that until now had no on-screen affordance at all —
|
||||
// only keyboard shortcuts. Same ghost 30px tile as the "+", but anchored
|
||||
// to the title bar's otherwise-empty right edge and opening from its
|
||||
// top-right corner so the popup never spills off-screen.
|
||||
//
|
||||
// `.menu(label, Action)` dispatches the real action, so a click and the
|
||||
// shortcut travel one path and the row auto-renders the shortcut hint; it
|
||||
// needs an `action_context` inside the app's element tree to land on the
|
||||
// root `on_action` handlers, so we hand it the focused pane (falling back
|
||||
// to the home page's handle when no tab is open).
|
||||
// (Settings is a full-window overlay now, so it simply covers this menu
|
||||
// while open — no need to conditionally hide it.)
|
||||
let action_ctx = self
|
||||
.tabs
|
||||
.get(active)
|
||||
.and_then(|t| t.pane.focused_or_first(window, cx))
|
||||
.map(|leaf| leaf.read(cx).focus_handle.clone())
|
||||
.unwrap_or_else(|| self.home_focus.clone());
|
||||
// Code-panel toggle: the one on-screen entry point for the file-tree +
|
||||
// editor overlay (⌘⇧E). A global title-bar tile rather than a per-pane
|
||||
// affordance, so heavy split layouts don't grow a forest of icons; lit
|
||||
// (selected) while the overlay is up. Present in both tab-bar modes —
|
||||
// the sidebar layout keeps this strip as the right column's chrome.
|
||||
let code_open = self.code_panel_visible();
|
||||
let code_button = div().occlude().flex_shrink_0().child(
|
||||
Button::new("titlebar-code-panel")
|
||||
.icon(Icon::new(IconName::FolderClosed).size(px(15.)))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.w(px(30.))
|
||||
.h(px(30.))
|
||||
.rounded_lg()
|
||||
.selected(code_open)
|
||||
.tooltip("Code Panel")
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.toggle_code_panel(window, cx);
|
||||
})),
|
||||
);
|
||||
// Sidebar mode with the rail collapsed: the rail's own controls move here
|
||||
// rather than vanishing with it, so collapsing is never a one-way door.
|
||||
// They keep the rail's order and spacing and just re-anchor from the rail's
|
||||
// right edge to the window's left one, landing beside the traffic lights.
|
||||
let rail_collapsed = !show_chips && !self.left_panel_open(cx);
|
||||
let left_group = rail_collapsed.then(|| {
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.items_center()
|
||||
.gap(px(2.))
|
||||
// Negative off macOS only: the bar already inset us past the window
|
||||
// controls, and there the reserve *is* the clearance.
|
||||
.ml(px(crate::ui::app::title_bar_hug_offset()))
|
||||
.child(
|
||||
div().occlude().flex_shrink_0().child(
|
||||
self.attach_new_tab_menu(
|
||||
chrome_tile(
|
||||
Button::new("titlebar-add-collapsed")
|
||||
.icon(Icon::new(IconName::Plus).size(px(15.))),
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.xsmall()
|
||||
.w(px(30.))
|
||||
.h(px(30.))
|
||||
.rounded_lg(),
|
||||
cx,
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div().occlude().flex_shrink_0().child(
|
||||
chrome_tile(
|
||||
Button::new("titlebar-expand-sidebar")
|
||||
.icon(Icon::new(IconName::PanelLeft).size(px(15.))),
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.xsmall()
|
||||
.w(px(30.))
|
||||
.h(px(30.))
|
||||
.rounded_lg()
|
||||
.tooltip("Show Sidebar")
|
||||
.on_click(cx.listener(|this, _, _window, cx| this.toggle_left_panel(cx))),
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
let menu_button = div().occlude().flex_shrink_0().child(
|
||||
Button::new("titlebar-menu")
|
||||
.icon(Icon::new(IconName::Ellipsis).size(px(15.)))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.w(px(30.))
|
||||
.h(px(30.))
|
||||
.rounded_lg()
|
||||
.dropdown_menu_with_anchor(gpui::Anchor::TopRight, move |menu, _window, _cx| {
|
||||
menu.min_w(px(220.))
|
||||
.action_context(action_ctx.clone())
|
||||
.menu("Command Palette", Box::new(TogglePalette))
|
||||
.menu("Settings…", Box::new(OpenSettings))
|
||||
}),
|
||||
);
|
||||
let panel_open = self.right_panel_open(cx);
|
||||
// The window's right-corner chrome. When the panel is open it lives on the
|
||||
// *panel's* top zone (the panel is what reaches the window's right edge
|
||||
// then) exactly like the rail's controls live on the rail; the strip only
|
||||
// carries it while the panel is closed.
|
||||
let right_chrome = (!panel_open).then(|| self.window_chrome(window, cx));
|
||||
|
||||
// Outer strip: the clipping chip row and the always-visible "+" anchored
|
||||
// left, the overflow "⋯" pushed to the right edge by a flexible spacer.
|
||||
@@ -869,20 +1008,15 @@ impl Tty7App {
|
||||
// — the original tight inset, which now holds steady on resize since
|
||||
// `strip_w` keeps the right edge tracking the window.
|
||||
.pl_0()
|
||||
.pr_2()
|
||||
// On Windows/Linux the window controls (─ ▢ ✕) sit on the right, right
|
||||
// where the "⋯" lands; give it extra right breathing room there so it
|
||||
// reads as a menu affordance, not a fourth window control.
|
||||
.when(!cfg!(target_os = "macos"), |this| this.pr_3())
|
||||
.min_w_0()
|
||||
.when_some(left_group, |this, g| this.child(g))
|
||||
.child(chips)
|
||||
// In sidebar mode the rail owns "New Tab" (a "+" in its own top bar),
|
||||
// so the title bar drops its "+" to avoid a redundant second one —
|
||||
// leaving just the "⋯" overflow menu on a thin strip.
|
||||
.when(show_chips, move |this| this.child(add_button))
|
||||
.child(div().flex_1())
|
||||
.child(code_button)
|
||||
.child(menu_button)
|
||||
.when_some(right_chrome, |this, chrome| this.child(chrome))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user