fix(ui): tidy menus, the switcher and the editor's conflict strip

- New Tab menu: host endpoints elide against fixed caps instead of being
  clipped mid-glyph at the panel edge; notes are right-aligned again.
- Switcher: a workspace's tab count reads '1 tab', not a bare 1 beside
  the slot number.
- Editor 'changed on disk' strip: neutral with an amber dot and dialog
  buttons, Keep mine as the safe primary, instead of an amber wash with a
  library-default outlined button.
- Title bar search keeps its full label with a document docked.
This commit is contained in:
l0ng-ai
2026-09-27 16:50:19 +02:00
parent 5928b34da7
commit 73eb7e55c5
3 changed files with 118 additions and 46 deletions
+56 -26
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use gpui::prelude::*;
use gpui::{
AnyElement, Context, Entity, Focusable as _, PromptLevel, SharedString, Subscription, Window,
div, px,
div, px, rems,
};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::input::{Input, InputEvent, InputState, Position, TabSize};
@@ -1550,42 +1550,72 @@ impl Tty7App {
}
fn render_editor_conflict_banner(&self, cx: &mut Context<Self>) -> AnyElement {
use crate::ui::dialog::{self, Tone};
let tab_ix = self.active;
let ix = self.tab_code().map(|c| c.active).unwrap_or(0);
let theme = cx.theme();
let rungs = cx.global::<crate::ui::presets::Surfaces>().window;
// The floating notices' grammar, laid flat: a neutral strip over a
// hairline, and the state carried by one amber dot. A strip tinted
// amber end to end, with a white outlined button from the component
// library beside a bare-text one, was three visual languages in one
// 32px row.
//
// Keep mine is the filled answer because it is the safe one: Reload
// throws away every unsaved edit in the file, so it is offered, not
// pressed on the reader.
h_flex()
.flex_none()
.w_full()
.items_center()
.gap_2()
.px_2()
.py_1()
.bg(cx.theme().warning.opacity(0.15))
.gap(px(8.))
.pl(px(dialog::INSET))
.pr(px(6.))
.h(px(dialog::FOOTER_H))
.border_b_1()
.border_color(cx.theme().border)
.text_sm()
.child(div().flex_1().child(crate::ui::i18n::t(
crate::ui::i18n::L10nKey::FileChangedOnDisk,
)))
.border_color(theme.border)
.text_size(rems(crate::ui::right_panel::TAB_TEXT))
.child(
Button::new("editor-conflict-reload")
.label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Reload))
.small()
.on_click(cx.listener(move |this, _, window, cx| {
this.editor_reload_from_disk(tab_ix, ix, window, cx);
})),
div()
.flex_none()
.size(px(6.))
.rounded_full()
.bg(theme.warning),
)
.child(
Button::new("editor-conflict-keep")
.label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::KeepMine))
.ghost()
.small()
.on_click(cx.listener(move |this, _, _w, cx| {
if let Some(f) = this.tab_code_mut().and_then(|c| c.files.get_mut(ix)) {
f.conflict = false;
cx.notify();
}
})),
div()
.flex_1()
.min_w_0()
.truncate()
.child(crate::ui::i18n::t(
crate::ui::i18n::L10nKey::FileChangedOnDisk,
)),
)
.child(dialog::button(
"editor-conflict-reload",
crate::ui::i18n::t(crate::ui::i18n::L10nKey::Reload),
Tone::Secondary,
true,
rungs,
cx,
cx.listener(move |this, _, window, cx| {
this.editor_reload_from_disk(tab_ix, ix, window, cx);
}),
))
.child(dialog::button(
"editor-conflict-keep",
crate::ui::i18n::t(crate::ui::i18n::L10nKey::KeepMine),
Tone::Primary,
true,
rungs,
cx,
cx.listener(move |this, _, _w, cx| {
if let Some(f) = this.tab_code_mut().and_then(|c| c.files.get_mut(ix)) {
f.conflict = false;
cx.notify();
}
}),
))
.into_any_element()
}
}
+12 -7
View File
@@ -2567,9 +2567,10 @@ impl Tty7App {
.gap(px(2.))
.text_size(gpui::rems(11.5 / 16.))
.text_color(muted)
.when(!row.tabs.is_empty(), |c| {
c.child(row.tabs.len().to_string())
})
// In words, the way the tab column's header says it: a
// bare `1` here sat a few pixels from the slot number after
// the name, and the two read as the same thing twice.
.when(!row.tabs.is_empty(), |c| c.child(tab_count(row.tabs.len())))
.children(badge.map(|(label, _here)| {
div()
.text_size(gpui::rems(11. / 16.))
@@ -2973,10 +2974,7 @@ impl Tty7App {
.font_weight(gpui::FontWeight::MEDIUM)
.child(row.name.clone()),
)
.child(div().flex_shrink_0().child(match row.tabs.len() {
1 => t(L10nKey::SwitcherTabCountOne).to_string(),
n => t_fmt(L10nKey::SwitcherTabCount, &[("n", &n.to_string())]),
})),
.child(div().flex_shrink_0().child(tab_count(row.tabs.len()))),
);
for (nth, i) in hits.iter().enumerate() {
@@ -3447,6 +3445,13 @@ fn keycap(label: impl Into<gpui::SharedString>, cx: &App) -> AnyElement {
.into_any_element()
}
/// "1 tab", "3 tabs" — how both columns count a workspace's tabs.
fn tab_count(n: usize) -> String {
match n {
1 => t(L10nKey::SwitcherTabCountOne).to_string(),
n => t_fmt(L10nKey::SwitcherTabCount, &[("n", &n.to_string())]),
}
}
#[cfg(test)]
mod tests {
use super::*;
+50 -13
View File
@@ -1,7 +1,7 @@
use gpui::{
Animation, AnimationExt as _, AnyElement, App, Axis, Bounds, Context, FontWeight, MouseButton,
Pixels, SharedString, Window, canvas, deferred, div, ease_out_quint, linear_color_stop,
linear_gradient, prelude::*, px, relative,
linear_gradient, prelude::*, px,
};
use gpui_component::button::{Button, ButtonCustomVariant, ButtonVariants as _};
use gpui_component::input::Input;
@@ -1005,10 +1005,19 @@ fn menu_hosts(
/// Both halves are cut rather than allowed to push: the panel stops at
/// [`MENU_W`], and a descriptive host name next to a long `user@host:port`
/// asks for more than that. Which half gives way is the whole point — the name
/// is what the reader is picking by, so the endpoint is capped at half the row
/// and elides first, and the name takes everything left over. Sized the other
/// way round (name growing from nothing, endpoint at its natural width) a long
/// address squeezes the name down to `..` and the row names nothing at all.
/// is what the reader is picking by, so it keeps its width up to
/// [`MENU_NAME_W`], and the endpoint takes whatever is left and elides first.
/// Sized the other way round (name growing from nothing, endpoint at its
/// natural width) a long address squeezes the name down to `..` and the row
/// names nothing at all.
///
/// Both caps are fixed lengths, never a share of the row. The panel is sized
/// to fit its content, so while the text is laid out no row has a width yet:
/// anything capped by `relative(..)` or by flex shrinking measures at full
/// length, is squeezed afterwards, and is clipped mid-glyph at the panel edge
/// (`git@ssh.github.con`) instead of ending in an ellipsis. A pixel cap is
/// known up front, so the text elides against it. The two caps and the row's
/// insets add up to [`MENU_W`].
///
/// An empty note drops the right half entirely rather than leaving a zero-width
/// child to hold the `gap_3` open — the name is then free to use the full row,
@@ -1020,19 +1029,43 @@ fn menu_row(label: SharedString, note: SharedString, cx: &gpui::App) -> impl Int
.items_center()
.justify_between()
.gap_3()
.child(div().flex_1().min_w_0().truncate().child(label))
.child(
div()
.flex_shrink_0()
.max_w(match note.is_empty() {
true => MENU_W - MENU_ROW_INSET,
false => MENU_NAME_W,
})
.truncate()
.child(label),
)
.when(!note.is_empty(), |this| {
// Pushed right by the cell, not by `text_right`: a line set
// with `truncate` ignores its alignment and hugs the name.
this.child(
div()
.flex_shrink_0()
.max_w(relative(0.5))
.truncate()
.text_color(muted)
.child(note),
h_flex().flex_1().justify_end().child(
div()
.max_w(MENU_NOTE_W)
.truncate()
.text_color(muted)
.child(note),
),
)
})
}
/// The most of a [`menu_row`] its name keeps before it elides — the larger
/// share, since the name is what the row is picked by.
const MENU_NAME_W: Pixels = px(172.);
/// The most the endpoint beside a name gets: room for `user@host:port` on a
/// typical host, and the gap and the insets take the rest of [`MENU_W`].
const MENU_NOTE_W: Pixels = px(150.);
/// What the panel spends on either side of a row's content: its own 5px
/// inset and the row's 8px padding, twice.
const MENU_ROW_INSET: Pixels = px(26.);
/// The words behind the status dot's colour.
pub(crate) fn agent_status_label(
status: Option<crate::core::cli_agent::AgentStatus>,
@@ -2444,7 +2477,11 @@ impl Tty7App {
.id("titlebar-search")
.occlude()
.w(px(TITLEBAR_SEARCH_W))
.max_w(gpui::relative(0.5))
// Enough of the bar that the label and its chord still
// fit with a document docked beside the terminal; at a
// half it read `Search Everywhe…` in exactly the
// layout people search from most.
.max_w(gpui::relative(0.6))
.h(px(TITLEBAR_SEARCH_H))
.items_center()
.gap(px(7.))