diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index e6eed147..04eed513 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -7,7 +7,7 @@ use gpui::{ AnyElement, Context, Entity, EntityInputHandler as _, Focusable as _, MouseButton, PromptLevel, SharedString, Subscription, Window, div, px, rems, }; -use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::button::Button; use gpui_component::input::{Input, InputEvent, InputState, Position, RopeExt as _, TabSize}; use gpui_component::menu::{ContextMenuExt as _, PopupMenu, PopupMenuItem}; use gpui_component::{ @@ -27,6 +27,7 @@ mod nav; pub(crate) mod outline; mod problems; mod split; +mod strip; pub(crate) use nav::KEY_CONTEXT as NAV_KEY_CONTEXT; @@ -191,6 +192,10 @@ pub(crate) struct TabCode { /// the focus. `files` and `active` above are always the focused group's /// (see `split`). pub(crate) split: Option, + /// Buffers in the order they were last in front, most recent first — + /// what decides which files keep a place in the header's strip when + /// it cannot hold them all (see `strip`). + pub(crate) recent: Vec, } impl TabCode { @@ -203,6 +208,7 @@ impl TabCode { expanded: std::collections::HashSet::new(), selected: None, split: None, + recent: Vec::new(), } } @@ -327,6 +333,8 @@ pub(crate) struct EditorPanelState { nav: nav::EditorNav, /// The Problems list at the foot of the panel — see `problems`. problems: problems::ProblemsPane, + /// The header's list of every open file, while it is open. + strip_picker: Option, } impl EditorPanelState { @@ -371,6 +379,7 @@ impl EditorPanelState { events_tx: tx, nav: nav::EditorNav::new(cx), problems: Default::default(), + strip_picker: None, } } } @@ -429,6 +438,49 @@ pub(crate) fn language_for_path(path: &Path) -> &'static str { } } +/// What the status bar calls a language id from [`language_for_path`]. +/// `None` for plain text, which the bar leaves unsaid. +fn language_label(id: &str) -> Option<&'static str> { + Some(match id { + "rust" => "Rust", + "go" => "Go", + "python" => "Python", + "javascript" => "JavaScript", + "typescript" => "TypeScript", + "tsx" => "TypeScript JSX", + "json" => "JSON", + "toml" => "TOML", + "yaml" => "YAML", + "html" => "HTML", + "css" => "CSS", + "markdown" => "Markdown", + "bash" => "Shell", + "c" => "C", + "cpp" => "C++", + "java" => "Java", + "kotlin" => "Kotlin", + "lua" => "Lua", + "ruby" => "Ruby", + "php" => "PHP", + "sql" => "SQL", + "swift" => "Swift", + "scala" => "Scala", + "zig" => "Zig", + "proto" => "Protocol Buffers", + "diff" => "Diff", + "elixir" => "Elixir", + "erb" => "ERB", + "ejs" => "EJS", + "svelte" => "Svelte", + "astro" => "Astro", + "graphql" => "GraphQL", + "csharp" => "C#", + "cmake" => "CMake", + "make" => "Makefile", + _ => return None, + }) +} + /// How many frames a jump-to-line may wait for the editor to be laid out. /// /// `InputState::scroll_to` gives up when the buffer has never been painted, @@ -3108,6 +3160,9 @@ impl Tty7App { self.editor_split_follow_focus(window, cx); self.editor_gutter_sync(cx); self.editor_nav_tick(cx); + if let Some(code) = self.tab_code_mut() { + code.note_front(); + } let breadcrumbs = self.render_editor_breadcrumbs(window, cx); let body = self.render_editor_body( self.tab_code().and_then(TabCode::active_id), @@ -3276,38 +3331,37 @@ impl Tty7App { } else { row }; - let menu_app = cx.entity().downgrade(); - // v4 chrome: the file names in body ink — the one heading the column - // has — a hairline in the divider tone under the bar, and the rail's - // 26px close tile, so the header reads as part of the plane it sits in - // rather than a toolbar bolted on top of it. + // v6 chrome: no rule under the bar — the header, the breadcrumbs and + // the text are one plane — and only as many file cells as the column + // has room for, the rest behind a `+N` list (see `strip`). let (tile, glyph) = ( crate::ui::tab_strip::RAIL_TILE, crate::ui::tab_strip::RAIL_TILE_GLYPH, ); - let files: Vec<(usize, SharedString, String, bool, bool)> = self + let cap = self.editor_strip_cap(chrome, cx); + let (shown, hidden) = self .tab_code() - .map(|c| { - c.files - .iter() - .enumerate() - .filter_map(|(pos, id)| { - let f = self.buffer(*id)?; - let tip = if f.untitled.is_some() { - f.label().to_string() - } else { - f.path.display().to_string() - }; - Some((pos, f.label(), tip, pos == c.active, f.dirty)) - }) - .collect() - }) + .map(|c| c.strip_split(cap)) .unwrap_or_default(); + let active_id = self.tab_code().and_then(TabCode::active_id); + let files: Vec<(usize, SharedString, String, bool, bool)> = shown + .into_iter() + .filter_map(|(pos, id)| { + let f = self.buffer(id)?; + let tip = if f.untitled.is_some() { + f.label().to_string() + } else { + f.path.display().to_string() + }; + Some((pos, f.label(), tip, Some(id) == active_id, f.dirty)) + }) + .collect(); let strip = if files.is_empty() { div() .min_w_0() + .px(px(8.)) .text_ellipsis() - .text_size(gpui::rems(crate::ui::right_panel::TEXT)) + .text_size(gpui::rems(strip::CELL_TEXT)) .text_color(cx.theme().muted_foreground) .child(SharedString::from(t(L10nKey::EditorNoFileOpen))) .into_any_element() @@ -3315,26 +3369,28 @@ impl Tty7App { h_flex() .id("editor-file-tabs") .min_w_0() - .h_full() - .overflow_x_scroll() + .gap(px(2.)) + .overflow_hidden() .children(files.into_iter().map(|(pos, name, tip, active, dirty)| { self.render_file_tab(pos, name, tip, active, dirty, cx) })) .into_any_element() }; + let back = + (chrome == DocumentChrome::Fill).then(|| self.render_back_to_terminal(window, cx)); row.flex_none() .h(px(crate::ui::app::TITLE_BAR_HEIGHT)) .items_center() - .gap(px(4.)) + .gap(px(2.)) .pl(px(lead - 8.).max(px(0.))) // The glyph, not the tile, lands on `CONTENT_INSET`, the column // the file name starts on at the other end of the bar. .pr(px(crate::ui::app::CONTENT_INSET - (tile - glyph) / 2.)) - .border_b(crate::ui::theme::hairline(window)) - .border_color(cx.theme().sidebar_border) + .children(back) .child(strip) + .children(self.render_strip_overflow(&hidden, cap, cx)) .child( - div().occlude().flex_shrink_0().child( + div().occlude().flex_none().child( crate::ui::tab_strip::chrome_tile_sized( Button::new("editor-new-file").icon(Icon::new(IconName::Plus)), tile, @@ -3351,8 +3407,9 @@ impl Tty7App { ) // Whatever is left of the bar stays a place to drag the window by. .child(div().flex_1().h_full()) + .child(self.render_layout_switch(chrome, cx)) .child( - div().occlude().flex_shrink_0().child( + div().occlude().flex_none().ml(px(4.)).child( crate::ui::tab_strip::chrome_tile_sized( Button::new("editor-panel-close").icon(Icon::new(IconName::Close)), tile, @@ -3367,9 +3424,6 @@ impl Tty7App { })), ), ) - .context_menu(move |menu, _window, cx| { - Tty7App::document_header_menu(menu, &menu_app, cx) - }) } /// One file in the header's strip: its name, and a slot that shows the @@ -3385,7 +3439,8 @@ impl Tty7App { cx: &mut Context, ) -> AnyElement { let group: SharedString = format!("editor-file-tab-{pos}").into(); - let slot = crate::ui::tab_strip::ROW_STATUS_SLOT; + let slot = 16.; + let muted = cx.theme().muted_foreground; let close = div() .id(("editor-file-tab-close", pos)) .flex_none() @@ -3393,61 +3448,64 @@ impl Tty7App { .flex() .items_center() .justify_center() - .rounded(px(3.)) + .rounded(px(4.)) .hover(|s| s.bg(cx.theme().muted)) - .child( - Icon::new(IconName::Close) - .xsmall() - .text_color(cx.theme().muted_foreground), - ) + .child(Icon::new(IconName::Close).xsmall().text_color(muted)) .on_click(cx.listener(move |this, _, window, cx| { cx.stop_propagation(); this.editor_close_file(pos, window, cx); })); + // Unsaved is a state, not an alarm: the dot is in caption ink, the + // same as the `+N` button's, not the warning hue. let slot_el = div() .flex_none() .size(px(slot)) .flex() .items_center() .justify_center() - .map(|d| { - if dirty { - d.child( - div() - .group_hover(group.clone(), |s| s.invisible()) - .size(px(crate::ui::tab_strip::ROW_STATUS_DOT)) - .rounded_full() - .bg(cx.theme().warning), - ) - } else { - d - } + .when(dirty, |d| { + d.child( + div() + .group_hover(group.clone(), |s| s.invisible()) + .size(px(6.)) + .rounded_full() + .bg(muted), + ) }); div() .id(("editor-file-tab", pos)) .group(group.clone()) .occlude() .flex_none() - .h(px(26.)) + .min_w_0() + .max_w(px(strip::CELL_MAX_W)) + .h(px(strip::CELL_H)) .flex() .items_center() - .gap(px(4.)) - .pl(px(8.)) - .pr(px(4.)) - .rounded(px(crate::ui::tab_strip::RAIL_TILE_RADIUS)) - .text_size(gpui::rems(crate::ui::right_panel::TEXT)) + .gap(px(6.)) + .pl(px(10.)) + .pr(px(6.)) + .rounded(px(strip::CELL_RADIUS)) + .text_size(gpui::rems(strip::CELL_TEXT)) .map(|d| match active { true => d .font_weight(gpui::FontWeight::MEDIUM) .text_color(cx.theme().foreground) .bg(cx.theme().sidebar_accent), - false => d.text_color(cx.theme().muted_foreground).hover(|s| { + false => d.text_color(muted).hover(|s| { s.bg(gpui::rgb( cx.global::().sidebar.hover, )) }), }) - .child(div().whitespace_nowrap().child(name)) + .child( + div() + .min_w_0() + .whitespace_nowrap() + .overflow_hidden() + .text_ellipsis() + .child(name), + ) .child( div() .relative() @@ -3558,23 +3616,49 @@ impl Tty7App { }); let line_ending: Option<&'static str> = active.map(|f| f.format.line_ending.label()); let encoding: Option = active.map(|f| f.format.encoding_label().into()); + let language: Option<&'static str> = active.and_then(|f| language_label(f.language())); + let (lsp_lead, lsp_tail) = match self.render_lsp_status(cx) { + Some((el, true)) => (Some(el), None), + Some((el, false)) => (None, Some(el)), + None => (None, None), + }; // Metadata, not a toolbar: caption ink on the plane's own fill, set // off by a hairline in the divider tone rather than a control border. + // The readouts that do something answer the pointer with a soft fill + // and body ink; the rest are plain text in the same row. + let fg = cx.theme().foreground; + let hover = cx.theme().muted; + let item = |id: &'static str, label: SharedString| { + div() + .id(id) + .flex_none() + .h(px(20.)) + .px(px(4.)) + .mx(px(-4.)) + .flex() + .items_center() + .rounded(px(4.)) + .cursor_pointer() + .hover(move |s| s.bg(hover).text_color(fg)) + .child(label) + }; h_flex() .flex_none() .w_full() - .h(px(26.)) + .h(px(28.)) .items_center() - .gap_3() + .gap(px(14.)) .px(px(crate::ui::app::CONTENT_INSET)) .border_t(crate::ui::theme::hairline(window)) .border_color(cx.theme().sidebar_border) - .text_size(gpui::rems(crate::ui::right_panel::META)) + .text_size(gpui::rems(11.5 / 16.)) .text_color(muted) + .whitespace_nowrap() + .children(lsp_lead) .child(div().flex_1()) // The counts open the Problems list. - .children(self.render_lsp_status(cx).map(|status| { + .children(lsp_tail.map(|status| { div() .id("status-problems") .flex_none() @@ -3583,32 +3667,35 @@ impl Tty7App { .child(status) })) .when(is_markdown, |this| { + let label = if preview { + t(L10nKey::EditorEdit) + } else { + t(L10nKey::EditorPreview) + }; this.child( - Button::new("status-md-preview") - .label(if preview { - t(L10nKey::EditorEdit) - } else { - t(L10nKey::EditorPreview) - }) - .custom(crate::ui::tab_strip::chrome_tile_variant(cx)) - .xsmall() + item("status-md-preview", label.into()) .on_click(cx.listener(|this, _, _w, cx| this.toggle_document_preview(cx))), ) }) .when_some(wrap, |this, wrap| { + let label = if wrap { + t(L10nKey::EditorWrapOn) + } else { + t(L10nKey::EditorWrapOff) + }; + this.child(item("status-wrap", label.into()).on_click( + cx.listener(|this, _, window, cx| this.toggle_document_wrap(window, cx)), + )) + }) + // Tabular figures, so the position does not jitter sideways as + // the caret walks from line 9 to line 10. A click asks for a line + // to go to. + .when_some(cursor, |this, t| { this.child( - Button::new("status-wrap") - .label(if wrap { - t(L10nKey::EditorWrapOn) - } else { - t(L10nKey::EditorWrapOff) - }) - .custom(crate::ui::tab_strip::chrome_tile_variant(cx)) - .xsmall() + item("status-cursor", t) + .font_features(crate::ui::theme::tabular_figures()) .on_click( - cx.listener(|this, _, window, cx| { - this.toggle_document_wrap(window, cx) - }), + cx.listener(|this, _, window, cx| this.editor_go_to_line(window, cx)), ), ) }) @@ -3616,22 +3703,11 @@ impl Tty7App { .when_some(encoding, |this, t| this.child(div().flex_none().child(t))) .when_some(line_ending, |this, eol| { this.child( - Button::new("status-eol") - .label(eol) - .custom(crate::ui::tab_strip::chrome_tile_variant(cx)) - .xsmall() + item("status-eol", eol.into()) .on_click(cx.listener(|this, _, _w, cx| this.toggle_line_ending(cx))), ) }) - // Tabular figures, so the position does not jitter sideways as - // the caret walks from line 9 to line 10. - .when_some(cursor, |this, t| { - this.child( - div() - .font_features(crate::ui::theme::tabular_figures()) - .child(t), - ) - }) + .when_some(language, |this, l| this.child(div().flex_none().child(l))) } fn render_editor_empty(&self, cx: &Context) -> gpui::Div { diff --git a/src/ui/code_editor/nav.rs b/src/ui/code_editor/nav.rs index 61742322..bd941640 100644 --- a/src/ui/code_editor/nav.rs +++ b/src/ui/code_editor/nav.rs @@ -21,7 +21,7 @@ use std::time::{Duration, Instant}; use gpui::prelude::*; use gpui::{AnyElement, App, Context, Window, div, px}; use gpui_component::input::{LineEdit, Position}; -use gpui_component::{ActiveTheme as _, h_flex}; +use gpui_component::{ActiveTheme as _, Icon, IconName, h_flex}; use tty7_core::core::machine::TabId; use super::outline::{self, Outline, SymbolKind}; @@ -744,7 +744,7 @@ impl Tty7App { id: BufferId, slot: usize, focused: bool, - window: &Window, + _window: &Window, cx: &mut Context, ) -> Option { let f = self.buffer(id)?; @@ -774,7 +774,15 @@ impl Tty7App { let theme = cx.theme(); let (fg, muted) = (theme.foreground, theme.muted_foreground); - let sep = || div().flex_none().px(px(4.)).text_color(muted).child("›"); + // A chevron a rung fainter than the names, so the path reads as its + // names and the separators recede. + let sep = || { + div().flex_none().px(px(4.)).child( + Icon::new(IconName::ChevronRight) + .size(px(9.)) + .text_color(muted.opacity(0.6)), + ) + }; let last = segments.len().saturating_sub(1); let mut path = h_flex() .flex_shrink(1.) @@ -821,7 +829,7 @@ impl Tty7App { .when(kind != SymbolKind::Impl, |d| { d.child(div().text_color(muted).child(kind.tag())) }) - .child(div().text_color(fg).child(name)), + .child(div().text_color(muted).child(name)), ); } div() @@ -851,12 +859,10 @@ impl Tty7App { .id(("editor-breadcrumbs", slot)) .flex_none() .w_full() - .h(px(24.)) + .h(px(26.)) .items_center() .overflow_hidden() .px(px(crate::ui::app::CONTENT_INSET)) - .border_b(crate::ui::theme::hairline(window)) - .border_color(cx.theme().sidebar_border) .text_size(gpui::rems(crate::ui::right_panel::META)) .child(path) .children(symbols) diff --git a/src/ui/code_editor/strip.rs b/src/ui/code_editor/strip.rs new file mode 100644 index 00000000..19ba2211 --- /dev/null +++ b/src/ui/code_editor/strip.rs @@ -0,0 +1,813 @@ +//! The header's file strip when there are more files than it has room for, +//! and the controls that share the header with it. +//! +//! **The strip keeps the files you use.** A docked column fits two or three +//! names, a filled window six or seven; past that the strip used to scroll +//! sideways, which put the file you were just in out of sight as often as +//! not. Now it holds the most recently fronted files — in their strip order, +//! so nothing jumps about as you switch between them — and a `+N` button at +//! its end lists everything, hidden files first, with a filter box. +//! +//! **Dock / Fill** is a two-cell switch rather than a menu entry, and a filled +//! editor leads with a pill naming the terminal it covers, which docks it back +//! beside that terminal. + +use gpui::prelude::*; +use gpui::{ + Anchor, AnyElement, Context, Entity, FontWeight, SharedString, Subscription, Window, anchored, + deferred, div, px, rems, +}; +use gpui_component::input::{Input, InputEvent, InputState}; +use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; + +use super::{BufferId, TabCode}; +use crate::ui::app::Tty7App; +use crate::ui::document_column::DocumentChrome; +use crate::ui::i18n::{L10nKey, t, t_fmt}; +use tty7_core::core::config::DocumentLayout; + +/// Height of a strip cell — a file, the `+N` button, the terminal pill. +pub(super) const CELL_H: f32 = 28.; +/// Their corner. +pub(super) const CELL_RADIUS: f32 = 7.; +/// The widest a file's name may make its cell before it is elided. +pub(super) const CELL_MAX_W: f32 = 168.; +/// Header text: a half step under the body size, like the rail's rows. +pub(super) const CELL_TEXT: f32 = 12.5 / 16.; + +const PICKER_W: f32 = 340.; +const PICKER_LIST_MAX_H: f32 = 340.; +const PICKER_ROW_H: f32 = 28.; + +impl TabCode { + /// Every file in the strip, the one in front first and the rest by when + /// they last were. Files that have never been in front follow in strip + /// order. + pub(crate) fn by_recency(&self) -> Vec { + let front = self.active_id(); + let mut out: Vec = front.into_iter().collect(); + for id in self.recent.iter().chain(self.files.iter()) { + if self.files.contains(id) && !out.contains(id) { + out.push(*id); + } + } + out + } + + /// Moves the file in front to the head of `recent`. Run every frame the + /// editor draws, so whatever brought a file forward — a click, a link, + /// back/forward, a restore — is counted without a hook of its own. The + /// other group's files keep their places, so moving the focus between + /// groups of a split does not wipe either one's order. + pub(crate) fn note_front(&mut self) { + let order = self.by_recency(); + let rest: Vec = self + .recent + .iter() + .copied() + .filter(|id| !order.contains(id) && self.shows(*id)) + .collect(); + self.recent = order; + self.recent.extend(rest); + } + + /// Which files the strip shows when it has room for `cap`: the `cap` most + /// recent, as `(position in files, id)` in strip order; and the rest, + /// most recent first. + pub(crate) fn strip_split(&self, cap: usize) -> (Vec<(usize, BufferId)>, Vec) { + let order = self.by_recency(); + let cap = cap.max(1); + let keep = &order[..cap.min(order.len())]; + let shown = self + .files + .iter() + .copied() + .enumerate() + .filter(|(_, id)| keep.contains(id)) + .collect(); + let hidden = order.iter().skip(cap).copied().collect(); + (shown, hidden) + } +} + +/// The `+N` list while it is open. +pub(crate) struct StripPicker { + input: Entity, + /// The highlighted row, an index into the rows the query leaves. + hi: usize, + /// Whether the pointer is on the `+N` button. A press there is the + /// button's to answer — it closes the list on click — so the list does + /// not also close itself on the press and have the click reopen it. + over_button: bool, + _sub: Subscription, +} + +/// One row of the list. +struct PickerRow { + id: BufferId, + name: SharedString, + dir: SharedString, + dirty: bool, + front: bool, +} + +impl Tty7App { + /// How many names the strip holds. Fixed counts rather than a measure: + /// names differ so much in width that a measured fit would reshuffle the + /// strip whenever a long one came forward. + pub(super) fn editor_strip_cap(&self, chrome: DocumentChrome, cx: &gpui::App) -> usize { + let (left, right) = (self.left_panel_open(cx), self.right_panel_open(cx)); + match chrome { + DocumentChrome::Fill if left && right => 5, + DocumentChrome::Fill => 7, + _ if right => 2, + _ => 3, + } + } + + fn editor_strip_rows(&self, cx: &gpui::App) -> Vec { + let Some(code) = self.tab_code() else { + return Vec::new(); + }; + let front = code.active_id(); + let roots = code.roots.as_slice(); + let local_host = self.spawn_host(cx); + code.by_recency() + .into_iter() + .filter_map(|id| { + let f = self.buffer(id)?; + let dir = if f.untitled.is_some() { + String::new() + } else { + let local = f.host.id() == local_host; + let mut segs = + super::nav::path_segments(&f.path, if local { roots } else { &[] }); + segs.pop(); + segs.join("/") + }; + Some(PickerRow { + id, + name: f.label(), + dir: dir.into(), + dirty: f.dirty, + front: Some(id) == front, + }) + }) + .collect() + } + + /// The rows the query leaves, in the order Up/Down walks them. + fn editor_strip_matches(&self, cap: usize, cx: &gpui::App) -> (Vec, usize) { + let q = self + .editor + .strip_picker + .as_ref() + .map(|p| p.input.read(cx).value().trim().to_lowercase()) + .unwrap_or_default(); + let rows = self.editor_strip_rows(cx); + if !q.is_empty() { + let hits: Vec = rows + .into_iter() + .filter(|r| r.name.to_lowercase().contains(&q) || r.dir.to_lowercase().contains(&q)) + .collect(); + return (hits, 0); + } + // Hidden files first — they are what the list is for — then the ones + // the strip already shows. Rows are in recency order, and the strip + // shows the first `cap` of it. + let cap = cap.max(1).min(rows.len()); + let mut rows = rows; + let shown: Vec = rows.drain(..cap).collect(); + let hidden = rows.len(); + rows.extend(shown); + (rows, hidden) + } + + pub(super) fn editor_toggle_strip_picker( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + if self.editor.strip_picker.is_some() { + self.editor_close_strip_picker(window, cx); + return; + } + let n = self.tab_code().map_or(0, |c| c.files.len()); + let input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(t_fmt(L10nKey::EditorStripSearch, &[("n", &n.to_string())])) + }); + input.update(cx, |state, cx| state.focus(window, cx)); + let _sub = cx.subscribe_in(&input, window, |this, _, ev: &InputEvent, _, cx| { + if matches!(ev, InputEvent::Change) { + if let Some(p) = this.editor.strip_picker.as_mut() { + p.hi = 0; + } + cx.notify(); + } + }); + self.editor.strip_picker = Some(StripPicker { + input, + hi: 0, + over_button: true, + _sub, + }); + cx.notify(); + } + + pub(super) fn editor_close_strip_picker( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + if self.editor.strip_picker.take().is_some() { + self.focus_editor(window, cx); + cx.notify(); + } + } + + fn editor_strip_pick(&mut self, id: BufferId, window: &mut Window, cx: &mut Context) { + self.editor.strip_picker = None; + if let Some(pos) = self + .tab_code() + .and_then(|c| c.files.iter().position(|f| *f == id)) + { + self.editor_activate(pos, window, cx); + } + cx.notify(); + } + + fn on_strip_picker_key( + &mut self, + ev: &gpui::KeyDownEvent, + cap: usize, + window: &mut Window, + cx: &mut Context, + ) { + let (rows, _) = self.editor_strip_matches(cap, cx); + let Some(p) = self.editor.strip_picker.as_mut() else { + return; + }; + let n = rows.len(); + match ev.keystroke.key.as_str() { + "escape" => self.editor_close_strip_picker(window, cx), + "up" if n > 0 => { + p.hi = p.hi.min(n - 1).saturating_sub(1); + cx.notify(); + } + "down" if n > 0 => { + p.hi = (p.hi + 1).min(n - 1); + cx.notify(); + } + "enter" => { + if let Some(row) = rows.get(p.hi.min(n.saturating_sub(1))) { + let id = row.id; + self.editor_strip_pick(id, window, cx); + } + } + _ => return, + } + cx.stop_propagation(); + } + + /// The `+N` button at the end of the strip, with its list when open. + pub(super) fn render_strip_overflow( + &self, + hidden: &[BufferId], + cap: usize, + cx: &mut Context, + ) -> Option { + if hidden.is_empty() { + return None; + } + let theme = cx.theme(); + let open = self.editor.strip_picker.is_some(); + let hidden_dirty = hidden + .iter() + .any(|id| self.buffer(*id).is_some_and(|f| f.dirty)); + let button = h_flex() + .id("editor-strip-more") + .occlude() + .flex_none() + .h(px(CELL_H)) + .px(px(8.)) + .gap(px(5.)) + .items_center() + .rounded(px(CELL_RADIUS)) + .text_size(rems(12. / 16.)) + .font_features(crate::ui::theme::tabular_figures()) + .text_color(theme.muted_foreground) + .when(open, |d| d.bg(theme.sidebar_accent)) + .hover(|s| s.bg(theme.sidebar_accent).text_color(theme.foreground)) + .when(hidden_dirty, |d| { + d.child( + div() + .size(px(crate::ui::tab_strip::ROW_STATUS_DOT)) + .rounded_full() + .bg(theme.muted_foreground), + ) + }) + .child(format!("+{}", hidden.len())) + .child( + Icon::new(IconName::ChevronDown) + .xsmall() + .text_color(theme.muted_foreground), + ) + .tooltip(|window, cx| { + gpui_component::tooltip::Tooltip::new(t(L10nKey::EditorStripAllFiles)) + .build(window, cx) + }) + .on_hover(cx.listener(|this, over: &bool, _, _| { + if let Some(p) = this.editor.strip_picker.as_mut() { + p.over_button = *over; + } + })) + .on_click(cx.listener(|this, _, window, cx| { + this.editor_toggle_strip_picker(window, cx); + })); + Some( + div() + .relative() + .flex_none() + .child(button) + .children(self.render_strip_picker(cap, cx)) + .into_any_element(), + ) + } + + fn render_strip_picker(&self, cap: usize, cx: &mut Context) -> Option { + let picker = self.editor.strip_picker.as_ref()?; + let (rows, hidden) = self.editor_strip_matches(cap, cx); + let querying = !picker.input.read(cx).value().trim().is_empty(); + let hi = picker.hi.min(rows.len().saturating_sub(1)); + let theme = cx.theme(); + let (fg, muted) = (theme.foreground, theme.muted_foreground); + let row_hover = gpui::rgb(cx.global::().popover.hover); + + let heading = |label: SharedString| { + div() + .h(px(24.)) + .pt(px(4.)) + .px(px(8.)) + .flex() + .items_center() + .text_size(rems(11. / 16.)) + .font_weight(FontWeight::MEDIUM) + .text_color(muted) + .child(label) + }; + let empty = rows.is_empty(); + let mut list = v_flex() + .id("editor-strip-list") + .max_h(px(PICKER_LIST_MAX_H)) + .overflow_y_scroll() + .gap(px(1.)); + for (i, row) in rows.into_iter().enumerate() { + if !querying && i == 0 && hidden > 0 { + list = list.child(heading( + t_fmt(L10nKey::EditorStripHidden, &[("n", &hidden.to_string())]).into(), + )); + } + if !querying && i == hidden { + list = list.child(heading(t(L10nKey::EditorStripInBar).into())); + } + let id = row.id; + let group: SharedString = format!("editor-strip-row-{i}").into(); + let slot = div() + .id(("editor-strip-row-close", i)) + .relative() + .flex_none() + .size(px(18.)) + .rounded(px(4.)) + .hover(|s| s.bg(theme.muted)) + .when(row.dirty, |d| { + d.child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .group_hover(group.clone(), |s| s.invisible()) + .child(div().size(px(6.)).rounded_full().bg(muted)), + ) + }) + .child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .when(row.dirty, |d| d.invisible()) + .group_hover(group.clone(), |s| s.visible()) + .child(Icon::new(IconName::Close).xsmall().text_color(muted)), + ) + .on_click(cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.editor_close_buffer(id, window, cx); + })); + list = list.child( + h_flex() + .id(("editor-strip-row", i)) + .group(group) + .flex_none() + .h(px(PICKER_ROW_H)) + .pl(px(8.)) + .pr(px(4.)) + .gap(px(8.)) + .items_center() + .rounded(px(6.)) + .when(i == hi, |d| d.bg(row_hover)) + .on_mouse_move(cx.listener(move |this, _, _, cx| { + if let Some(p) = this.editor.strip_picker.as_mut() + && p.hi != i + { + p.hi = i; + cx.notify(); + } + })) + .on_click(cx.listener(move |this, _, window, cx| { + this.editor_strip_pick(id, window, cx); + })) + .child( + div() + .min_w(px(40.)) + .whitespace_nowrap() + .text_ellipsis() + .overflow_hidden() + .text_color(fg) + .when(row.front, |d| d.font_weight(FontWeight::MEDIUM)) + .child(row.name), + ) + .child( + div() + .flex_1() + .min_w_0() + .whitespace_nowrap() + .text_ellipsis() + .overflow_hidden() + .text_size(rems(11.5 / 16.)) + .text_color(muted) + .child(row.dir), + ) + .child(slot), + ); + } + if empty { + list = list.child( + div() + .h(px(PICKER_ROW_H)) + .px(px(8.)) + .flex() + .items_center() + .text_color(muted) + .child(t(L10nKey::EditorStripNoMatch)), + ); + } + + let rule = || div().h(px(1.)).mx(px(6.)).my(px(4.)).bg(theme.border); + let footer_button = |key: &'static str, label: L10nKey| { + div() + .id(key) + .text_color(muted) + .hover(|s| s.text_color(fg)) + .child(t(label)) + }; + let card = v_flex() + .occlude() + .w(px(PICKER_W)) + .p(px(5.)) + .bg(theme.popover) + .text_color(theme.popover_foreground) + .border_1() + .border_color(theme.border) + .rounded(px(10.)) + .shadow_lg() + .text_size(rems(CELL_TEXT)) + .on_key_down( + cx.listener(move |this, ev: &gpui::KeyDownEvent, window, cx| { + this.on_strip_picker_key(ev, cap, window, cx); + }), + ) + .on_mouse_down_out(cx.listener(|this, _, window, cx| { + if this + .editor + .strip_picker + .as_ref() + .is_some_and(|p| p.over_button) + { + return; + } + this.editor_close_strip_picker(window, cx); + })) + .child( + h_flex() + .h(px(30.)) + .px(px(4.)) + .gap(px(3.)) + .items_center() + .child(Icon::new(IconName::Search).xsmall().text_color(muted)) + .child( + div().flex_1().min_w_0().child( + Input::new(&picker.input) + .appearance(false) + .small() + .text_size(rems(CELL_TEXT)), + ), + ), + ) + .child(rule()) + .child(list) + .child(rule()) + .child( + h_flex() + .h(px(28.)) + .px(px(8.)) + .gap(px(14.)) + .items_center() + .text_size(rems(12. / 16.)) + .child( + footer_button("editor-strip-close-saved", L10nKey::EditorStripCloseSaved) + .on_click(cx.listener(|this, _, window, cx| { + this.editor.strip_picker = None; + let ids = this.editor_strip_others(true); + this.editor_close_buffers(ids, window, cx); + this.focus_editor(window, cx); + })), + ) + .child( + footer_button("editor-strip-close-others", L10nKey::EditorStripCloseOthers) + .on_click(cx.listener(|this, _, window, cx| { + this.editor.strip_picker = None; + let ids = this.editor_strip_others(false); + this.editor_close_buffers(ids, window, cx); + this.focus_editor(window, cx); + })), + ), + ); + Some( + div() + .absolute() + .right_0() + .top(px(CELL_H + 4.)) + .child( + deferred( + anchored() + .anchor(Anchor::TopRight) + .snap_to_window_with_margin(px(8.)) + .child(card), + ) + .with_priority(1), + ) + .into_any_element(), + ) + } + + /// Every file in the strip but the one in front — only the saved ones + /// when `saved_only`. + fn editor_strip_others(&self, saved_only: bool) -> Vec { + let Some(code) = self.tab_code() else { + return Vec::new(); + }; + let front = code.active_id(); + code.files + .iter() + .copied() + .filter(|id| Some(*id) != front) + .filter(|id| !saved_only || self.buffer(*id).is_some_and(|f| !f.dirty)) + .collect() + } + + /// Dock / Fill, as a two-cell switch: a raised cell on a sunken track. + pub(super) fn render_layout_switch( + &self, + chrome: DocumentChrome, + cx: &mut Context, + ) -> AnyElement { + let theme = cx.theme(); + let sf = cx.global::().window; + let dark = theme.mode.is_dark(); + // What is on screen, not what was asked for: a window too narrow for + // a column fills even when set to dock. + let current = if chrome.is_dock() { + DocumentLayout::Dock + } else { + DocumentLayout::Fill + }; + let cell = |i: usize, layout: DocumentLayout, tip: L10nKey| { + let live = layout == current; + let ink = if live { + theme.foreground + } else { + theme.muted_foreground + }; + // The glyph: the window, with the column the editor takes in it. + let frame = div() + .relative() + .w(px(14.)) + .h(px(11.)) + .border_1() + .border_color(ink) + .rounded(px(2.)); + let glyph = match layout { + DocumentLayout::Dock => frame.child( + div() + .absolute() + .top_0() + .bottom_0() + .left(px(4.)) + .w(px(1.)) + .bg(ink), + ), + DocumentLayout::Fill => frame.child( + div() + .absolute() + .top(px(1.5)) + .bottom(px(1.5)) + .left(px(1.5)) + .right(px(1.5)) + .rounded(px(0.8)) + .bg(ink), + ), + }; + div() + .id(("editor-layout", i)) + .w(px(24.)) + .h(px(22.)) + .flex() + .items_center() + .justify_center() + .rounded(px(5.)) + .when(live, |d| { + d.bg(if dark { + gpui::rgb(sf.selected).into() + } else { + theme.background + }) + .shadow_xs() + }) + .child(glyph) + .tooltip(move |window, cx| { + gpui_component::tooltip::Tooltip::new(t(tip)).build(window, cx) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.set_document_layout(layout, cx); + })) + }; + h_flex() + .id("editor-layout-switch") + .occlude() + .flex_none() + .ml(px(8.)) + .p(px(2.)) + .gap(px(2.)) + .rounded(px(CELL_RADIUS)) + .bg(gpui::rgb(sf.hover)) + .child(cell(0, DocumentLayout::Dock, L10nKey::DocumentDock)) + .child(cell(1, DocumentLayout::Fill, L10nKey::DocumentFill)) + .into_any_element() + } + + /// A filled editor's lead: the terminal it covers, by name, and a way back + /// to it that keeps the file open beside it. + pub(super) fn render_back_to_terminal( + &self, + window: &Window, + cx: &mut Context, + ) -> AnyElement { + let theme = cx.theme(); + let name = self + .tabs + .get(self.active) + .map(|tab| self.tab_label(tab, self.active, Some(window), cx)) + .unwrap_or_default(); + let badge_bg = gpui::rgb(cx.global::().window.selected); + h_flex() + .flex_none() + .items_center() + .child( + h_flex() + .id("editor-back-to-terminal") + .occlude() + .h(px(CELL_H)) + .pl(px(8.)) + .pr(px(10.)) + .gap(px(7.)) + .items_center() + .rounded(px(CELL_RADIUS)) + .text_size(rems(CELL_TEXT)) + .text_color(theme.muted_foreground) + .hover(|s| s.bg(theme.sidebar_accent).text_color(theme.foreground)) + .child( + div() + .flex_none() + .size(px(16.)) + .rounded_full() + .flex() + .items_center() + .justify_center() + .bg(badge_bg) + .font_family(theme.mono_font_family.clone()) + .text_size(px(9.5)) + .font_weight(FontWeight::SEMIBOLD) + .child(">_"), + ) + .child( + div() + .max_w(px(CELL_MAX_W)) + .whitespace_nowrap() + .text_ellipsis() + .overflow_hidden() + .child(name), + ) + .tooltip(|window, cx| { + gpui_component::tooltip::Tooltip::new(t(L10nKey::DocumentDock)) + .build(window, cx) + }) + .on_click(cx.listener(|this, _, _, cx| { + this.set_document_layout(DocumentLayout::Dock, cx); + })), + ) + .child( + div() + .flex_none() + .w(crate::ui::theme::hairline(window)) + .h(px(16.)) + .mx(px(6.)) + .bg(theme.border), + ) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(n: u64) -> Vec { + (1..=n).map(gpui::EntityId::from).collect() + } + + fn code(files: &[BufferId], active: usize) -> TabCode { + let mut code = TabCode::new(); + code.files = files.to_vec(); + code.active = active; + code + } + + #[test] + fn the_strip_keeps_the_files_last_in_front_in_their_strip_order() { + let ids = ids(5); + let mut code = code(&ids, 0); + for front in [3, 1, 4] { + code.active = front; + code.note_front(); + } + let (shown, hidden) = code.strip_split(3); + // 4, 1 and 3 were in front last; they keep their strip positions. + assert_eq!(shown, vec![(1, ids[1]), (3, ids[3]), (4, ids[4])]); + // The rest by recency: 0 was in front before any of them, 2 never. + assert_eq!(hidden, vec![ids[0], ids[2]]); + } + + #[test] + fn the_file_in_front_always_has_a_place() { + let ids = ids(4); + let mut code = code(&ids, 0); + code.note_front(); + // Brought forward from outside the strip's view (a link, the list) + // without a frame drawn in between. + code.active = 3; + let (shown, _) = code.strip_split(1); + assert_eq!(shown, vec![(3, ids[3])]); + } + + #[test] + fn a_split_keeps_the_other_groups_order() { + let ids = ids(4); + let mut code = code(&ids[..2], 1); + code.split = Some(super::super::split::OtherGroup { + files: ids[2..].to_vec(), + active: 0, + focus_left: true, + }); + code.note_front(); + code.swap_focus(); + code.note_front(); + code.swap_focus(); + code.note_front(); + assert_eq!(code.recent, vec![ids[1], ids[0], ids[2], ids[3]]); + } + + #[test] + fn closed_files_leave_the_order() { + let ids = ids(3); + let mut code = code(&ids, 2); + code.note_front(); + code.files.remove(1); + code.active = 1; + code.note_front(); + assert_eq!(code.recent, vec![ids[2], ids[0]]); + } +} diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index f7231719..b9b91c4a 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1181,6 +1181,13 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::EditorUnsavedChanges => "\"{name}\" has unsaved changes", L10nKey::EditorDiscard => "Discard", L10nKey::EditorNoFileOpen => "No file open", + L10nKey::EditorStripSearch => "Search {n} open files", + L10nKey::EditorStripAllFiles => "All open files", + L10nKey::EditorStripHidden => "Hidden · {n}", + L10nKey::EditorStripInBar => "In tab bar", + L10nKey::EditorStripNoMatch => "No open files match", + L10nKey::EditorStripCloseSaved => "Close saved", + L10nKey::EditorStripCloseOthers => "Close others", L10nKey::EditorBackToTerminal => "Back to Terminal (Esc)", L10nKey::EditorLnCol => "Ln {line}, Col {column}", L10nKey::EditorSelections => "({n} selections)", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 930f109f..44b64e7d 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1231,6 +1231,13 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::EditorUnsavedChanges => "「{name}」には保存されていない変更があります", L10nKey::EditorDiscard => "破棄", L10nKey::EditorNoFileOpen => "開かれているファイルはありません", + L10nKey::EditorStripSearch => "開いている {n} 個のファイルを検索", + L10nKey::EditorStripAllFiles => "開いているすべてのファイル", + L10nKey::EditorStripHidden => "非表示 · {n}", + L10nKey::EditorStripInBar => "タブバーに表示中", + L10nKey::EditorStripNoMatch => "一致する開いているファイルはありません", + L10nKey::EditorStripCloseSaved => "保存済みを閉じる", + L10nKey::EditorStripCloseOthers => "ほかを閉じる", L10nKey::EditorBackToTerminal => "ターミナルに戻る (Esc)", L10nKey::EditorLnCol => "行 {line}, 列 {column}", L10nKey::EditorSelections => "({n} 個の選択範囲)", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 1611f294..8ebfec1d 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -919,6 +919,13 @@ l10n_keys! { EditorUnsavedChanges, EditorDiscard, EditorNoFileOpen, + EditorStripSearch, + EditorStripAllFiles, + EditorStripHidden, + EditorStripInBar, + EditorStripNoMatch, + EditorStripCloseSaved, + EditorStripCloseOthers, EditorBackToTerminal, EditorLnCol, EditorSelections, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index d0875d20..e797ff9a 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1103,6 +1103,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::EditorUnsavedChanges => "“{name}”有未保存的更改", L10nKey::EditorDiscard => "放弃", L10nKey::EditorNoFileOpen => "没有打开的文件", + L10nKey::EditorStripSearch => "搜索 {n} 个打开的文件", + L10nKey::EditorStripAllFiles => "所有打开的文件", + L10nKey::EditorStripHidden => "已收起 · {n}", + L10nKey::EditorStripInBar => "在标签栏中", + L10nKey::EditorStripNoMatch => "没有匹配的打开文件", + L10nKey::EditorStripCloseSaved => "关闭已保存", + L10nKey::EditorStripCloseOthers => "关闭其他", L10nKey::EditorBackToTerminal => "返回终端 (Esc)", L10nKey::EditorLnCol => "行 {line},列 {column}", L10nKey::EditorSelections => "({n} 个选区)", diff --git a/src/ui/lsp/editor.rs b/src/ui/lsp/editor.rs index 9e6fd6af..1ee47ff3 100644 --- a/src/ui/lsp/editor.rs +++ b/src/ui/lsp/editor.rs @@ -382,7 +382,10 @@ impl Tty7App { /// The status bar's word on the front file's server: its problem counts /// once it runs, or why there are none. - pub(crate) fn render_lsp_status(&self, cx: &mut Context) -> Option { + /// The language server's state for the status bar, and whether it is the + /// missing-server note, which leads the bar rather than joining the + /// readouts at its end. + pub(crate) fn render_lsp_status(&self, cx: &mut Context) -> Option<(AnyElement, bool)> { if !Self::lsp_enabled(cx) { return None; } @@ -390,10 +393,31 @@ impl Tty7App { let status = LspStore::status(&f.path, f.language, cx)?; let muted = cx.theme().muted_foreground; let element = match status { - LspStatus::Missing(name) => div() - .flex_none() - .child(t_fmt(L10nKey::LspServerMissing, &[("name", name)])) - .into_any_element(), + LspStatus::Missing(name) => { + return Some(( + h_flex() + .min_w_0() + .gap(px(6.)) + .items_center() + .child( + div() + .flex_none() + .size(px(5.)) + .rounded_full() + .bg(cx.theme().warning), + ) + .child( + div() + .min_w_0() + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .child(t_fmt(L10nKey::LspServerMissing, &[("name", name)])), + ) + .into_any_element(), + true, + )); + } LspStatus::Starting(name) => div() .flex_none() .child(t_fmt(L10nKey::LspServerStarting, &[("name", &name)])) @@ -442,6 +466,6 @@ impl Tty7App { .into_any_element() } }; - Some(element) + Some((element, false)) } }