diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf81ddd6..425504e9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- **Documents dock beside the terminal** (#625). Opening a file, toggling the
+ code panel or opening a diff no longer covers the workspace: the document
+ takes a column to the right of the terminal — half the space between the
+ sidebar and the right panel by default — and the pane you were reading stays
+ visible and typeable underneath none of it. Reviewing a file while an agent
+ talks stopped being a toggle loop. Drag the divider for any width, double-click
+ it to cycle a third, a half and two thirds, or use **Document: Third / Half /
+ Two-Thirds Width** in the palette. Right-click the document's header for
+ **Fill window** — the old overlay, unchanged, and per tab, so a file read
+ over the whole window in one tab leaves the agent beside its own in the next.
+ The terminal keeps its floor through all of it, and a window too narrow to
+ seat both fills for that file only, without changing what any tab chose. New
+ in `config.json`: `document_ratio`, and `document_layout` for what a fresh
+ tab starts as.
+
- **A tab can be dropped into another tab, as a pane of it** (#621). Drag a tab
by its chip or by its sidebar row, out over the panes, and it lands where the
highlight says — the same reading as dragging a pane, minus the middle, which
diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs
index ef6309ad..e896130f 100644
--- a/crates/tty7-core/src/core/config.rs
+++ b/crates/tty7-core/src/core/config.rs
@@ -183,6 +183,19 @@ pub struct Config {
/// `diffEditor.renderSideBySide` makes.
#[serde(default, deserialize_with = "de_lenient")]
pub diff_view: DiffViewMode,
+ /// How the code / diff surface shares the window with the terminal. Global
+ /// for the same reason `diff_view` is: someone who wants documents beside
+ /// the terminal wants that in every workspace, not once per tab.
+ #[serde(default, deserialize_with = "de_lenient")]
+ pub document_layout: DocumentLayout,
+ /// The share of the terminal column — the flex area between the sidebar and
+ /// the right panel — the document column takes when docked. The named
+ /// widths land on a third, a half and two thirds; a drag leaves whatever it
+ /// leaves. Live layout narrows this further when the terminal's floor needs
+ /// the width, so the bounds here only have to keep a hand-written config
+ /// from hiding one side or the other outright.
+ #[serde(default = "default_document_ratio")]
+ pub document_ratio: f32,
/// The source control panel's history section starts collapsed: a graph
/// unfurling the first time someone opens the panel is a worse first
/// impression than one they asked for.
@@ -586,6 +599,8 @@ impl Default for Config {
right_panel_width: default_right_panel_width(),
right_panel_tab: RightPanelTab::Info,
diff_view: DiffViewMode::Split,
+ document_layout: DocumentLayout::default(),
+ document_ratio: default_document_ratio(),
scm_graph_expanded: false,
sidebar_grouping: SidebarGrouping::Repo,
sidebar_diff_preview: true,
@@ -759,6 +774,10 @@ impl Config {
self.right_panel_width = default_right_panel_width();
}
self.right_panel_width = self.right_panel_width.clamp(100.0, 2000.0);
+ if !self.document_ratio.is_finite() || self.document_ratio <= 0.0 {
+ self.document_ratio = default_document_ratio();
+ }
+ self.document_ratio = self.document_ratio.clamp(0.2, 0.8);
if let Some(command) = &self.link_file_command
&& command.trim().is_empty()
{
@@ -1073,6 +1092,36 @@ fn default_right_panel_width() -> f32 {
260.
}
+/// Where the code / diff surface is drawn.
+///
+/// It used to be one thing — a full-workspace overlay — so there was nothing to
+/// name. Docking it beside the terminal is the default now: opening a file to
+/// read it while an agent talks underneath was the reason the built-in editor
+/// exists, and an overlay covers the agent. `Fill` is that overlay, kept for
+/// anyone who wants the whole window for the file.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum DocumentLayout {
+ #[default]
+ Dock,
+ Fill,
+}
+
+fn default_document_ratio() -> f32 {
+ 0.5
+}
+
+/// The named shares of the terminal column a document column can be snapped to,
+/// in the order the segmented control and the divider's double-click cycle use.
+pub const DOCUMENT_RATIO_THIRD: f32 = 1. / 3.;
+pub const DOCUMENT_RATIO_HALF: f32 = 0.5;
+pub const DOCUMENT_RATIO_TWO_THIRDS: f32 = 2. / 3.;
+pub const DOCUMENT_RATIO_STOPS: [f32; 3] = [
+ DOCUMENT_RATIO_THIRD,
+ DOCUMENT_RATIO_HALF,
+ DOCUMENT_RATIO_TWO_THIRDS,
+];
+
/// The rem the chrome has always been laid out against — gpui's own default,
/// which is what `text_sm()` and `text_xs()` resolve 14px and 12px from. Left
/// alone, the interface looks exactly as it did before the size was settable.
diff --git a/docs/git/diffs.mdx b/docs/git/diffs.mdx
index 836d825a..8915db72 100644
--- a/docs/git/diffs.mdx
+++ b/docs/git/diffs.mdx
@@ -11,7 +11,10 @@ description: "The diff overlay: side-by-side or unified, from the sidebar or the
| Source Control | **Open Changes** on a file, or click the row |
| History | Click a file inside a commit's detail view |
-The overlay covers the window; Esc closes it.
+A diff docks beside the terminal, in the same column an open file uses, so the
+pane you were reading stays on screen. Esc closes it, and its header
+right-clicks to a menu that fills the window with it if you prefer that —
+[more about the column →](/window/side-panel#where-it-opens)
diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx
index 3ff0d300..8e32b60e 100644
--- a/docs/reference/configuration.mdx
+++ b/docs/reference/configuration.mdx
@@ -77,6 +77,8 @@ their id from the file name. [More about themes →](/customization/themes)
| `right_panel_width` | number | `260` | Pixels (100–2000). |
| `right_panel_tab` | enum | `"info"` | `info`, `changes`, `files`. |
| `diff_view` | enum | `"split"` | Or `unified`. Global, not per file. |
+| `document_layout` | enum | `"dock"` | Where an open file or diff is drawn: `dock` beside the terminal, or `fill` over the workspace. What a fresh tab starts as — each tab keeps its own from there. |
+| `document_ratio` | number | `0.5` | The docked column’s share of the terminal column (0.2–0.8). Named widths are `0.333`, `0.5`, `0.667`. |
| `scm_graph_expanded` | bool | `false` | Whether the history section starts open. |
| `show_tray_icon` | bool | `true` | The tray / menu bar status item. |
diff --git a/docs/window/side-panel.mdx b/docs/window/side-panel.mdx
index 71277d56..70b09eeb 100644
--- a/docs/window/side-panel.mdx
+++ b/docs/window/side-panel.mdx
@@ -75,3 +75,27 @@ have opened `vim` for.
Files are watched on disk: a change underneath you is picked up, and closing
with unsaved edits asks before discarding them. Files over 4 MB and anything
that looks binary are refused with a note rather than opened badly.
+
+### Where it opens
+
+The editor docks beside the terminal, taking half the space between the sidebar
+and the right panel. The terminal keeps running, stays visible, and stays
+typeable — click it, read what your agent said, click back. Diffs open in the
+same column.
+
+Drag the divider for any width; double-click it to cycle a third, a half and
+two thirds. **Document: Third / Half / Two-Thirds Width** in the command palette
+do the same.
+
+Right-click the document's header for **Fill window**, which is the old
+full-workspace overlay, unchanged. **Document: Fill Window** and **Document:
+Dock Beside Terminal** in the palette are the same switch.
+
+Fill or dock is **per tab**: read a long file over the whole window in one tab
+while an agent keeps half of another, and neither moves the other. A fresh tab
+starts from `document_layout` in `config.json`. The width is shared, and
+persists as `document_ratio`.
+
+A window too narrow to give both the terminal and the document a readable width
+fills for that file only — widen it and the column comes back, without your
+setting having changed.
diff --git a/src/core/actions.rs b/src/core/actions.rs
index d4689f7b..09604411 100644
--- a/src/core/actions.rs
+++ b/src/core/actions.rs
@@ -102,6 +102,10 @@ actions!(
ToggleSftp,
ShowSshForwards,
ToggleCodePanel,
+ ToggleDocumentFill,
+ DocumentWidthThird,
+ DocumentWidthHalf,
+ DocumentWidthTwoThirds,
EditorSave,
OpenSshProfiles,
RestartSshSession,
diff --git a/src/ui/app.rs b/src/ui/app.rs
index eb2c7fc6..a3c6aa7b 100644
--- a/src/ui/app.rs
+++ b/src/ui/app.rs
@@ -175,10 +175,12 @@ pub(crate) const HOME_CURSOR_BLINK_MS: u64 = 600;
/// dropping it would have let a panel grow past where it could before under
/// cover of a change that is only meant to take width away from panels.
///
-/// `other_floor` is zero when the other panel is closed, which is why this
-/// takes floors rather than reading them: only the caller knows what is up.
-pub(crate) fn side_panel_max(viewport: f32, own_floor: f32, other_floor: f32) -> f32 {
- (viewport - TERMINAL_MIN_W - other_floor)
+/// `others_floor` is the sum of the floors under every *other* column that is
+/// open — the panel opposite, and the document column when a file or a diff is
+/// docked. It is zero for each of them that is closed, which is why this takes
+/// floors rather than reading them: only the caller knows what is up.
+pub(crate) fn side_panel_max(viewport: f32, own_floor: f32, others_floor: f32) -> f32 {
+ (viewport - TERMINAL_MIN_W - others_floor)
.min(viewport * SIDE_PANEL_MAX_RATIO)
.max(own_floor)
}
@@ -186,6 +188,35 @@ pub(crate) fn side_panel_max(viewport: f32, own_floor: f32, other_floor: f32) ->
/// The half of the window neither panel may grow past on its own.
const SIDE_PANEL_MAX_RATIO: f32 = 0.5;
+/// The narrowest a docked document column may be squeezed to: the header, a
+/// readable run of about thirty columns, and the status bar under them. Below
+/// this a file is a ribbon of hyphenated fragments and the column is worth
+/// less than the terminal width it costs.
+pub(crate) const DOCUMENT_MIN_W: f32 = 280.;
+
+/// How wide the docked document column is, given the width the terminal and the
+/// document share — the window less the sidebar and the right panel — and the
+/// share of it the user asked for.
+///
+/// `None` is the narrow-window answer: there is no way to give both the
+/// terminal and a document a width worth reading, so the caller falls back to
+/// filling the workspace for this frame. That fallback is *derived*, never
+/// stored — widening the window docks again on the next frame, and the user's
+/// saved `document_layout` is untouched throughout.
+///
+/// The named two-thirds deliberately runs past the half-window cap the side
+/// panels obey. That cap is there so neither *panel* can dominate a wide
+/// display; the document is the thing the user is reading, and an increment
+/// that silently became a half on every window wider than about 720 points of
+/// body would be a lie. The terminal's floor still binds.
+pub(crate) fn document_column_px(body: f32, ratio: f32) -> Option {
+ if !body.is_finite() || body < TERMINAL_MIN_W + DOCUMENT_MIN_W {
+ return None;
+ }
+ let ratio = if ratio.is_finite() { ratio } else { 0.5 };
+ Some((body * ratio).clamp(DOCUMENT_MIN_W, body - TERMINAL_MIN_W))
+}
+
pub(crate) const TITLE_BAR_HEIGHT: f32 = 40.;
pub(crate) const TILE_SIZE: f32 = 32.;
@@ -353,6 +384,16 @@ pub struct Tab {
pub(crate) code: Option>,
pub(crate) sidebar_group: std::cell::RefCell>,
pub(crate) overlay_top: OverlayTop,
+ /// Whether this tab's document fills the workspace or docks beside the
+ /// terminal, once the tab has been told. `None` follows `document_layout`
+ /// in the config, which is the default a fresh tab starts from and the
+ /// last explicit choice anyone made.
+ ///
+ /// Per tab rather than per window because what you are doing differs per
+ /// tab: reading a long file in one while an agent works in another wants
+ /// the whole window here and half of it there, and a global switch made
+ /// each of those flip the other.
+ pub(crate) document_layout: Option,
pub(crate) tree_id: std::cell::Cell,
/// Monotonic stamp of when this tab was last activated, used to order the
/// switcher's tab column most-recently-used first. Zero means never.
@@ -367,7 +408,7 @@ pub(crate) enum OverlayTop {
}
impl Tab {
- fn new(pane: Pane) -> Self {
+ pub(crate) fn new(pane: Pane) -> Self {
Self {
pane,
name: None,
@@ -376,6 +417,7 @@ impl Tab {
diff_overlay: None,
code: None,
overlay_top: OverlayTop::default(),
+ document_layout: None,
sidebar_group: std::cell::RefCell::new(None),
tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()),
last_used: std::cell::Cell::new(0),
@@ -391,6 +433,7 @@ impl Tab {
diff_overlay: None,
code: None,
overlay_top: OverlayTop::default(),
+ document_layout: None,
sidebar_group: std::cell::RefCell::new(
tree.sidebar_group.clone().map(std::path::PathBuf::from),
),
@@ -636,6 +679,12 @@ pub struct Tty7App {
pub(crate) settings_hit_anchored: Cell,
pub(crate) right_panel_width: Rc>,
pub(crate) right_panel_dragging: Rc>,
+ /// The docked document column's share of the terminal column, live. Held
+ /// beside the config value rather than in it for the same reason the two
+ /// panel widths are: a drag writes this cell on every mouse move and the
+ /// config once, on mouse up.
+ pub(crate) document_ratio: Rc>,
+ pub(crate) document_dragging: Rc>,
pub(crate) right_panel_visible: bool,
pub(crate) right_panel_tab: RightPanelTab,
pub(crate) sidebar_collapsed: bool,
@@ -1057,6 +1106,7 @@ impl Tty7App {
});
let sidebar_width = cx.global::().sidebar_width;
let right_panel_width = cx.global::().right_panel_width;
+ let document_ratio = cx.global::().document_ratio;
let right_panel_visible = cx.global::().right_panel_visible;
let right_panel_tab = cx.global::().right_panel_tab;
let scm_graph_expanded = cx.global::().scm_graph_expanded;
@@ -1227,6 +1277,8 @@ impl Tty7App {
settings_hit_anchored: Cell::new(false),
right_panel_width: Rc::new(Cell::new(right_panel_width)),
right_panel_dragging: Rc::new(Cell::new(false)),
+ document_ratio: Rc::new(Cell::new(document_ratio)),
+ document_dragging: Rc::new(Cell::new(false)),
right_panel_visible,
right_panel_tab,
sidebar_collapsed,
@@ -1550,6 +1602,7 @@ impl Tty7App {
diff_overlay: None,
code: None,
overlay_top: OverlayTop::default(),
+ document_layout: None,
sidebar_group: std::cell::RefCell::new(st.sidebar_group),
tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()),
last_used: std::cell::Cell::new(0),
@@ -4878,6 +4931,16 @@ impl Tty7App {
ToggleSftp => self.toggle_sftp(window, cx),
ShowSshForwards => self.show_ssh_forwards(window, cx),
ToggleCodePanel => self.toggle_code_panel(window, cx),
+ ToggleDocumentFill => self.toggle_document_fill(cx),
+ DocumentWidthThird => {
+ self.set_document_ratio(crate::core::config::DOCUMENT_RATIO_THIRD, cx)
+ }
+ DocumentWidthHalf => {
+ self.set_document_ratio(crate::core::config::DOCUMENT_RATIO_HALF, cx)
+ }
+ DocumentWidthTwoThirds => {
+ self.set_document_ratio(crate::core::config::DOCUMENT_RATIO_TWO_THIRDS, cx)
+ }
RestartSshSession => self.restart_ssh_session(window, cx),
SetTheme(i) => {
if let Some(id) = crate::ui::presets::all(cx).get(i).map(|t| t.id.clone()) {
@@ -5735,6 +5798,8 @@ impl Tty7App {
self.sidebar_width.set(cx.global::().sidebar_width);
self.right_panel_width
.set(cx.global::().right_panel_width);
+ self.document_ratio
+ .set(cx.global::().document_ratio);
if font_size != self.font_size {
self.font_size = font_size;
let px_size = px(font_size);
@@ -6972,27 +7037,72 @@ impl Render for Tty7App {
this.child(el)
});
- let diff_overlay = self.render_diff_overlay(window, cx);
-
- let code_overlay = self.render_code_overlay(window, cx);
-
- let overlays: Vec = {
- 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()
+ // One decision for the whole document surface. Docked, exactly one of
+ // the two surfaces is drawn — a column has one child, and two `flex_1`
+ // siblings would split it and fight — so `overlay_top` stops ordering a
+ // pair and starts choosing between them. Filling, nothing changes: both
+ // are rendered, ordered by `overlay_top`, and the front one wins on
+ // paint order as it always has.
+ let document_dock_px = self.document_dock_px(window, cx);
+ // Where the docked header sits. Everywhere but macOS the title bar
+ // spans the workspace and leaves the strip above the column empty, so
+ // the header goes up into it; on macOS the column already reaches the
+ // top of the window and its own first row lands there.
+ let document_chrome = if cfg!(target_os = "macos") {
+ crate::ui::document_column::DocumentChrome::Dock
+ } else {
+ crate::ui::document_column::DocumentChrome::DockHoisted
};
+ let document_header = document_dock_px
+ .is_some()
+ .then(|| self.render_document_header(document_chrome, window, cx))
+ .flatten();
+ let (overlays, document_column) = match document_dock_px {
+ Some(w) => (
+ Vec::new(),
+ self.render_document_column(w, document_chrome, window, cx),
+ ),
+ None => {
+ let diff_overlay = self.render_diff_overlay(
+ crate::ui::document_column::DocumentChrome::Fill,
+ window,
+ cx,
+ );
+ let code_overlay = self.render_code_overlay(
+ crate::ui::document_column::DocumentChrome::Fill,
+ window,
+ cx,
+ );
+ 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::>(),
+ None,
+ )
+ }
+ };
+ let document_px = document_column
+ .as_ref()
+ .map_or(0., |_| document_dock_px.unwrap_or_default());
let right_panel = self.render_right_panel(window, cx);
- let panel_below_title_bar = right_panel.is_some() && !cfg!(target_os = "macos");
+ // A docked document takes the same fork the right panel does: on
+ // Windows and Linux the window controls live at the right end of the
+ // title bar, so the bar has to span the workspace rather than sit
+ // inside the terminal column with a column drawn to the right of it.
+ let panel_below_title_bar =
+ (right_panel.is_some() || document_column.is_some()) && !cfg!(target_os = "macos");
let (column_title_bar, spanning_title_bar) = if panel_below_title_bar {
(None, Some(title_bar))
} else {
@@ -7003,7 +7113,11 @@ impl Render for Tty7App {
} else {
(overlays, Vec::new())
};
- let panel_px = self.right_panel_px(window, cx);
+ let panel_px = if right_panel.is_some() {
+ self.right_panel_px(window, cx)
+ } else {
+ 0.
+ };
let terminal_column = div()
.flex_1()
.min_w_0()
@@ -7020,6 +7134,7 @@ impl Render for Tty7App {
.flex()
.flex_row()
.child(terminal_column)
+ .when_some(document_column, |this, column| this.child(column))
.when_some(right_panel, |this, panel| this.child(panel));
let main_layout = div()
.flex_1()
@@ -7039,18 +7154,59 @@ impl Render for Tty7App {
div()
.relative()
.flex_none()
- .child(
- div()
- .absolute()
- .top_0()
- .bottom_0()
- .right_0()
- .w(px(self.right_panel_px(window, cx)))
- .bg(crate::ui::theme::workspace_surface_color(cx))
- .border_l_1()
- .border_color(cx.theme().sidebar_border),
- )
- .child(bar),
+ // One patch per column below, rather than one for
+ // both: each carries the left border its own column
+ // carries, so the rule between the document and the
+ // detail panel runs the full height of the window
+ // instead of stopping at the title bar.
+ .when(panel_px > 0., |this| {
+ this.child(
+ div()
+ .absolute()
+ .top_0()
+ .bottom_0()
+ .right_0()
+ .w(px(panel_px))
+ .bg(crate::ui::theme::workspace_surface_color(cx))
+ .border_l_1()
+ .border_color(cx.theme().sidebar_border),
+ )
+ })
+ .when(document_px > 0., |this| {
+ this.child(
+ div()
+ .absolute()
+ .top_0()
+ .bottom_0()
+ .right(px(panel_px))
+ .w(px(document_px))
+ .bg(crate::ui::theme::workspace_surface_color(cx))
+ .border_l_1()
+ .border_color(cx.theme().sidebar_border),
+ )
+ })
+ .child(bar)
+ // The document's header, in the strip the spanning
+ // title bar leaves empty above its column. Drawn
+ // after the bar so it sits over the tab strip's
+ // slack — and stopping short of the trailing
+ // chrome, which is only in the way when the detail
+ // panel is closed and this column is the one at the
+ // window's right edge.
+ .when_some(document_header, |this, header| {
+ this.child(
+ div()
+ .absolute()
+ .top_0()
+ .h(px(TITLE_BAR_HEIGHT))
+ .right(px(panel_px))
+ .w(px(document_px))
+ .when(panel_px <= 0., |d| {
+ d.pr(px(crate::ui::tab_strip::trailing_chrome_w()))
+ })
+ .child(header),
+ )
+ }),
)
.child(panel_row)
.children(hoisted_overlays.into_iter().map(|overlay| {
@@ -7266,6 +7422,20 @@ impl Render for Tty7App {
.on_action(cx.listener(|this, _: &ToggleDiffViewMode, _window, cx| {
this.toggle_diff_view_mode(cx)
}))
+ .on_action(cx.listener(|this, _: &ToggleDocumentFill, _window, cx| {
+ this.toggle_document_fill(cx)
+ }))
+ .on_action(cx.listener(|this, _: &DocumentWidthThird, _window, cx| {
+ this.set_document_ratio(crate::core::config::DOCUMENT_RATIO_THIRD, cx)
+ }))
+ .on_action(cx.listener(|this, _: &DocumentWidthHalf, _window, cx| {
+ this.set_document_ratio(crate::core::config::DOCUMENT_RATIO_HALF, cx)
+ }))
+ .on_action(
+ cx.listener(|this, _: &DocumentWidthTwoThirds, _window, cx| {
+ this.set_document_ratio(crate::core::config::DOCUMENT_RATIO_TWO_THIRDS, cx)
+ }),
+ )
.on_action(cx.listener(|this, _: &ScmCommit, window, cx| {
this.run_scm_action(ScmIntent::Commit, window, cx)
}))
@@ -7642,6 +7812,7 @@ fn tabs_from_session(
diff_overlay: None,
code: None,
overlay_top: OverlayTop::default(),
+ document_layout: None,
sidebar_group: std::cell::RefCell::new(st.sidebar_group.clone()),
tree_id: std::cell::Cell::new(
st.tree_id
@@ -8439,10 +8610,10 @@ mod window_drag_tests {
#[cfg(test)]
mod tests {
use super::{
- CloseReason, TERMINAL_MIN_W, TabAgentSession, clear_window_override_values, close_prompt,
- join_shell_args, leaf_shares_the_window_daemon, mru_order, pane_free_for,
- parse_ssh_connect_input, parse_ssh_option_words, side_panel_max, split_shell_args,
- wd_path_saveable,
+ CloseReason, DOCUMENT_MIN_W, TERMINAL_MIN_W, TabAgentSession, clear_window_override_values,
+ close_prompt, document_column_px, join_shell_args, leaf_shares_the_window_daemon,
+ mru_order, pane_free_for, parse_ssh_connect_input, parse_ssh_option_words, side_panel_max,
+ split_shell_args, wd_path_saveable,
};
const SIDEBAR_MIN: f32 = crate::ui::tab_sidebar::MIN_SIDEBAR_WIDTH;
@@ -8468,6 +8639,47 @@ mod tests {
assert_eq!(side_panel_max(mid, SIDEBAR_MIN, 0.), mid / 2.);
}
+ /// A docked document is a third column in the same budget, so it has to be
+ /// reserved by the two panels the way they already reserve each other —
+ /// otherwise a panel dragged to its old limit takes the width out of the
+ /// document, which then has nowhere to take it from but the terminal.
+ #[test]
+ fn a_docked_document_is_reserved_by_the_panels_too() {
+ let wide = 1440.;
+ let max = side_panel_max(wide, PANEL_MIN, SIDEBAR_MIN + DOCUMENT_MIN_W);
+ assert_eq!(
+ wide - SIDEBAR_MIN - DOCUMENT_MIN_W - max,
+ TERMINAL_MIN_W,
+ "a panel at its cap, with both other columns at their floors, leaves the terminal exactly its floor"
+ );
+ assert!(
+ max < side_panel_max(wide, PANEL_MIN, SIDEBAR_MIN),
+ "the reservation only ever takes width away"
+ );
+ }
+
+ /// The floors are not what the panels are actually drawn at. Both are
+ /// draggable and both persist, so the budget has to be fed the live widths
+ /// or a widened sidebar is width the terminal silently loses.
+ #[test]
+ fn widened_panels_still_leave_the_terminal_its_floor() {
+ let viewport = 1440.;
+ // Both dragged well past their floors, and the document asked for the
+ // widest named share there is.
+ let body = viewport - 400. - 320.;
+ let document = document_column_px(body, 2. / 3.).expect("720 points seats both");
+ assert!(
+ body - document >= TERMINAL_MIN_W,
+ "terminal got {}",
+ body - document
+ );
+
+ // Squeezed further, the document is the one that gives up first — and
+ // then stops existing rather than dropping under its own floor.
+ let squeezed = viewport - 600. - 400.;
+ assert_eq!(document_column_px(squeezed, 0.5), None);
+ }
+
/// The reservation must only ever take width away from a panel. On a wide
/// window it works out *larger* than the half-window cap that was already
/// there, and letting it win would widen the ceiling instead.
diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs
index 150b49b4..428714d6 100644
--- a/src/ui/code_editor.rs
+++ b/src/ui/code_editor.rs
@@ -9,11 +9,13 @@ use gpui::{
};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::input::{Input, InputEvent, InputState, Position, TabSize};
+use gpui_component::menu::ContextMenuExt as _;
use gpui_component::{
ActiveTheme as _, Icon, IconName, Sizable as _, WindowExt as _, h_flex, v_flex,
};
use crate::ui::app::Tty7App;
+use crate::ui::document_column::DocumentChrome;
use crate::ui::host_ops::{HostId, HostOps, MTime, SharedHost, WatchSub};
use crate::ui::i18n::{L10nKey, t, t_fmt};
@@ -1081,6 +1083,7 @@ impl Tty7App {
impl Tty7App {
pub(crate) fn render_code_overlay(
&mut self,
+ chrome: DocumentChrome,
window: &mut Window,
cx: &mut Context,
) -> Option {
@@ -1132,17 +1135,25 @@ impl Tty7App {
.filter(|f| f.conflict)
.map(|_| self.render_editor_conflict_banner(cx));
+ let header = chrome
+ .renders_own_header()
+ .then(|| self.render_editor_header(chrome, window, cx));
let editor_col = v_flex()
.flex_1()
.min_w_0()
.h_full()
- .child(self.render_editor_header(window, cx))
+ .children(header)
.when_some(conflict_banner, |this, b| this.child(b))
.child(div().flex_1().min_h_0().child(body));
- Some(
- v_flex()
- .id("code-panel")
+ // The panel's own paint is the same either way; only the box is not.
+ // Filling the workspace means stopping the window's translucency and
+ // repainting the theme image the root's copy now sits under; docking
+ // means sitting in the same plane as the right panel, which the column
+ // wrapper has already painted.
+ let shell = v_flex().id("code-panel");
+ let shell = match chrome {
+ DocumentChrome::Fill => shell
.absolute()
.inset_0()
.occlude()
@@ -1154,33 +1165,58 @@ impl Tty7App {
// theme background image is repainted on top of it, since the
// root's copy now sits below this fill.
.bg(crate::ui::theme::overlay_background(cx))
+ .children(crate::ui::app::overlay_surface_layers(cx)),
+ DocumentChrome::Dock | DocumentChrome::DockHoisted => shell.size_full().min_w_0(),
+ };
+ Some(
+ shell
.on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| {
if ev.keystroke.key == "escape" {
this.toggle_code_panel(window, cx);
}
}))
- .children(crate::ui::app::overlay_surface_layers(cx))
.child(h_flex().flex_1().min_h_0().w_full().child(editor_col))
.child(self.render_code_status_bar(window, cx))
.into_any_element(),
)
}
- fn render_editor_header(
+ /// The editor header alone, for the strip above a docked column.
+ pub(crate) fn render_editor_header_only(
&self,
+ chrome: DocumentChrome,
window: &mut Window,
cx: &mut Context,
- ) -> gpui::Stateful {
+ ) -> gpui::AnyElement {
+ self.render_editor_header(chrome, window, cx)
+ .into_any_element()
+ }
+
+ fn render_editor_header(
+ &self,
+ chrome: DocumentChrome,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> impl IntoElement + use<> {
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);
- let lead = if self.left_panel_open(cx) {
+ // `TITLE_BAR_LEAD` is the room macOS's traffic lights need. Only a
+ // header that starts at the left edge of the window has them to clear,
+ // and a docked column never does.
+ let lead = if self.left_panel_open(cx) || chrome.is_dock() {
crate::ui::app::CONTENT_INSET
} else {
crate::ui::app::TITLE_BAR_LEAD
};
- crate::ui::app::title_bar_drag(h_flex().id("editor-header"), "editor-header", window, cx)
- .flex_none()
+ let row = h_flex().id("editor-header");
+ let row = if chrome.header_is_title_strip() {
+ crate::ui::app::title_bar_drag(row, "editor-header", window, cx)
+ } else {
+ row
+ };
+ let menu_app = cx.entity().downgrade();
+ row.flex_none()
.h(px(crate::ui::app::TITLE_BAR_HEIGHT))
.items_center()
.gap_1p5()
@@ -1226,6 +1262,9 @@ impl Tty7App {
})),
),
)
+ .context_menu(move |menu, _window, cx| {
+ Tty7App::document_header_menu(menu, &menu_app, cx)
+ })
}
fn render_code_status_bar(&self, _window: &Window, cx: &mut Context) -> gpui::Div {
diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs
index a3706413..2e869e31 100644
--- a/src/ui/diff_overlay.rs
+++ b/src/ui/diff_overlay.rs
@@ -7,6 +7,7 @@ use gpui::{
Window, div, prelude::*, px,
};
use gpui_component::button::Button;
+use gpui_component::menu::ContextMenuExt as _;
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex};
use crate::core::config::{Config, DiffViewMode};
@@ -22,6 +23,7 @@ use crate::terminal::git_diff::{
const MAX_PREVIEW_BYTES: u64 = 4 * 1024 * 1024;
use crate::ui::app::Tty7App;
use crate::ui::diff_rows::{Side, SplitCell, SplitRow, UnifiedRow, split_hunk, unified_rows};
+use crate::ui::document_column::DocumentChrome;
use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural};
use crate::ui::right_panel::info_chip;
use crate::ui::rounding;
@@ -388,6 +390,7 @@ impl Tty7App {
pub(crate) fn render_diff_overlay(
&mut self,
+ chrome: DocumentChrome,
window: &mut Window,
cx: &mut Context,
) -> Option {
@@ -426,10 +429,14 @@ impl Tty7App {
),
};
- let header = self.diff_header(overlay, window, cx);
+ let header = chrome
+ .renders_own_header()
+ .then(|| self.diff_header(overlay, chrome, window, cx));
+ let focus_handle = overlay.focus_handle.clone();
- Some(
- v_flex()
+ let shell = v_flex();
+ let shell = match chrome {
+ DocumentChrome::Fill => shell
.absolute()
.inset_0()
.occlude()
@@ -439,27 +446,49 @@ impl Tty7App {
cx.try_global::(),
cx.theme().background,
))
+ // The opaque fill above covers the theme background image the
+ // workspace root paints, so the overlay carries its own copy,
+ // dimmed back to the strength it had when this overlay was
+ // itself translucent.
+ .children(crate::ui::app::overlay_surface_layers(cx)),
+ // Docked, the column wrapper has already painted the surface this
+ // sits on — the same one the right panel uses — and nothing behind
+ // it needs stopping.
+ DocumentChrome::Dock | DocumentChrome::DockHoisted => shell.size_full().min_w_0(),
+ };
+ Some(
+ shell
.text_color(cx.theme().foreground)
- .track_focus(&overlay.focus_handle)
+ .track_focus(&focus_handle)
.on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| {
if ev.keystroke.key.as_str() == "escape" {
this.close_diff_overlay(window, cx);
}
}))
- // The opaque fill above covers the theme background image the
- // workspace root paints, so the overlay carries its own copy,
- // dimmed back to the strength it had when this overlay was
- // itself translucent.
- .children(crate::ui::app::overlay_surface_layers(cx))
- .child(header)
+ .children(header)
.child(content)
.into_any_element(),
)
}
+ /// The diff header alone, for the strip above a docked column.
+ pub(crate) fn render_diff_header_only(
+ &mut self,
+ chrome: DocumentChrome,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Option {
+ let overlay = self.tabs.get(self.active)?.diff_overlay.as_ref()?;
+ Some(
+ self.diff_header(overlay, chrome, window, cx)
+ .into_any_element(),
+ )
+ }
+
fn diff_header(
&self,
overlay: &DiffOverlayState,
+ chrome: DocumentChrome,
window: &mut Window,
cx: &mut Context,
) -> impl IntoElement + use<> {
@@ -471,19 +500,25 @@ impl Tty7App {
}
_ => (String::new(), 0, 0, 0, 0),
};
- let lead = if self.left_panel_open(cx) {
+ // See `render_editor_header`: the traffic-light inset belongs to a
+ // header that starts at the window's left edge, which a column's does
+ // not.
+ let lead = if self.left_panel_open(cx) || chrome.is_dock() {
crate::ui::app::CONTENT_INSET
} else {
crate::ui::app::TITLE_BAR_LEAD
};
let mono = SharedString::from(self.font_family.clone());
let subject = source_subject(&overlay.source, branch);
- let row = crate::ui::app::title_bar_drag(
- h_flex().id("diff-overlay-header"),
- "diff-overlay-header",
- window,
- cx,
- );
+ let subject_takes_the_slack =
+ chrome.is_dock() && !subject.is_rev && subject.label.is_none();
+ let menu_app = cx.entity().downgrade();
+ let row = h_flex().id("diff-overlay-header");
+ let row = if chrome.header_is_title_strip() {
+ crate::ui::app::title_bar_drag(row, "diff-overlay-header", window, cx)
+ } else {
+ row
+ };
row.flex_shrink_0()
.h(px(crate::ui::app::TITLE_BAR_HEIGHT))
.pl(px(lead))
@@ -509,7 +544,16 @@ impl Tty7App {
.child(subject.text)
.into_any_element()
} else {
+ // Docked, this is the name that gives: the header has a
+ // column's width rather than a window's, and a branch name that
+ // refused to yield any of it pushed the view toggle and the
+ // close tile off the end. It takes the slack the spacer below
+ // would otherwise have — the same trade the label branch makes,
+ // and for the same reason two `flex_1` siblings would split the
+ // line and truncate the name with empty space beside it.
div()
+ .when(subject_takes_the_slack, |d| d.flex_1().min_w_0().truncate())
+ .when(!subject_takes_the_slack, |d| d.flex_shrink_0())
.text_sm()
.font_weight(FontWeight::MEDIUM)
.child(subject.text)
@@ -588,12 +632,17 @@ impl Tty7App {
if untracked > 0 {
summary.push_str(&t_plural(L10nKey::DiffUntrackedCount, untracked, &[]));
}
- bar.child(
- div()
- .text_xs()
- .text_color(cx.theme().muted_foreground)
- .child(summary),
- )
+ // The file count is the first thing a column drops: the
+ // same number is one line down, at the top of the list.
+ // The totals stay — they have no second home.
+ bar.when(!chrome.is_dock(), |bar| {
+ bar.child(
+ div()
+ .text_xs()
+ .text_color(cx.theme().muted_foreground)
+ .child(summary),
+ )
+ })
.when(added > 0, |bar| {
bar.child(
div()
@@ -623,7 +672,9 @@ impl Tty7App {
)
},
)
- .when(subject.label.is_none(), |bar| bar.child(div().flex_1()))
+ .when(subject.label.is_none() && !subject_takes_the_slack, |bar| {
+ bar.child(div().flex_1())
+ })
.child(div().occlude().flex_shrink_0().child({
let sf = cx.global::().window;
let selected = usize::from(view_mode(cx) == DiffViewMode::Unified);
@@ -659,6 +710,9 @@ impl Tty7App {
})),
),
)
+ .context_menu(move |menu, _window, cx| {
+ Tty7App::document_header_menu(menu, &menu_app, cx)
+ })
}
/// Dispatch the byte read behind an untracked file's preview, at most
diff --git a/src/ui/document_column.rs b/src/ui/document_column.rs
new file mode 100644
index 00000000..68bbd807
--- /dev/null
+++ b/src/ui/document_column.rs
@@ -0,0 +1,768 @@
+//! The slot the code panel and the diff overlay are drawn in.
+//!
+//! Both surfaces used to be full-workspace overlays — `absolute`, `inset_0`,
+//! `occlude` — so opening a file to read it hid the agent that told you to read
+//! it, and reviewing one turned into a toggle loop. They now dock as a column
+//! beside the terminal instead, on the same pattern the right panel has always
+//! used: a flex sibling with a drag handle and a persisted share of the width.
+//!
+//! A sibling column, rather than a narrower overlay, is the whole point: the
+//! terminal element's laid-out bounds are what drive `set_grid_size`, so a
+//! column takes width *away* from the grid and the PTY reflows to what is left.
+//! An overlay painted over half the workspace would leave the grid full width
+//! with half of it under a card.
+//!
+//! The overlay is not gone — [`DocumentLayout::Fill`] is exactly the old paint,
+//! one command away, and a window too narrow to seat both a terminal and a
+//! document falls back to it for that frame without touching what the user
+//! saved.
+
+use gpui::{AnyElement, Context, Window, div, prelude::*, px};
+use gpui_component::menu::{PopupMenu, PopupMenuItem};
+use gpui_component::{ActiveTheme as _, InteractiveElementExt as _, v_flex};
+use std::cell::Cell as StdCell;
+use std::rc::Rc;
+
+use crate::core::config::{Config, DOCUMENT_RATIO_STOPS, DocumentLayout};
+use crate::ui::app::{DOCUMENT_MIN_W, OverlayTop, TERMINAL_MIN_W, Tty7App, document_column_px};
+use crate::ui::i18n::{L10nKey, t};
+use crate::ui::right_panel::RESIZE_HANDLE_WIDTH;
+
+/// Which wrapper a document surface is being asked to paint itself in.
+///
+/// The *content* of the code panel and of the diff overlay is the same in all
+/// three; only the box around it changes, and with it where the header sits and
+/// what its gestures mean.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum DocumentChrome {
+ /// The historical full-workspace overlay.
+ Fill,
+ /// A column beside the terminal, carrying its own header as its first row.
+ /// macOS only: there the title bar lives inside the terminal column, so the
+ /// document column runs to the top of the window and its header lands in
+ /// the title strip by itself.
+ Dock,
+ /// A column beside the terminal whose header has been lifted into the
+ /// spanning title bar above it.
+ ///
+ /// Windows and Linux put the window controls at the right end of a title
+ /// bar that spans the workspace, which leaves the strip directly above the
+ /// document column empty — a title bar's height of nothing, with the file
+ /// name one row below it. The header goes there instead, and the column
+ /// renders its body alone.
+ DockHoisted,
+}
+
+impl DocumentChrome {
+ /// Whether this is one of the two column layouts.
+ pub(crate) fn is_dock(self) -> bool {
+ !matches!(self, DocumentChrome::Fill)
+ }
+
+ /// Whether the surface draws its own header, or has had it lifted away.
+ pub(crate) fn renders_own_header(self) -> bool {
+ !matches!(self, DocumentChrome::DockHoisted)
+ }
+
+ /// Whether the header is the strip along the top of the window, and so has
+ /// to behave like a title bar — dragging the window, zooming on a
+ /// double-click.
+ ///
+ /// True for the overlay, whose header stands in for the title bar. True for
+ /// a hoisted header, which is drawn *into* the title bar. True for a docked
+ /// column only on macOS, where the column reaches the top of the window
+ /// anyway. False for a plain docked header, which sits inside the workspace
+ /// and would be a second, fake title bar if it moved the window.
+ pub(crate) fn header_is_title_strip(self) -> bool {
+ match self {
+ DocumentChrome::Fill | DocumentChrome::DockHoisted => true,
+ DocumentChrome::Dock => cfg!(target_os = "macos"),
+ }
+ }
+}
+
+impl Tty7App {
+ /// The layout the active tab's document was asked for, which is not always
+ /// the one it gets — see [`Tty7App::document_dock_px`].
+ ///
+ /// A tab that has never been told follows the config, which holds the last
+ /// explicit choice anyone made and is therefore what a fresh tab starts
+ /// from. Telling one tab never moves another.
+ pub(crate) fn document_layout(&self, cx: &gpui::App) -> DocumentLayout {
+ self.tabs
+ .get(self.active)
+ .and_then(|t| t.document_layout)
+ .unwrap_or_else(|| cx.global::().document_layout)
+ }
+
+ /// Which of the two surfaces the docked column shows.
+ ///
+ /// `overlay_top` orders a *pair* of overlays in fill mode, where both are
+ /// painted and the front one wins on paint order. A column has one child,
+ /// so the ordering has to become a choice: the surface on top, unless it is
+ /// closed, in which case there is no front and the survivor is it.
+ pub(crate) fn document_front(&self) -> Option {
+ let tab = self.tabs.get(self.active)?;
+ let code = tab.code.as_ref().is_some_and(|c| c.visible);
+ let diff = tab.diff_overlay.is_some();
+ match (tab.overlay_top, code, diff) {
+ (_, false, false) => None,
+ (OverlayTop::Code, true, _) | (OverlayTop::Diff, true, false) => Some(OverlayTop::Code),
+ (OverlayTop::Diff, _, true) | (OverlayTop::Code, false, true) => Some(OverlayTop::Diff),
+ }
+ }
+
+ /// What the document column has reserved, from a side panel's point of
+ /// view.
+ ///
+ /// Read from the user's intent and from whether a surface is open — never
+ /// from the *effective* layout, which is derived from the widths this feeds
+ /// and would close the loop on itself.
+ pub(crate) fn document_floor(&self, cx: &gpui::App) -> f32 {
+ if self.document_layout(cx) == DocumentLayout::Dock && self.document_front().is_some() {
+ DOCUMENT_MIN_W
+ } else {
+ 0.
+ }
+ }
+
+ /// The width the terminal and a docked document share: the window less
+ /// whichever side panels are open, at the widths they are actually drawn
+ /// at rather than at their floors. A sidebar someone dragged wider is width
+ /// the terminal no longer has.
+ pub(crate) fn document_body_px(&self, window: &Window, cx: &gpui::App) -> f32 {
+ let viewport = window.viewport_size().width.as_f32();
+ let sidebar = if self.sidebar_open(cx) {
+ self.sidebar_px(window, cx)
+ } else {
+ 0.
+ };
+ let panel = if self.right_panel_open(cx) {
+ self.right_panel_px(window, cx)
+ } else {
+ 0.
+ };
+ viewport - sidebar - panel
+ }
+
+ /// How wide the document column is drawn this frame, or `None` when the
+ /// surface is closed, the user chose fill, or the window is too narrow to
+ /// seat both.
+ pub(crate) fn document_dock_px(&self, window: &Window, cx: &gpui::App) -> Option {
+ if self.document_layout(cx) != DocumentLayout::Dock || self.document_front().is_none() {
+ return None;
+ }
+ document_column_px(self.document_body_px(window, cx), self.document_ratio.get())
+ }
+
+ /// Fill ↔ dock, for the active tab. One of the two writers of the layout;
+ /// the narrow window fallback is not, on purpose — running this while the
+ /// fallback is showing is the user saying they meant the overlay, and that
+ /// is worth keeping.
+ pub(crate) fn toggle_document_fill(&mut self, cx: &mut Context) {
+ let next = match self.document_layout(cx) {
+ DocumentLayout::Dock => DocumentLayout::Fill,
+ DocumentLayout::Fill => DocumentLayout::Dock,
+ };
+ self.set_document_layout(next, cx);
+ }
+
+ /// Snap the column to a named share of the terminal column. Docks first if
+ /// the surface is filling the window: asking for a third of the width is
+ /// asking for a column.
+ pub(crate) fn set_document_ratio(&mut self, ratio: f32, cx: &mut Context) {
+ self.document_ratio.set(ratio);
+ self.update_config(cx, |cfg| cfg.document_ratio = ratio);
+ self.set_document_layout(DocumentLayout::Dock, cx);
+ }
+
+ /// Third → half → two thirds → third, the double-click on the divider.
+ /// Starts from whichever named width the current one is nearest, so a
+ /// dragged column joins the cycle where it looks like it is.
+ pub(crate) fn cycle_document_ratio(&mut self, cx: &mut Context) {
+ let current = self.document_ratio.get();
+ let nearest = next_ratio_stop(current);
+ self.set_document_ratio(nearest, cx);
+ }
+
+ /// Point the active tab at a layout, and only that tab.
+ ///
+ /// Deliberately not written back to `document_layout` in the config: that
+ /// key is the value a tab starts from, and a tab that has not been told is
+ /// still reading it. Writing it here would reach every one of those at
+ /// once, which is the window-wide switch this is not.
+ ///
+ /// The narrow-window fallback does not come through here either — it is
+ /// derived at render time and stored nowhere.
+ pub(crate) fn set_document_layout(&mut self, layout: DocumentLayout, cx: &mut Context) {
+ if let Some(tab) = self.tabs.get_mut(self.active) {
+ tab.document_layout = Some(layout);
+ }
+ cx.notify();
+ }
+
+ /// What right-clicking a document's header offers: where it sits.
+ ///
+ /// On the header rather than in Settings because this is where the question
+ /// comes up — the moment a file covers the terminal is the moment you want
+ /// it not to, and a preference three pages into a settings panel is not an
+ /// answer to that. On the header rather than on a tile beside the close
+ /// button because the row already carries a file name, a dirty dot and, for
+ /// a diff, a view toggle; a fifth control in a column's width is one too
+ /// many for something you set once.
+ ///
+ pub(crate) fn document_header_menu(
+ menu: PopupMenu,
+ app: &gpui::WeakEntity,
+ cx: &gpui::App,
+ ) -> PopupMenu {
+ let docked = app
+ .upgrade()
+ .is_none_or(|this| this.read(cx).document_layout(cx) == DocumentLayout::Dock);
+ let mut menu = menu.min_w(px(220.));
+
+ for (label, layout) in [
+ (L10nKey::DocumentDock, DocumentLayout::Dock),
+ (L10nKey::DocumentFill, DocumentLayout::Fill),
+ ] {
+ menu = menu.item(
+ PopupMenuItem::new(t(label))
+ .checked(docked == (layout == DocumentLayout::Dock))
+ .on_click({
+ let app = app.clone();
+ move |_, _window, cx| {
+ let _ = app.update(cx, |this, cx| this.set_document_layout(layout, cx));
+ }
+ }),
+ );
+ }
+ menu
+ }
+
+ /// The column itself: the surface `overlay_top` selects, sized, bordered,
+ /// and given the divider on its left edge.
+ /// The header on its own, for the strip above the column — see
+ /// [`DocumentChrome::DockHoisted`]. Returns `None` for the layouts that
+ /// keep their header inside the surface.
+ pub(crate) fn render_document_header(
+ &mut self,
+ chrome: DocumentChrome,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Option {
+ if chrome.renders_own_header() {
+ return None;
+ }
+ match self.document_front()? {
+ OverlayTop::Code => Some(self.render_editor_header_only(chrome, window, cx)),
+ OverlayTop::Diff => self.render_diff_header_only(chrome, window, cx),
+ }
+ }
+
+ pub(crate) fn render_document_column(
+ &mut self,
+ width: f32,
+ chrome: DocumentChrome,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Option {
+ let body = self.document_body_px(window, cx);
+ let surface = match self.document_front()? {
+ OverlayTop::Code => self.render_code_overlay(chrome, window, cx),
+ OverlayTop::Diff => self.render_diff_overlay(chrome, window, cx),
+ }?;
+ let (backing, handle) = self.document_resize(body, cx);
+ Some(
+ v_flex()
+ .id("document-column")
+ .relative()
+ .flex_none()
+ .w(px(width))
+ .h_full()
+ .bg(crate::ui::theme::workspace_surface_color(cx))
+ .border_l_1()
+ .border_color(cx.theme().sidebar_border)
+ .child(backing)
+ // The clip belongs to the content, not to the column: the
+ // divider hangs half a handle past the left edge, the way the
+ // panels' do, and clipping the column would have taken that
+ // half — and the grab with it — away.
+ .child(
+ div()
+ .flex_1()
+ .min_h_0()
+ .w_full()
+ .overflow_hidden()
+ .child(surface),
+ )
+ .child(handle)
+ .into_any_element(),
+ )
+ }
+
+ /// The divider, on the same contract as the right panel's: the cell moves
+ /// on every mouse move, the config is written once, on mouse up.
+ ///
+ /// `body` is read here, while there is still a `cx` to read it from — the
+ /// drag handler only ever sees a `Window`, and a limit that disagreed with
+ /// the one the layout applies would spring the column back from wherever it
+ /// was dropped.
+ fn document_resize(&self, body: f32, cx: &mut Context) -> (AnyElement, AnyElement) {
+ use gpui::{Bounds, MouseButton, MouseMoveEvent, MouseUpEvent, Pixels, canvas};
+
+ let container: Rc>>> = 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 ratio_cell = self.document_ratio.clone();
+ let dragging = self.document_dragging.clone();
+ move |_bounds, _state, window, _cx| {
+ window.on_mouse_event({
+ let container = container.clone();
+ let ratio_cell = ratio_cell.clone();
+ let dragging = dragging.clone();
+ move |ev: &MouseMoveEvent, _phase, window, _cx| {
+ if !dragging.get() || body <= 0. {
+ 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 w = raw
+ .clamp(DOCUMENT_MIN_W, (body - TERMINAL_MIN_W).max(DOCUMENT_MIN_W));
+ ratio_cell.set(w / body);
+ window.refresh();
+ }
+ });
+ window.on_mouse_event({
+ let ratio_cell = ratio_cell.clone();
+ let dragging = dragging.clone();
+ move |_ev: &MouseUpEvent, _phase, window, cx| {
+ if !dragging.get() {
+ return;
+ }
+ dragging.set(false);
+ let r = ratio_cell.get();
+ let cfg = cx.global_mut::();
+ if cfg.document_ratio != r {
+ cfg.document_ratio = r;
+ cfg.save();
+ }
+ window.refresh();
+ }
+ });
+ }
+ },
+ )
+ .absolute()
+ .size_full()
+ .into_any_element();
+
+ let active = self.document_dragging.get();
+ let handle = div()
+ .id("document-resize")
+ .group("document-resize")
+ .occlude()
+ .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("document-resize", |s| s.bg(cx.theme().drag_border)),
+ )
+ .on_mouse_down(MouseButton::Left, {
+ let dragging = self.document_dragging.clone();
+ move |_ev, window, _cx| {
+ dragging.set(true);
+ window.refresh();
+ }
+ })
+ // A double-click lands a mouse-down first, which arms the drag; the
+ // mouse-up disarms it without having moved, so the cycle below is
+ // the only thing that ends up happening.
+ .on_double_click(cx.listener(|this, _, _window, cx| {
+ this.document_dragging.set(false);
+ this.cycle_document_ratio(cx);
+ }))
+ .into_any_element();
+
+ (backing, handle)
+ }
+}
+
+/// The width the divider's double-click moves to from `current`: the one after
+/// whichever named stop `current` is nearest, wrapping round.
+pub(crate) fn next_ratio_stop(current: f32) -> f32 {
+ let nearest = DOCUMENT_RATIO_STOPS
+ .iter()
+ .enumerate()
+ .min_by(|(_, a), (_, b)| (*a - current).abs().total_cmp(&(*b - current).abs()))
+ .map(|(i, _)| i)
+ .unwrap_or(1);
+ DOCUMENT_RATIO_STOPS[(nearest + 1) % DOCUMENT_RATIO_STOPS.len()]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::next_ratio_stop;
+ use crate::core::config::{
+ DOCUMENT_RATIO_HALF, DOCUMENT_RATIO_THIRD, DOCUMENT_RATIO_TWO_THIRDS,
+ };
+ use crate::ui::app::{DOCUMENT_MIN_W, TERMINAL_MIN_W, document_column_px};
+
+ /// Third, half, two thirds, round again — and a dragged width joins at
+ /// whichever stop it looks nearest to rather than always restarting.
+ #[test]
+ fn the_divider_cycles_the_named_widths() {
+ assert_eq!(next_ratio_stop(DOCUMENT_RATIO_THIRD), DOCUMENT_RATIO_HALF);
+ assert_eq!(
+ next_ratio_stop(DOCUMENT_RATIO_HALF),
+ DOCUMENT_RATIO_TWO_THIRDS
+ );
+ assert_eq!(
+ next_ratio_stop(DOCUMENT_RATIO_TWO_THIRDS),
+ DOCUMENT_RATIO_THIRD
+ );
+ // Dragged to just under half: nearest stop is half, so the cycle goes
+ // on to two thirds rather than back to a third.
+ assert_eq!(next_ratio_stop(0.47), DOCUMENT_RATIO_TWO_THIRDS);
+ assert_eq!(next_ratio_stop(0.8), DOCUMENT_RATIO_THIRD);
+ }
+
+ /// The default is half of what the terminal and the document share, and
+ /// what they share is the window less the panels — not the window.
+ #[test]
+ fn half_of_the_body_is_half_of_the_body() {
+ let body = 1440. - 220. - 260.;
+ assert_eq!(document_column_px(body, 0.5), Some(body / 2.));
+ }
+
+ /// Two thirds is allowed past the half-window cap the side panels obey.
+ /// Only the terminal's floor binds it.
+ #[test]
+ fn two_thirds_is_two_thirds_until_the_terminal_floor_says_otherwise() {
+ let wide = 1200.;
+ assert_eq!(document_column_px(wide, 2. / 3.), Some(wide * 2. / 3.));
+
+ // 800 * 2/3 is 533, which would leave the terminal 267 — under its
+ // floor — so the column stops where the terminal starts.
+ let tight = 800.;
+ assert_eq!(
+ document_column_px(tight, 2. / 3.),
+ Some(tight - TERMINAL_MIN_W)
+ );
+ }
+
+ /// A column narrower than it can be read at is not a column. The ratio
+ /// floors out rather than shrinking with the window.
+ #[test]
+ fn a_thin_share_still_gets_the_documents_floor() {
+ let body = 700.;
+ assert_eq!(document_column_px(body, 0.2), Some(DOCUMENT_MIN_W));
+ }
+
+ /// Below the width where both fit there is no docked layout to draw, and
+ /// the caller falls back to the overlay for the frame. The threshold is
+ /// exact so that widening by a point re-docks.
+ #[test]
+ fn a_window_too_narrow_for_both_has_no_docked_width() {
+ let floor = TERMINAL_MIN_W + DOCUMENT_MIN_W;
+ assert_eq!(document_column_px(floor - 1., 0.5), None);
+ assert_eq!(document_column_px(floor, 0.5), Some(DOCUMENT_MIN_W));
+ assert_eq!(document_column_px(f32::NAN, 0.5), None);
+ }
+
+ /// Whatever the terminal is left, it is never less than its floor. That is
+ /// the invariant the whole budget exists for.
+ #[test]
+ fn the_terminal_keeps_its_floor_at_every_share() {
+ for body in [640., 700., 900., 1200., 2400.] {
+ for ratio in [0.2, 1. / 3., 0.5, 2. / 3., 0.8] {
+ let Some(doc) = document_column_px(body, ratio) else {
+ continue;
+ };
+ assert!(
+ body - doc >= TERMINAL_MIN_W - f32::EPSILON,
+ "body {body} ratio {ratio} left the terminal {}",
+ body - doc
+ );
+ assert!(doc >= DOCUMENT_MIN_W - f32::EPSILON);
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod gpui_tests {
+ use super::*;
+ use crate::core::config::{DOCUMENT_RATIO_TWO_THIRDS, DocumentLayout};
+ use crate::ui::app::test_window;
+ use crate::ui::pane::{Pane, PaneSlot};
+ use crate::ui::pending_pane::{PendingPane, PendingSpawn};
+ use gpui::{Entity, TestAppContext, VisualTestContext, px, size};
+
+ /// One quiet tab. The pane is a *connecting* one so the harness needs no
+ /// PTY and runs on every platform.
+ fn push_tab(app: &mut Tty7App, cx: &mut Context) {
+ let pending = cx.new(|cx| {
+ PendingPane::new(
+ "test-box",
+ PendingSpawn {
+ workspace: None,
+ working_directory: None,
+ restore_pane: None,
+ shell: None,
+ agent: None,
+ agent_session_id: None,
+ agent_launch_argv: None,
+ owner: None,
+ font_size: 14.0,
+ },
+ cx,
+ )
+ });
+ app.tabs
+ .push(crate::ui::app::Tab::new(Pane::leaf(PaneSlot::Connecting(
+ pending,
+ ))));
+ cx.notify();
+ }
+
+ /// A window with `tabs` quiet tabs, active on the first, sized to order.
+ fn window_with(
+ cx: &mut TestAppContext,
+ w: f32,
+ tabs: usize,
+ ) -> (Entity, VisualTestContext) {
+ let (app, mut vcx) = test_window::harness(cx);
+ app.update_in(&mut vcx, |app, _, cx| {
+ for _ in 0..tabs {
+ push_tab(app, cx);
+ }
+ app.active = 0;
+ });
+ vcx.simulate_resize(size(px(w), px(900.)));
+ vcx.run_until_parked();
+ (app, vcx)
+ }
+
+ fn window(cx: &mut TestAppContext, w: f32) -> (Entity, VisualTestContext) {
+ window_with(cx, w, 1)
+ }
+
+ fn dock_px(app: &Entity, vcx: &mut VisualTestContext) -> Option {
+ app.update_in(vcx, |app, window, cx| app.document_dock_px(window, cx))
+ }
+
+ /// The config value: the default under tabs that were never told, not
+ /// necessarily what the active tab is doing.
+ fn layout(vcx: &mut VisualTestContext) -> DocumentLayout {
+ vcx.update(|_, cx| cx.global::().document_layout)
+ }
+
+ /// What the active tab is actually doing.
+ fn tab_layout(app: &Entity, vcx: &mut VisualTestContext) -> DocumentLayout {
+ app.update_in(vcx, |app, _, cx| app.document_layout(cx))
+ }
+
+ /// Fill is one tab's answer, not the window's. Reading a long file in one
+ /// tab while an agent works in another wants the whole window here and half
+ /// of it there, and a global switch made each of those flip the other.
+ #[gpui::test]
+ fn one_tabs_fill_leaves_the_other_docked(cx: &mut TestAppContext) {
+ let (app, mut vcx) = window_with(cx, 1440., 2);
+
+ // A file open in each tab, both docked to start with.
+ for i in [0, 1] {
+ app.update_in(&mut vcx, |app, window, cx| {
+ app.active = i;
+ app.toggle_code_panel(window, cx);
+ });
+ }
+ vcx.run_until_parked();
+
+ app.update_in(&mut vcx, |app, _, cx| {
+ app.active = 1;
+ app.toggle_document_fill(cx);
+ });
+ vcx.run_until_parked();
+ assert_eq!(dock_px(&app, &mut vcx), None, "the tab that asked, fills");
+
+ app.update_in(&mut vcx, |app, _, _cx| app.active = 0);
+ vcx.run_until_parked();
+ assert!(
+ dock_px(&app, &mut vcx).is_some(),
+ "the tab that did not ask, does not"
+ );
+
+ // The config is untouched — it is what every tab that was never told is
+ // still reading, so writing it would have reached all of them at once.
+ assert_eq!(layout(&mut vcx), DocumentLayout::Dock);
+ app.update_in(&mut vcx, |app, window, cx| {
+ push_tab(app, cx);
+ app.active = 2;
+ app.toggle_code_panel(window, cx);
+ });
+ vcx.run_until_parked();
+ assert!(
+ dock_px(&app, &mut vcx).is_some(),
+ "a fresh tab starts from the config, not from what tab 1 chose"
+ );
+ }
+
+ /// The complaint in #625: opening a file must leave the terminal on screen.
+ /// The column is half of what the terminal and the document share, and the
+ /// terminal's own laid-out area gives up exactly that width — which is what
+ /// makes the PTY reflow rather than hide half its columns under a card.
+ #[gpui::test]
+ fn opening_the_code_panel_docks_a_column_beside_the_terminal(cx: &mut TestAppContext) {
+ let (app, mut vcx) = window(cx, 1440.);
+
+ app.update_in(&mut vcx, |app, window, cx| {
+ app.toggle_code_panel(window, cx);
+ });
+ vcx.run_until_parked();
+
+ let body = app.update_in(&mut vcx, |app, window, cx| app.document_body_px(window, cx));
+ let docked = dock_px(&app, &mut vcx).expect("a 1440 window seats both");
+ assert!(
+ (docked - body / 2.).abs() < 0.5,
+ "half the terminal column, not half the window: {docked} of {body}"
+ );
+
+ // The terminal is laid out at what is left, not at the full width with
+ // a card over half of it. is the rectangle the grid sizes
+ // itself from, so this is the assertion the PTY reflow rests on.
+ let pane = app
+ .update_in(&mut vcx, |app, _, _| app.pane_area.get())
+ .expect("the body area painted");
+ assert!(
+ (pane.size.width.as_f32() - (body - docked)).abs() < 1.5,
+ "terminal laid out at {} of a {body} body beside a {docked} column",
+ pane.size.width.as_f32()
+ );
+ assert!(pane.size.width.as_f32() >= TERMINAL_MIN_W);
+ }
+
+ /// Esc — which is what `toggle_code_panel` runs — puts the width back.
+ #[gpui::test]
+ fn closing_the_surface_gives_the_width_back(cx: &mut TestAppContext) {
+ let (app, mut vcx) = window(cx, 1440.);
+ app.update_in(&mut vcx, |app, window, cx| {
+ app.toggle_code_panel(window, cx);
+ });
+ vcx.run_until_parked();
+ assert!(dock_px(&app, &mut vcx).is_some());
+
+ app.update_in(&mut vcx, |app, window, cx| {
+ app.toggle_code_panel(window, cx);
+ });
+ vcx.run_until_parked();
+ assert_eq!(
+ app.update_in(&mut vcx, |app, _, _| app.document_front()),
+ None
+ );
+ assert_eq!(dock_px(&app, &mut vcx), None);
+ assert_eq!(
+ app.update_in(&mut vcx, |app, _, cx| app.document_floor(cx)),
+ 0.,
+ "a closed surface reserves nothing from the panels"
+ );
+ }
+
+ /// A window with no room for both falls back to the overlay for the frame
+ /// and leaves the saved layout alone. Widening re-docks with no command run
+ /// in between — the fallback is derived, not stored.
+ #[gpui::test]
+ fn a_narrow_window_falls_back_without_saving_it(cx: &mut TestAppContext) {
+ let (app, mut vcx) = window(cx, 560.);
+ app.update_in(&mut vcx, |app, window, cx| {
+ app.toggle_code_panel(window, cx);
+ });
+ vcx.run_until_parked();
+
+ assert_eq!(dock_px(&app, &mut vcx), None, "no room for both");
+ assert_eq!(
+ layout(&mut vcx),
+ DocumentLayout::Dock,
+ "the fallback must never write the user's choice"
+ );
+
+ vcx.simulate_resize(size(px(1440.), px(900.)));
+ vcx.run_until_parked();
+ assert!(
+ dock_px(&app, &mut vcx).is_some(),
+ "widening re-docks on the next frame"
+ );
+ assert_eq!(layout(&mut vcx), DocumentLayout::Dock);
+ }
+
+ /// Filling is still there, and asking for it *is* a choice worth keeping —
+ /// including from inside the narrow-window fallback, where the user is
+ /// looking at an overlay and saying they want it.
+ #[gpui::test]
+ fn asking_to_fill_is_kept_and_a_named_width_docks_again(cx: &mut TestAppContext) {
+ let (app, mut vcx) = window(cx, 560.);
+ app.update_in(&mut vcx, |app, window, cx| {
+ app.toggle_code_panel(window, cx);
+ });
+ app.update_in(&mut vcx, |app, _, cx| app.toggle_document_fill(cx));
+ vcx.run_until_parked();
+ assert_eq!(tab_layout(&app, &mut vcx), DocumentLayout::Fill);
+
+ vcx.simulate_resize(size(px(1440.), px(900.)));
+ vcx.run_until_parked();
+ assert_eq!(
+ dock_px(&app, &mut vcx),
+ None,
+ "a wide window does not overrule a chosen fill"
+ );
+
+ // Asking for two thirds of the width is asking for a column.
+ app.update_in(&mut vcx, |app, _, cx| {
+ app.set_document_ratio(DOCUMENT_RATIO_TWO_THIRDS, cx)
+ });
+ vcx.run_until_parked();
+ assert_eq!(tab_layout(&app, &mut vcx), DocumentLayout::Dock);
+ let body = app.update_in(&mut vcx, |app, window, cx| app.document_body_px(window, cx));
+ let docked = dock_px(&app, &mut vcx).expect("docked again");
+ // Two thirds, or as near as the terminal's floor allows — with both
+ // side panels open a 1440 window has 960 to share, and two thirds of
+ // that would leave the terminal 320.
+ let want = (body * 2. / 3.).min(body - TERMINAL_MIN_W);
+ assert!((docked - want).abs() < 0.5, "{docked} of {body}");
+ assert!(docked > body / 2., "wider than the half it started at");
+ assert_eq!(
+ vcx.update(|_, cx| cx.global::().document_ratio),
+ DOCUMENT_RATIO_TWO_THIRDS
+ );
+
+ // The header menu writes the layout outright rather than toggling it.
+ app.update_in(&mut vcx, |app, _, cx| {
+ app.set_document_layout(DocumentLayout::Fill, cx)
+ });
+ vcx.run_until_parked();
+ assert_eq!(tab_layout(&app, &mut vcx), DocumentLayout::Fill);
+ assert_eq!(dock_px(&app, &mut vcx), None);
+ assert_eq!(
+ vcx.update(|_, cx| cx.global::().document_ratio),
+ DOCUMENT_RATIO_TWO_THIRDS,
+ "filling does not forget the width to come back to"
+ );
+ }
+}
diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs
index 1db56b4f..09ad93db 100644
--- a/src/ui/i18n/en.rs
+++ b/src/ui/i18n/en.rs
@@ -554,6 +554,8 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsDiffPreviewFromCountsDesc => {
"Click a row's +N −N to open the working-tree diff in an overlay. Off leaves the counts visible, just not clickable."
}
+ L10nKey::DocumentDock => "Dock beside terminal",
+ L10nKey::DocumentFill => "Fill window",
L10nKey::SettingsNotifications => "Notifications",
L10nKey::SettingsNotifyOnCommandFinish => "Notify on command finish",
L10nKey::SettingsNotifyOnCommandFinishDesc => {
@@ -1412,6 +1414,12 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::CmdResetFontSize => "Reset Font Size",
L10nKey::CmdEnterFullScreen => "Enter Full Screen",
L10nKey::CmdToggleDiffViewMode => "Toggle Unified / Side-by-Side Diff",
+ L10nKey::CmdDocumentDock => "Document: Dock Beside Terminal",
+ L10nKey::CmdDocumentFill => "Document: Fill Window",
+ L10nKey::CmdToggleDocumentFill => "Toggle Document Fill / Dock",
+ L10nKey::CmdDocumentWidthThird => "Document: Third Width",
+ L10nKey::CmdDocumentWidthHalf => "Document: Half Width",
+ L10nKey::CmdDocumentWidthTwoThirds => "Document: Two-Thirds Width",
L10nKey::CmdGitCommit => "Git: Commit",
L10nKey::CmdGitStageAll => "Git: Stage All Changes",
L10nKey::CmdGitUnstageAll => "Git: Unstage All Changes",
diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs
index 4b7edcd9..6d73b2d2 100644
--- a/src/ui/i18n/ja.rs
+++ b/src/ui/i18n/ja.rs
@@ -563,6 +563,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsDiffPreviewFromCountsDesc => {
"行の +N −N をクリックすると、オーバーレイでワーキングツリーの Diff を開きます。オフならカウントは表示されたまま、クリックだけできません"
}
+ L10nKey::DocumentDock => "ターミナルの隣にドック",
+ L10nKey::DocumentFill => "ウィンドウ全体",
L10nKey::SettingsNotifications => "通知",
L10nKey::SettingsNotifyOnCommandFinish => "コマンド終了時に通知",
L10nKey::SettingsNotifyOnCommandFinishDesc => {
@@ -1469,6 +1471,12 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdResetFontSize => "フォントサイズをリセット",
L10nKey::CmdEnterFullScreen => "全画面表示",
L10nKey::CmdToggleDiffViewMode => "統合 / 左右分割の差分表示を切り替え",
+ L10nKey::CmdDocumentDock => "ドキュメント: ターミナルの隣にドック",
+ L10nKey::CmdDocumentFill => "ドキュメント: ウィンドウ全体",
+ L10nKey::CmdToggleDocumentFill => "ドキュメントのフィル / ドックを切り替え",
+ L10nKey::CmdDocumentWidthThird => "ドキュメント: 幅3分の1",
+ L10nKey::CmdDocumentWidthHalf => "ドキュメント: 幅半分",
+ L10nKey::CmdDocumentWidthTwoThirds => "ドキュメント: 幅3分の2",
L10nKey::CmdGitCommit => "Git: コミット",
L10nKey::CmdGitStageAll => "Git: すべての変更をステージ",
L10nKey::CmdGitUnstageAll => "Git: すべてのステージを取り消す",
diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs
index 61872198..95a68dd8 100644
--- a/src/ui/i18n/mod.rs
+++ b/src/ui/i18n/mod.rs
@@ -464,6 +464,8 @@ l10n_keys! {
SettingsSidebarGroupingDesc,
SettingsDiffPreviewFromCounts,
SettingsDiffPreviewFromCountsDesc,
+ DocumentDock,
+ DocumentFill,
SettingsNotifications,
SettingsWindow,
SettingsNotifyOnCommandFinish,
@@ -1162,6 +1164,12 @@ l10n_keys! {
CmdResetFontSize,
CmdEnterFullScreen,
CmdToggleDiffViewMode,
+ CmdDocumentDock,
+ CmdDocumentFill,
+ CmdToggleDocumentFill,
+ CmdDocumentWidthThird,
+ CmdDocumentWidthHalf,
+ CmdDocumentWidthTwoThirds,
CmdGitCommit,
CmdGitStageAll,
CmdGitUnstageAll,
diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs
index da4ed249..6309e7a0 100644
--- a/src/ui/i18n/zh.rs
+++ b/src/ui/i18n/zh.rs
@@ -488,6 +488,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsDiffPreviewFromCountsDesc => {
"点击行上的 +N −N 在浮层中打开 worktree diff。关闭后计数仍显示,只是不可点击。"
}
+ L10nKey::DocumentDock => "停靠在终端旁",
+ L10nKey::DocumentFill => "铺满窗口",
L10nKey::SettingsNotifications => "通知",
L10nKey::SettingsNotifyOnCommandFinish => "命令完成时通知",
L10nKey::SettingsNotifyOnCommandFinishDesc => "较长的前台命令完成后发出桌面提醒。",
@@ -1333,6 +1335,12 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdEnterFullScreen => "进入全屏",
L10nKey::CmdClearScrollback => "清除回滚内容",
L10nKey::CmdToggleDiffViewMode => "切换统一 / 并排差异视图",
+ L10nKey::CmdDocumentDock => "文档:停靠在终端旁",
+ L10nKey::CmdDocumentFill => "文档:铺满窗口",
+ L10nKey::CmdToggleDocumentFill => "切换文档铺满 / 停靠",
+ L10nKey::CmdDocumentWidthThird => "文档:三分之一宽",
+ L10nKey::CmdDocumentWidthHalf => "文档:一半宽",
+ L10nKey::CmdDocumentWidthTwoThirds => "文档:三分之二宽",
L10nKey::CmdGitCommit => "Git:提交",
L10nKey::CmdGitStageAll => "Git:暂存全部更改",
L10nKey::CmdGitUnstageAll => "Git:取消暂存全部更改",
diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs
index ccc07265..5d996f24 100644
--- a/src/ui/keymap.rs
+++ b/src/ui/keymap.rs
@@ -383,6 +383,13 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
("ToggleSftp", ""),
("ShowSshForwards", ""),
("ToggleCodePanel", "secondary-shift-e"),
+ // Deliberately unbound. Docking is the default and Esc already gets the
+ // terminal back, so a default chord here would only be one more thing
+ // competing for a two-key combination nobody asked for.
+ ("ToggleDocumentFill", ""),
+ ("DocumentWidthThird", ""),
+ ("DocumentWidthHalf", ""),
+ ("DocumentWidthTwoThirds", ""),
// Implemented, dispatchable, and until now unbindable: `set_binding`
// only fills slots that exist here, so `"ShowRightPanelInfo": "ctrl-1"`
// in config.json was dropped without a word, and the Keybindings page —
@@ -568,6 +575,22 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> {
t(L10nKey::AppMenuRightPanel).to_string(),
),
"ToggleCodePanel" => (CommandGroup::View, t(L10nKey::AppMenuCodePanel).to_string()),
+ "ToggleDocumentFill" => (
+ CommandGroup::View,
+ t(L10nKey::CmdToggleDocumentFill).to_string(),
+ ),
+ "DocumentWidthThird" => (
+ CommandGroup::View,
+ t(L10nKey::CmdDocumentWidthThird).to_string(),
+ ),
+ "DocumentWidthHalf" => (
+ CommandGroup::View,
+ t(L10nKey::CmdDocumentWidthHalf).to_string(),
+ ),
+ "DocumentWidthTwoThirds" => (
+ CommandGroup::View,
+ t(L10nKey::CmdDocumentWidthTwoThirds).to_string(),
+ ),
"ShowRightPanelInfo" => (
CommandGroup::View,
t(L10nKey::CmdRightPanelInfo).to_string(),
@@ -1030,6 +1053,10 @@ fn make_binding(action: &str, keystroke: &str) -> Option {
"ToggleSftp" => KeyBinding::new(keystroke, ToggleSftp, None),
"ShowSshForwards" => KeyBinding::new(keystroke, ShowSshForwards, None),
"ToggleCodePanel" => KeyBinding::new(keystroke, ToggleCodePanel, None),
+ "ToggleDocumentFill" => KeyBinding::new(keystroke, ToggleDocumentFill, None),
+ "DocumentWidthThird" => KeyBinding::new(keystroke, DocumentWidthThird, None),
+ "DocumentWidthHalf" => KeyBinding::new(keystroke, DocumentWidthHalf, None),
+ "DocumentWidthTwoThirds" => KeyBinding::new(keystroke, DocumentWidthTwoThirds, None),
"EditorSave" => KeyBinding::new(keystroke, EditorSave, None),
"OpenSshProfiles" => KeyBinding::new(keystroke, OpenSshProfiles, None),
"RestartSshSession" => KeyBinding::new(keystroke, RestartSshSession, None),
diff --git a/src/ui/mod.rs b/src/ui/mod.rs
index df5799f4..31f360c8 100644
--- a/src/ui/mod.rs
+++ b/src/ui/mod.rs
@@ -3,6 +3,7 @@ pub mod assets;
pub mod code_editor;
pub mod diff_overlay;
pub mod diff_rows;
+pub mod document_column;
pub mod file_copy;
pub mod file_tree;
pub mod forwards;
diff --git a/src/ui/palette.rs b/src/ui/palette.rs
index 84c7de8b..9014ec82 100644
--- a/src/ui/palette.rs
+++ b/src/ui/palette.rs
@@ -75,6 +75,10 @@ pub enum CommandKind {
ToggleSftp,
ShowSshForwards,
ToggleCodePanel,
+ ToggleDocumentFill,
+ DocumentWidthThird,
+ DocumentWidthHalf,
+ DocumentWidthTwoThirds,
RestartSshSession,
ScmCommit,
ScmStageAll,
@@ -182,6 +186,10 @@ impl CommandKind {
ToggleSftp => "ssh-remote-files",
ShowSshForwards => "ssh-port-forwarding",
ToggleCodePanel => "code-panel",
+ ToggleDocumentFill => "document-fill",
+ DocumentWidthThird => "document-width-third",
+ DocumentWidthHalf => "document-width-half",
+ DocumentWidthTwoThirds => "document-width-two-thirds",
RestartSshSession => "ssh-reconnect",
ScmCommit => "git-commit",
ScmStageAll => "git-stage-all",
@@ -281,6 +289,10 @@ impl CommandKind {
ToggleSftp => "ToggleSftp",
ShowSshForwards => "ShowSshForwards",
ToggleCodePanel => "ToggleCodePanel",
+ ToggleDocumentFill => "ToggleDocumentFill",
+ DocumentWidthThird => "DocumentWidthThird",
+ DocumentWidthHalf => "DocumentWidthHalf",
+ DocumentWidthTwoThirds => "DocumentWidthTwoThirds",
RestartSshSession => "RestartSshSession",
OpenSshProfiles => "OpenSshProfiles",
ScmCommit => "ScmCommit",
@@ -410,6 +422,7 @@ impl Command {
let tab_bar_left = cfg.tab_bar_position == TabBarPosition::Left;
let sidebar_hidden = chrome.rail_collapsed || !tab_bar_left;
let right_panel_open = chrome.right_panel_visible;
+ let document_filled = cfg.document_layout == crate::core::config::DocumentLayout::Fill;
let tabs = [
Command::localized(L10nKey::CmdNewTab, NewTab),
@@ -473,6 +486,17 @@ impl Command {
ToggleRightPanel,
),
Command::localized(L10nKey::CmdShowCodePanel, ToggleCodePanel),
+ Command::localized(
+ if document_filled {
+ L10nKey::CmdDocumentDock
+ } else {
+ L10nKey::CmdDocumentFill
+ },
+ ToggleDocumentFill,
+ ),
+ Command::localized(L10nKey::CmdDocumentWidthThird, DocumentWidthThird),
+ Command::localized(L10nKey::CmdDocumentWidthHalf, DocumentWidthHalf),
+ Command::localized(L10nKey::CmdDocumentWidthTwoThirds, DocumentWidthTwoThirds),
Command::localized(
if tab_bar_left {
L10nKey::CmdTabBarMoveToTop
diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs
index 2ae44ca1..ddb0a0b6 100644
--- a/src/ui/right_panel.rs
+++ b/src/ui/right_panel.rs
@@ -306,7 +306,7 @@ impl Tty7App {
crate::ui::app::side_panel_max(
window.viewport_size().width.as_f32(),
MIN_WIDTH,
- self.sidebar_floor(cx),
+ self.sidebar_floor(cx) + self.document_floor(cx),
)
}
@@ -406,7 +406,7 @@ impl Tty7App {
// below only ever sees a `Window`, and the cap it clamps against has to
// be the same one the layout applies or the panel springs back from
// wherever it was dropped.
- let sidebar_floor = self.sidebar_floor(cx);
+ let others_floor = self.sidebar_floor(cx) + self.document_floor(cx);
let backing = canvas(
{
let container = container.clone();
@@ -433,7 +433,7 @@ impl Tty7App {
let max = crate::ui::app::side_panel_max(
window.viewport_size().width.as_f32(),
MIN_WIDTH,
- sidebar_floor,
+ others_floor,
);
width_cell.set(raw.clamp(MIN_WIDTH, max));
window.refresh();
diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs
index 449523ff..728d4407 100644
--- a/src/ui/tab_sidebar.rs
+++ b/src/ui/tab_sidebar.rs
@@ -117,10 +117,20 @@ impl Tty7App {
crate::ui::app::side_panel_max(
window.viewport_size().width.as_f32(),
MIN_SIDEBAR_WIDTH,
- self.right_panel_floor(cx),
+ self.right_panel_floor(cx) + self.document_floor(cx),
)
}
+ /// How wide the sidebar is drawn, given the live cell and the cap the rest
+ /// of the window leaves it. Read here rather than clamped at each caller so
+ /// the document column's budget and the sidebar itself can never disagree
+ /// about how much width is already spoken for.
+ pub(crate) fn sidebar_px(&self, window: &Window, cx: &gpui::App) -> f32 {
+ self.sidebar_width
+ .get()
+ .clamp(MIN_SIDEBAR_WIDTH, self.sidebar_max_px(window, cx))
+ }
+
pub(crate) fn tab_sidebar(
&self,
window: &mut Window,
@@ -129,8 +139,7 @@ impl Tty7App {
let active = self.active;
let sf = cx.global::().sidebar;
let show_badges = self.mod_hint_badges;
- let max_width = self.sidebar_max_px(window, cx);
- let width = self.sidebar_width.get().clamp(MIN_SIDEBAR_WIDTH, max_width);
+ let width = self.sidebar_px(window, cx);
let query = self.sidebar_search.read(cx).value().trim().to_lowercase();
// Blanked here, written again from paint: a row filtered out by the
// search — or hidden with its collapsed group — must leave no rectangle
@@ -1040,7 +1049,7 @@ impl Tty7App {
// below only ever sees a `Window`, and the cap it clamps against has to
// be the same one the layout applies or the sidebar springs back from
// wherever it was dropped.
- let panel_floor = self.right_panel_floor(cx);
+ let others_floor = self.right_panel_floor(cx) + self.document_floor(cx);
let backing = canvas(
{
let container = container.clone();
@@ -1066,7 +1075,7 @@ impl Tty7App {
let max = crate::ui::app::side_panel_max(
window.viewport_size().width.as_f32(),
MIN_SIDEBAR_WIDTH,
- panel_floor,
+ others_floor,
);
width_cell.set(raw.clamp(MIN_SIDEBAR_WIDTH, max));
window.refresh();
diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs
index 93fdfa33..25a78fe6 100644
--- a/src/ui/tab_strip.rs
+++ b/src/ui/tab_strip.rs
@@ -488,6 +488,27 @@ pub(crate) fn chrome_tile(button: Button, selected: bool, cx: &gpui::App) -> But
chrome_tile_sized(button, TILE_SIZE, TILE_GLYPH, selected, cx)
}
+/// How wide the two chrome tiles at the trailing end of the title bar are, with
+/// the padding around them.
+pub(crate) fn trailing_chrome_tiles_w() -> f32 {
+ let trailing_pad = if cfg!(target_os = "macos") {
+ tile_trailing_inset()
+ } else {
+ 4.
+ };
+ trailing_pad + crate::ui::app::TILE_SIZE + 2. + crate::ui::app::TILE_SIZE
+}
+
+/// The whole trailing cluster: those tiles and the OS window buttons beyond
+/// them.
+///
+/// Anything else drawn into that end of the title bar has to stop short of it —
+/// which for the hoisted document header means the case where the detail panel
+/// is closed and the document column runs to the window's right edge.
+pub(crate) fn trailing_chrome_w() -> f32 {
+ trailing_chrome_tiles_w() + crate::ui::app::WINDOW_CONTROLS_W
+}
+
pub(crate) fn chrome_tile_sized(
button: Button,
tile: f32,
@@ -1532,14 +1553,7 @@ impl Tty7App {
let corner_w = if panel_w > 0. {
0.
} else {
- chrome_band_w.unwrap_or_else(|| {
- let trailing_pad = if cfg!(target_os = "macos") {
- tile_trailing_inset()
- } else {
- 4.
- };
- trailing_pad + crate::ui::app::TILE_SIZE + 2. + crate::ui::app::TILE_SIZE
- })
+ chrome_band_w.unwrap_or_else(trailing_chrome_tiles_w)
};
let fixed_w = 3. * CHIP_GAP + crate::ui::app::TILE_SIZE + corner_w;
let chips_avail = (strip_w - px(fixed_w + GRAB_HANDLE_W)).max(px(80.));
| | | |