Merge pull request #188 from l0ng-ai/fix/windows-detail-panel-top

fix(right-panel): put the window controls back in the window's corner
This commit is contained in:
l0ng-ai
2026-07-26 20:09:20 +08:00
committed by GitHub
4 changed files with 260 additions and 126 deletions
+101 -60
View File
@@ -166,6 +166,12 @@ pub(crate) fn tile_trailing_inset_sm() -> f32 {
/// here, not at the window edge.
pub(crate) const TITLE_BAR_LEAD: f32 = if cfg!(target_os = "macos") { 80. } else { 12. };
/// What the bar reserves at its *trailing* edge for the window controls: three
/// 34px tiles (─ ▢ ✕) off macOS, nothing on macOS (the traffic lights are on the
/// left, and `TITLE_BAR_LEAD` covers them). Anything in the bar that has to line
/// up with a column below it measures from the window edge minus this.
pub(crate) const WINDOW_CONTROLS_W: f32 = if cfg!(target_os = "macos") { 0. } else { 102. };
/// 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
@@ -5165,68 +5171,103 @@ impl Render for Tty7App {
pair.into_iter().filter_map(|(_, el)| el).collect()
};
// The two layouts. Horizontal: a column of [title bar / body].
// Vertical (default): the rail is a full-height *left column* that reaches the very
// top of the window — the traffic lights sit on its surface — with the
// 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.
// The layout. The rail (vertical mode) is a full-height *left column* that
// reaches the very top of the window — the traffic lights sit on its
// surface — with the 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 does the same on macOS: a full-height column
// carrying its own title-bar-height top zone (tab row + the window's corner
// chrome), so its surface runs unbroken from the very top of the window.
//
// Off macOS it can't. The window controls (─ ▢ ✕) are laid out by the title
// bar itself, at *its* right end, so a full-height panel beside the bar
// strands them mid-window with the panel's grey to their right. There the
// bar spans the panel too — reaching the real top-right corner, where
// Windows and Linux users expect the controls — and the panel hangs below
// it, VS Code style. The panel's tab row then sits on the panel (no longer
// in the caption row), and the corner chrome stays in the strip.
let right_panel = self.render_right_panel(window, cx);
let main_layout = match sidebar {
Some(sidebar) => div()
.flex_1()
.min_h_0()
.w_full()
.flex()
.flex_row()
.child(sidebar)
.child(
div()
.flex_1()
.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)
.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_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(),
let panel_below_title_bar = right_panel.is_some() && !cfg!(target_os = "macos");
// Which host the bar goes to: the terminal column's first child, or the
// spanning row above [terminal | panel].
let (column_title_bar, spanning_title_bar) = if panel_below_title_bar {
(None, Some(title_bar))
} else {
(Some(title_bar), None)
};
// The terminal column, and the anchor for both overlays: they fill it —
// and, since the panel is a sibling rather than a child, stop short of the
// panel for free. With the bar spanning above, they stop short of it too,
// which keeps the native controls clickable while an overlay is open and
// lines the overlay's own header row up with the panel's tab row.
let terminal_column = div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.relative()
.when_some(column_title_bar, |this, bar| this.child(bar))
.child(body_area)
.children(overlays);
let panel_row = div()
.flex_1()
.min_h_0()
.min_w_0()
.flex()
.flex_row()
.child(terminal_column)
.when_some(right_panel, |this, panel| this.child(panel));
let main_layout = div()
.flex_1()
.min_h_0()
.w_full()
.flex()
.flex_row()
.when_some(sidebar, |this, sidebar| this.child(sidebar))
.child(match spanning_title_bar {
Some(bar) => div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.child(
// The bar's own band over the panel, painted in the panel's
// surface so the column still reads as one continuous
// sidebar from the very top of the window — the rail's
// trick, kept now that the tab row moved off the caption
// line. Without it the panel's grey started 40px down and
// the corner tore into two colours.
//
// A sibling *under* the transparent bar rather than padding
// inside it: the ─ ▢ ✕ group is the bar's own last child, so
// nothing laid out in the bar can get behind the controls,
// and only a layer below can carry a surface under them.
// Same width and left border as the panel, both read from
// `right_panel_px`, so the edge stays in register through a
// resize drag.
div()
.relative()
.flex_none()
.child(
div()
.absolute()
.top_0()
.bottom_0()
.right_0()
.w(px(self.right_panel_px(window, cx)))
.bg(cx.theme().sidebar)
.border_l_1()
.border_color(cx.theme().sidebar_border),
)
.child(bar),
)
.child(panel_row)
.into_any_element(),
None => panel_row.into_any_element(),
})
.into_any_element();
// The real window background paint: gradient-aware and opacity-carrying
// (see `theme::window_background`), plus the theme's optional background
+38 -2
View File
@@ -1247,9 +1247,18 @@ impl Tty7App {
let app = cx.entity().downgrade();
let path = path.clone();
let is_root = row.is_root;
let show_hidden = self.file_tree.show_hidden;
move |menu, _window, cx| {
let danger = cx.theme().danger;
Self::tree_row_context_menu(menu, &path, is_dir, is_root, danger, &app)
Self::tree_row_context_menu(
menu,
&path,
is_dir,
is_root,
show_hidden,
danger,
&app,
)
}
});
@@ -1279,12 +1288,16 @@ impl Tty7App {
out
}
/// The per-row right-click menu, mirroring Warp's Project Explorer set.
/// The per-row right-click menu, mirroring Warp's Project Explorer set, plus
/// the tree's one view option (dotfiles) — which lives here rather than as a
/// header button: it is set once and then forgotten, and a tile in the header
/// spends the panel's scarcest row on it forever.
fn tree_row_context_menu(
menu: PopupMenu,
path: &Path,
is_dir: bool,
is_root: bool,
show_hidden: bool,
danger: gpui::Hsla,
app: &gpui::WeakEntity<Self>,
) -> PopupMenu {
@@ -1379,6 +1392,8 @@ impl Tty7App {
}
}));
menu = menu.separator().item(dotfiles_menu_item(show_hidden, app));
if !is_root {
menu = menu.separator().item(
PopupMenuItem::element(move |_window, _cx| {
@@ -1397,6 +1412,27 @@ impl Tty7App {
}
}
/// The tree's dotfile switch as a row of the tree's existing right-click menu.
///
/// The label states what the click will do rather than checking off the current
/// state: a single checked item makes `PopupMenu` reserve a left icon gutter on
/// *every* row in the menu (see `tab_strip::window_chrome`), and the menu has a
/// dozen rows with nothing to put in one.
fn dotfiles_menu_item(show_hidden: bool, app: &gpui::WeakEntity<Tty7App>) -> PopupMenuItem {
let app = app.clone();
PopupMenuItem::new(if show_hidden {
"Hide Dotfiles"
} else {
"Show Dotfiles"
})
.on_click(move |_, _window, cx| {
let _ = app.update(cx, |this, cx| {
this.file_tree.show_hidden = !this.file_tree.show_hidden;
cx.notify();
});
})
}
/// The little drag ghost shown while a row is dragged toward a terminal.
struct DragGhost {
name: String,
+76 -55
View File
@@ -2,12 +2,14 @@
//! 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.
//! Its tab row has two homes. On macOS it is the panel's own title-bar-height top
//! zone, level with the window's chrome, so the column runs unbroken from the top
//! of the window. Off macOS the title bar has to span the panel (the window
//! controls live at its right end), so the row drops to the panel's second line —
//! Cursor-style — while the caption row above is painted in the panel's surface
//! so the column still reads as one colour.
//! Either way the tiles themselves are built in
//! [`tab_strip`](crate::ui::tab_strip), beside the rest of the window's tiles.
//!
//! 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
@@ -182,14 +184,15 @@ impl Tty7App {
// 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({
// The top zone sits level with the real `TitleBar`, but the
// bar only spans the terminal column — so, exactly like the
// rail's top strip (`tab_sidebar`), make this one act like the
// title bar it aligns with: drag to move, double-click to zoom.
// A press arms a flag and the first *move* starts the window
// move, so a plain click on a tab — and a double-click — still
// lands intact; the tabs and corner chrome take their own.
//
// macOS only. Off macOS the bar spans the panel — it has to, or the
// window controls end up stranded mid-window (see `app::render`) —
// and a row of tiles under that caption row was one chrome row too
// many: the panel opened with three stacked headers (caption chrome,
// tab tiles, section title) before any content. So there the tiles
// move into the section header instead (`panel_title`), which is a
// row the panel was drawing anyway.
.children(cfg!(target_os = "macos").then(|| {
let should_move = Rc::new(Cell::new(false));
h_flex()
.id("right-panel-titlebar-drag")
@@ -204,6 +207,14 @@ impl Tty7App {
// down a physical pixel the moment the panel opens.
.border_b_1()
.border_color(cx.theme().transparent)
// The top zone sits level with the real `TitleBar`, but the
// bar only spans the terminal column — so, exactly like the
// rail's top strip (`tab_sidebar`), make this one act like
// the title bar it aligns with: drag to move, double-click
// to zoom. A press arms a flag and the first *move* starts
// the window move, so a plain click on a tab — and a
// double-click — still lands intact; the tabs and corner
// chrome take their own.
.window_control_area(WindowControlArea::Drag)
.on_mouse_down(MouseButton::Left, {
let should_move = should_move.clone();
@@ -227,7 +238,7 @@ impl Tty7App {
// 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)
// The transfers footer is a sibling of the body, not part of any
// tab: an SFTP transfer belongs to the pane, so reading Info or
@@ -333,14 +344,20 @@ impl Tty7App {
(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.
/// A tab's header: the name in a weightier small-caps than the old faint
/// label, plus an optional live count trailing it (files, commands, changed
/// files) so the header states scale at a glance, and an optional control on
/// the right. The count is the quiet mono tally the sidebar group headers use.
/// `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.
///
/// Off macOS this row is also the panel's tab switcher: the four tiles ride
/// at its trailing edge, and the row takes the full title-bar height with a
/// hairline under it. The panel there hangs below a caption row that already
/// carries chrome (see `render_right_panel`), and a tile row of its own on top
/// of this one meant three stacked headers before a single line of content —
/// so the two that were saying "this is a header" merge into one that also
/// says which tab you are on.
pub(crate) fn panel_title(
&self,
text: &str,
@@ -348,23 +365,37 @@ impl Tty7App {
trailing: Option<AnyElement>,
cx: &mut Context<Self>,
) -> AnyElement {
let tabs = (!cfg!(target_os = "macos")).then(|| self.right_panel_tabs(cx));
let has_trailing = trailing.is_some();
h_flex()
.flex_none()
.h(px(32.))
// Tall enough to seat the chrome-scale tiles when it carries them;
// otherwise the compact label line it has always been.
.h(px(if tabs.is_some() {
crate::ui::app::TITLE_BAR_HEIGHT
} else {
32.
}))
.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. `_SM`
// because what hangs here is a body-scale tile, whose glyph sits a
// different distance inside its box than the chrome's does.
.pr(px(if trailing.is_some() {
tile_trailing_inset_sm()
} else {
CONTENT_INSET
// window; a label-only header just takes the plain inset. `_SM` for a
// tab's own control, whose glyph sits a different distance inside its
// box than the chrome-scale tab tiles do.
.pr(px(match (&tabs, has_trailing) {
(Some(_), _) => tile_trailing_inset(),
(None, true) => tile_trailing_inset_sm(),
(None, false) => CONTENT_INSET,
}))
// The line that separates the header from the tab's content. Only
// where the header is the switcher: a label alone doesn't need ruling
// off from the band it introduces.
.when(tabs.is_some(), |this| {
this.border_b_1().border_color(cx.theme().sidebar_border)
})
.child(
h_flex()
.flex_shrink_0()
.items_baseline()
.gap(px(7.))
.child(
@@ -384,35 +415,23 @@ impl Tty7App {
)
}),
)
.child(div().flex_1().min_w_0())
.when_some(trailing, |this, t| this.child(t))
.when_some(tabs, |this, tiles| {
this.child(
h_flex()
.flex_shrink_0()
.items_center()
.gap(px(2.))
// Clear of a tab's own control where there is one; flush
// against the label's spring where there isn't.
.when(has_trailing, |this| this.ml(px(6.)))
.children(tiles),
)
})
.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_sized(
Button::new("panel-tree-hidden").icon(Icon::new(IconName::Eye)),
TILE_SIZE_SM,
TILE_GLYPH_SM,
show_hidden,
cx,
)
.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()
}
/// A tab's filter box — the same borderless magnifier + input the tab rail
/// uses, so everything in the window searches the same way. Sits under the
/// header rather than in it: it's a full-width control, not a trailing tile.
@@ -1290,8 +1309,10 @@ impl Tty7App {
return self.render_panel_sftp(host.unwrap_or_default(), window, cx);
}
let controls = self.files_controls(cx);
let title = self.panel_title("Files", None, Some(controls), cx);
// No header control: the tree's one view option (dotfiles) is a
// right-click away in the tree itself (`file_tree::dotfiles_menu_item`),
// which is where you are when you want it.
let title = self.panel_title("Files", None, None, cx);
let search = self.panel_search(&self.file_search.clone(), cx);
let rows = self.render_file_tree_rows(window, cx);
v_flex()
+45 -9
View File
@@ -631,14 +631,23 @@ impl Tty7App {
.when(!cfg!(target_os = "macos"), |this| this.pr_1())
.child(
div().occlude().flex_shrink_0().child(
// Never drawn selected, exactly like the rail's own toggle
// (`tab_sidebar`): the panel being open is already on screen —
// it *is* the panel — so a lit capsule only restates it, and
// the two panel toggles would disagree about what a chrome tile
// means. The state lives in the tooltip's verb instead.
chrome_tile(
Button::new("titlebar-right-panel")
.icon(Icon::empty().path("icons/panel-right.svg")),
panel_open,
false,
cx,
)
.rounded_lg()
.tooltip("Detail Panel")
.tooltip(if panel_open {
"Hide Detail Panel"
} else {
"Show Detail Panel"
})
.on_click(cx.listener(|this, _, _window, cx| {
this.toggle_right_panel(cx);
})),
@@ -1107,12 +1116,21 @@ impl Tty7App {
} else {
(window.viewport_size().width - px(114.)).max(px(140.))
};
// Off macOS the bar spans the detail panel (see `app::render`). The corner
// chrome then sits *over the panel's surface*, so it stops hugging the
// window controls and aligns with the column it is painted on: a block as
// wide as the slice of the panel the bar can reach — panel width less the
// control group, less the panel's own left border — with the tiles packed
// at its leading edge, on the same inset as everything else in the panel.
let chrome_band_w = (!cfg!(target_os = "macos") && self.right_panel_open(cx)).then(|| {
(self.right_panel_px(window, cx) - crate::ui::app::WINDOW_CONTROLS_W - 1.).max(0.)
});
// The "+" and the right-edge overflow "⋯" (30px each), their surrounding
// gaps, and the strip's own left/right padding all live *outside* the
// clipped chip row — reserve that whole footprint here so the fixed chrome
// never overflows the strip box (which would eat the "⋯"'s right inset and
// shove it into the window corner) and cap the chip row at the remainder.
let chips_avail = (strip_w - px(100.)).max(px(80.));
let chips_avail = (strip_w - px(100.) - px(chrome_band_w.unwrap_or(0.))).max(px(80.));
// Only the chip row clips; a crowded row shrinks its chips (down to their
// `min_w`) and truncates their labels rather than pushing the "+" away.
let mut chips = h_flex()
@@ -1532,11 +1550,14 @@ impl Tty7App {
});
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));
// The window's right-corner chrome. On macOS, 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. Off macOS the bar spans
// the panel (the window controls are at its right end — see `app::render`),
// so the strip always reaches the right edge and always carries the chrome.
let right_chrome =
(!panel_open || !cfg!(target_os = "macos")).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.
@@ -1567,7 +1588,22 @@ impl Tty7App {
// leaving just the "⋯" overflow menu on a thin strip.
.when(show_chips, move |this| this.child(add_button))
.child(div().flex_1())
.when_some(right_chrome, |this, chrome| this.child(chrome))
.when_some(right_chrome, |this, chrome| match chrome_band_w {
// Over the panel: left-aligned on the panel's content edge, and
// wide enough that its own right edge lands where the window
// controls start.
Some(w) => this.child(
h_flex()
.flex_none()
.w(px(w))
.items_center()
.pl(px(tile_trailing_inset()))
.child(chrome),
),
// Over the terminal: pinned to the strip's trailing edge, which is
// the window's right edge (or the controls' left edge off macOS).
None => this.child(chrome),
})
}
}