diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c411884..e3db1e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,17 +9,53 @@ on: env: CARGO_TERM_COLOR: always +permissions: + contents: read + jobs: - build-and-test: - name: Build & Test (${{ matrix.os }}) - strategy: - fail-fast: false - matrix: - os: [macos-latest, windows-latest] - runs-on: ${{ matrix.os }} + build-and-test-macos: + name: Build & Test (macos-latest) + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: macos-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Check formatting + run: cargo fmt --check + + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Build + run: cargo build --verbose + + - name: Test + run: cargo test --verbose + + build-and-test-windows: + name: Build & Test (windows-latest) + runs-on: windows-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -27,13 +63,13 @@ jobs: components: rustfmt, clippy - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: | ~/.cargo/registry ~/.cargo/git target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + key: windows-cargo-${{ hashFiles('**/Cargo.lock') }} - name: Check formatting run: cargo fmt --check diff --git a/CHANGELOG.md b/CHANGELOG.md index bad156a..41e5b08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to vterm are documented here. ## [Unreleased] +- Add split panes: divide any tab side-by-side (`⌘ D`) or stacked (`⌘ ⇧ D`), click or `⌘ [` / `⌘ ]` to focus, `⌘ W` closes the pane (or the tab when only one remains). Layouts persist across restarts and old single-pane state files keep loading. +- Enable all keyboard shortcuts on Windows via Ctrl (`Ctrl+T/D/W`, `Ctrl+C` copies when text is selected and sends SIGINT otherwise) and show `Ctrl+…` hints in Settings; AltGr combos no longer fire shortcuts or send stray control codes. + + +## [0.6.0] - 2026-09-12 + + - Add Windows support to the curl install script (install.sh) and add native install.ps1 for PowerShell. - Embed application icon and version metadata into Windows vterm.exe binary. - Build and package Windows release zip in the CD workflow. diff --git a/Cargo.lock b/Cargo.lock index f763171..eb05ea8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -462,7 +462,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "auto_update" -version = "0.5.0" +version = "0.6.0" dependencies = [ "async-channel 2.5.0", "semver", @@ -5778,7 +5778,7 @@ dependencies = [ [[package]] name = "terminal" -version = "0.5.0" +version = "0.6.0" dependencies = [ "alacritty_terminal", "async-channel 2.5.0", @@ -5798,7 +5798,7 @@ dependencies = [ [[package]] name = "theme" -version = "0.5.0" +version = "0.6.0" dependencies = [ "gpui", ] @@ -6198,7 +6198,7 @@ dependencies = [ [[package]] name = "ui" -version = "0.5.0" +version = "0.6.0" dependencies = [ "gpui", "theme", @@ -6460,7 +6460,7 @@ dependencies = [ [[package]] name = "vterm" -version = "0.5.0" +version = "0.6.0" dependencies = [ "embed-resource", "gpui", @@ -7255,7 +7255,7 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "workspace" -version = "0.5.0" +version = "0.6.0" dependencies = [ "async-channel 2.5.0", "auto_update", diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 90c34c2..04947a4 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -1,3 +1,41 @@ pub mod button; pub mod modal; pub mod text_input; + +use gpui::Modifiers; + +/// Menu-shortcut modifier: ⌘ on macOS, Ctrl elsewhere. Alt is excluded so +/// AltGr combos on international Windows layouts keep typing characters +/// instead of firing shortcuts. +pub fn shortcut_modifier(m: &Modifiers) -> bool { + m.platform || (!cfg!(target_os = "macos") && m.control && !m.alt) +} + +#[cfg(test)] +mod tests { + use super::shortcut_modifier; + use gpui::Modifiers; + + fn mods(platform: bool, control: bool, alt: bool) -> Modifiers { + Modifiers { + platform, + control, + alt, + shift: false, + function: false, + } + } + + #[test] + fn shortcut_modifier_matches_platform_conventions() { + // ⌘-style always works. + assert!(shortcut_modifier(&mods(true, false, false))); + // AltGr (Ctrl+Alt) never fires shortcuts — it types characters. + assert!(!shortcut_modifier(&mods(false, true, true))); + if cfg!(target_os = "macos") { + assert!(!shortcut_modifier(&mods(false, true, false))); + } else { + assert!(shortcut_modifier(&mods(false, true, false))); + } + } +} diff --git a/crates/ui/src/text_input.rs b/crates/ui/src/text_input.rs index a8edcae..59f037b 100644 --- a/crates/ui/src/text_input.rs +++ b/crates/ui/src/text_input.rs @@ -105,7 +105,7 @@ impl TextField { return consumed; } match key { - "c" | "x" if modifiers.platform => { + "c" | "x" if crate::shortcut_modifier(modifiers) => { let (start, end) = match self.selection() { Some((s, e)) => (s, e), None => return true, @@ -118,7 +118,7 @@ impl TextField { } true } - "v" if modifiers.platform => { + "v" if crate::shortcut_modifier(modifiers) => { if let Some(item) = cx.read_from_clipboard() && let Some(text) = item.text() { @@ -173,7 +173,7 @@ impl TextField { self.caret = self.value.len(); Some(true) } - "a" if modifiers.platform => { + "a" if crate::shortcut_modifier(modifiers) => { self.select_all(); Some(true) } @@ -311,4 +311,21 @@ mod tests { assert_eq!(field.apply_simple_key("backspace", &no_mods()), Some(true)); assert_eq!(field.value(), "สวัสด"); } + + #[test] + fn ctrl_a_selects_all_off_macos() { + // Ctrl is the menu modifier everywhere except macOS. + let mut field = TextField::new("hi"); + let ctrl = Modifiers { + control: true, + ..no_mods() + }; + field.apply_simple_key("a", &ctrl); + field.apply_simple_key("backspace", &no_mods()); + if cfg!(target_os = "macos") { + assert_eq!(field.value(), "h", "Ctrl+A must not select on macOS"); + } else { + assert_eq!(field.value(), "", "Ctrl+A must select all off macOS"); + } + } } diff --git a/crates/vterm/src/main.rs b/crates/vterm/src/main.rs index 565a3c8..8cb41cf 100644 --- a/crates/vterm/src/main.rs +++ b/crates/vterm/src/main.rs @@ -52,6 +52,10 @@ fn open_window(cx: &mut App) { let app_name = std::env::var("VTERM_APP_NAME").unwrap_or_else(|_| "vterm".to_string()); let app_version = std::env::var("VTERM_DEV_VERSION") .unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_string()); + // On macOS every TitlebarOptions field above is specified, so the struct + // update is needless there — but dropping it breaks other platforms + // where `traffic_light_position` is compiled out. + #[allow(clippy::needless_update)] let options = WindowOptions { titlebar: Some(TitlebarOptions { title: Some(format!("{app_name} v{app_version}").into()), diff --git a/crates/workspace/src/components/settings.rs b/crates/workspace/src/components/settings.rs index 0740262..ed969ed 100644 --- a/crates/workspace/src/components/settings.rs +++ b/crates/workspace/src/components/settings.rs @@ -230,7 +230,7 @@ fn render_terminal(workspace: &Workspace, cx: &mut Context) -> Div { div() .text_size(px(11.0)) .text_color(theme.text_muted) - .child("⌘ + / ⌘ -"), + .child(format!("{} / {}", shortcut_key("+"), shortcut_key("-"))), ), ) .child( @@ -271,14 +271,51 @@ fn render_terminal(workspace: &Workspace, cx: &mut Context) -> Div { .text_color(theme.text_muted) .child("KEYBOARD SHORTCUTS"), ) - .child(shortcut_row("New terminal", "⌘ T", theme)) - .child(shortcut_row("New workspace", "⌘ N", theme)) - .child(shortcut_row("Close terminal", "⌘ W", theme)) - .child(shortcut_row("Reset font size", "⌘ 0", theme)), + .child(shortcut_row("New terminal", shortcut_key("T"), theme)) + .child(shortcut_row("New workspace", shortcut_key("N"), theme)) + .child(shortcut_row( + "Split pane side-by-side", + shortcut_key("D"), + theme, + )) + .child(shortcut_row( + "Split pane stacked", + shortcut_key("Shift+D"), + theme, + )) + .child(shortcut_row( + "Focus next/previous pane", + format!("{} / {}", shortcut_key("]"), shortcut_key("[")), + theme, + )) + .child(shortcut_row( + "Close pane (or tab)", + shortcut_key("W"), + theme, + )) + .child(shortcut_row("Reset font size", shortcut_key("0"), theme)), ) } -fn shortcut_row(label: &'static str, shortcut: &'static str, theme: &Theme) -> Div { +/// Menu-shortcut modifier glyph per platform (mirrors `ui::shortcut_modifier`). +fn mod_glyph() -> &'static str { + if cfg!(target_os = "macos") { + "⌘" + } else { + "Ctrl" + } +} + +/// Renders a shortcut hint like "⌘ T" on macOS or "Ctrl+T" on Windows. +fn shortcut_key(key: &str) -> String { + if cfg!(target_os = "macos") { + format!("{} {key}", mod_glyph()) + } else { + format!("{}+{key}", mod_glyph()) + } +} + +fn shortcut_row(label: &'static str, shortcut: String, theme: &Theme) -> Div { div() .flex() .justify_between() diff --git a/crates/workspace/src/components/tab_bar.rs b/crates/workspace/src/components/tab_bar.rs index 372bb67..8ba6574 100644 --- a/crates/workspace/src/components/tab_bar.rs +++ b/crates/workspace/src/components/tab_bar.rs @@ -61,7 +61,7 @@ pub fn render_tab_bar(workspace: &Workspace, cx: &mut Context) -> imp theme.text_muted }; - let drag_term = term.name.clone(); + let drag_term = term.display_name().to_string(); let drag_theme = theme.clone(); let show_indicator = drop_target == Some(i); let accent = theme.accent; @@ -147,7 +147,7 @@ pub fn render_tab_bar(workspace: &Workspace, cx: &mut Context) -> imp .flex() .gap_2() .items_center() - .child(term.name.clone()) + .child(term.display_name().to_string()) .child( div() .id(("del-tab", i)) diff --git a/crates/workspace/src/components/terminal.rs b/crates/workspace/src/components/terminal.rs index 9747545..353ac7b 100644 --- a/crates/workspace/src/components/terminal.rs +++ b/crates/workspace/src/components/terminal.rs @@ -1,7 +1,9 @@ +use crate::state::{SplitDir, SplitNode}; use crate::workspace::{TERMINAL_FONT, Workspace}; use gpui::prelude::*; use gpui::*; -use terminal::{CellColor, TermCell, palette_rgb}; +use std::collections::HashMap; +use terminal::{CellColor, PtyTerminal, TermCell, palette_rgb}; // Vertical nudge applied to each grid line so glyphs sit centered inside their // cell. Overlays (selection, IME, cursor) must add the same offset or they @@ -372,6 +374,269 @@ pub fn render_terminal_view( cx: &mut Context, viewport: gpui::Size, ) -> impl IntoElement { + let font_size = workspace.state.font_size; + let cell_w = Workspace::terminal_cell_width(window, font_size); + let (avail_w, avail_h) = Workspace::terminal_area_for_window(viewport); + let ws_idx = workspace.state.active_workspace; + let active = workspace + .state + .workspaces + .get(ws_idx) + .and_then(|ws| { + let tab_data = ws.terminals.get(ws.active_term)?; + let tab = workspace.terminals.get(ws_idx)?.get(ws.active_term)?; + Some((tab_data, tab)) + }) + // Guard against transient state/runtime disagreement. + .filter(|(tab_data, tab)| { + !tab_data.panes.is_empty() + && tab.entities.len() == tab_data.panes.len() + && tab_data.leaves().len() == tab_data.panes.len() + }); + + let content: gpui::AnyElement = match active { + Some((tab_data, tab)) if tab.entities.len() > 1 => render_split_tree( + workspace, window, cx, tab_data, tab, avail_w, avail_h, cell_w, + ), + Some((tab_data, tab)) => { + let pane_idx = match &tab_data.root { + SplitNode::Leaf(i) => *i, + _ => tab_data.active_pane, + }; + match tab.entities.get(pane_idx) { + Some(entity) => { + let (rows, cols) = + Workspace::terminal_size_for_window(viewport, font_size, window); + render_pane( + workspace, window, cx, entity, rows, cols, pane_idx, false, false, + ) + .into_any_element() + } + None => empty_pane(), + } + } + None => empty_pane(), + }; + + // Keyboard input is global: keystrokes always go to the focused pane. + let workspace_entity = cx.entity(); + let focus_handle = workspace.focus_handle.clone(); + let settings_open = workspace.settings_open; + div() + .relative() + .w_full() + .h_full() + .bg(workspace.state.theme.bg_main) + .font_family(TERMINAL_FONT) + .text_size(px(font_size)) + .line_height(relative(20.0 / 14.0)) + .overflow_hidden() + .child(content) + .when(!settings_open, move |this| { + this.child( + canvas( + |_bounds, _window, _cx| {}, + move |bounds, _prepaint, window, cx| { + window.handle_input( + &focus_handle, + ElementInputHandler::new(bounds, workspace_entity.clone()), + cx, + ); + }, + ) + .absolute() + .inset_0(), + ) + }) +} + +/// One empty frame for moments with no live terminal (all panes dead/missing). +fn empty_pane() -> gpui::AnyElement { + div().w_full().h_full().into_any_element() +} + +/// Multi-pane tab: lays the split tree out over the content area, one +/// bordered leaf per pane. Ratios here mirror [`SplitNode`] allocation, so +/// PTY sizes computed from the same rects match what is painted. +#[allow(clippy::too_many_arguments)] +fn render_split_tree( + workspace: &Workspace, + window: &gpui::Window, + cx: &mut Context, + tab_data: &crate::state::TabData, + tab: &crate::workspace::TabPanes, + avail_w: f32, + avail_h: f32, + cell_w: f32, +) -> gpui::AnyElement { + let font_size = workspace.state.font_size; + let mut sizes: HashMap = HashMap::new(); + for (pane_idx, _, _, w, h) in tab_data.allocations(avail_w, avail_h) { + // 1px focus border on every side eats 2px of text room. + sizes.insert( + pane_idx, + Workspace::terminal_size_for_area(w - 2.0, h - 2.0, cell_w, font_size), + ); + } + render_split_node(workspace, window, cx, tab, &tab_data.root, &sizes).into_any_element() +} + +fn render_split_node( + workspace: &Workspace, + window: &gpui::Window, + cx: &mut Context, + tab: &crate::workspace::TabPanes, + node: &SplitNode, + sizes: &HashMap, +) -> Div { + match node { + SplitNode::Leaf(pane_idx) => match tab.entities.get(*pane_idx) { + Some(entity) => { + let (rows, cols) = sizes.get(pane_idx).copied().unwrap_or((24, 80)); + let focused = *pane_idx == tab.active_pane; + render_pane( + workspace, window, cx, entity, rows, cols, *pane_idx, focused, true, + ) + } + None => div().w_full().h_full(), + }, + SplitNode::Split { + dir, + ratio, + first, + second, + } => { + let r = ratio.clamp(0.15, 0.85); + let first_el = render_split_node(workspace, window, cx, tab, first, sizes); + let second_el = render_split_node(workspace, window, cx, tab, second, sizes); + match dir { + SplitDir::Vertical => div() + .flex() + .flex_row() + .w_full() + .h_full() + .overflow_hidden() + .child(first_el.w(relative(r)).h_full()) + .child(second_el.w(relative(1.0 - r)).h_full()), + SplitDir::Horizontal => div() + .flex() + .flex_col() + .w_full() + .h_full() + .overflow_hidden() + .child(first_el.h(relative(r)).w_full()) + .child(second_el.h(relative(1.0 - r)).w_full()), + } + } + } +} + +/// One terminal leaf: padded grid, focus ring, and per-pane mouse handling. +/// `bordered` is false for the single-pane fast path (pixel-identical to the +/// pre-split renderer); split leaves always draw the 1px focus ring. +#[allow(clippy::too_many_arguments)] +fn render_pane( + workspace: &Workspace, + window: &gpui::Window, + cx: &mut Context, + term_entity: &Entity, + expected_rows: u16, + expected_cols: u16, + pane_idx: usize, + focused: bool, + bordered: bool, +) -> Div { + let theme = &workspace.state.theme; + let ( + pal, + selection_overlay, + lines_elements, + box_drawing_elements, + block_elements, + scrollbar_element, + ime_composition_overlay, + ) = render_pane_body( + workspace, + window, + cx, + term_entity, + expected_rows, + expected_cols, + // Single-pane tabs always own the selection; in split tabs only the + // focused leaf paints it. + !bordered || focused, + ); + let border_color = if !bordered { + gpui::rgba(0x00000000) + } else if focused { + theme.accent + } else { + theme.border + }; + div() + .relative() + .w_full() + .h_full() + .bg(gpui::rgb(pal.bg)) + .px_4() + .pt_4() + .pb_4() + .when(bordered, |el| el.border_1().border_color(border_color)) + .overflow_hidden() + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |this, event, window, cx| { + this.on_pane_mouse_down(pane_idx, event, window, cx); + }), + ) + .on_mouse_down( + gpui::MouseButton::Right, + cx.listener(move |this, event, window, cx| { + this.on_pane_mouse_down(pane_idx, event, window, cx); + }), + ) + .on_mouse_move(cx.listener(Workspace::on_pane_mouse_move)) + .on_mouse_up( + gpui::MouseButton::Left, + cx.listener(Workspace::on_pane_mouse_up), + ) + .on_mouse_up( + gpui::MouseButton::Right, + cx.listener(Workspace::on_pane_mouse_up), + ) + .on_scroll_wheel(cx.listener(move |this, event, _window, cx| { + this.on_pane_scroll_wheel(pane_idx, event, cx); + })) + .child(selection_overlay) + .children(lines_elements) + .children(block_elements) + .children(box_drawing_elements) + .child(scrollbar_element) + .child(ime_composition_overlay) +} + +/// The grid itself: snapshot → text runs, box-drawing segments, selection and +/// scrollbar overlays. Shared by single-pane and split leaves so rendering +/// can never drift between the two. +fn render_pane_body( + workspace: &Workspace, + window: &gpui::Window, + cx: &mut Context, + term_entity: &Entity, + expected_rows: u16, + expected_cols: u16, + // False for background split panes: the selection belongs to the focused + // pane, and painting it everywhere looks like every pane is selected. + draw_selection: bool, +) -> ( + Palette, + Div, + Vec
, + Vec, + Vec, + Div, + Div, +) { let theme = &workspace.state.theme; let font_size = workspace.state.font_size; let scale_factor = window.scale_factor(); @@ -382,34 +647,25 @@ pub fn render_terminal_view( let mut selection_overlay = div(); let mut scrollbar_element = div(); let mut ime_composition_overlay = div(); - // Fallback for frames with no active terminal: the built-in dark palette. - let mut pal = Palette::from_channels(terminal::TerminalColors::dark().get()); - if let Some(ws) = workspace - .state - .workspaces - .get(workspace.state.active_workspace) - && let Some(term_model) = workspace - .terminals - .get(workspace.state.active_workspace) - .and_then(|t| t.get(ws.active_term)) - { - let term = term_model.read(cx); - let snap = term.snapshot(); - pal = Palette::from_channels(term.colors.get()); + // Snapshot under one lock; the palette always matches what OSC queries + // answer because both read the shared TerminalColors handle. + let (snap, pal) = { + let term = term_entity.read(cx); + (term.snapshot(), Palette::from_channels(term.colors.get())) + }; + { let rows_count = snap.rows; let cols_count = snap.cols; let cell_w = Workspace::terminal_cell_width(window, font_size); let cell_h = font_size * (20.0 / 14.0); - let (expected_rows, expected_cols) = - Workspace::terminal_size_for_window(viewport, font_size, window); if expected_cols != cols_count || expected_rows != rows_count { - let term_model = term_model.clone(); + let term_entity = term_entity.clone(); cx.defer(move |app| { - term_model.update(app, |term, cx| { + term_entity.update(app, |term, cx| { term.resize(expected_rows, expected_cols); cx.notify(); }); @@ -466,7 +722,7 @@ pub fn render_terminal_view( .child(workspace.ime_composition.clone()); } - if let Some(sel) = workspace.selection { + if draw_selection && let Some(sel) = workspace.selection { let ((c1, r1), (c2, r2)) = sel; let min_c = c1.min(c2); let max_c = c1.max(c2); @@ -724,61 +980,15 @@ pub fn render_terminal_view( } } - let workspace_entity = cx.entity(); - let focus_handle = workspace.focus_handle.clone(); - - div() - .relative() - .w_full() - .h_full() - .bg(gpui::rgb(pal.bg)) - .px_4() - .pt_4() - .pb_4() - .font_family(TERMINAL_FONT) - .text_size(px(font_size)) - .line_height(relative(20.0 / 14.0)) - .overflow_hidden() - .on_mouse_down( - gpui::MouseButton::Left, - cx.listener(Workspace::on_terminal_mouse_down), - ) - .on_mouse_down( - gpui::MouseButton::Right, - cx.listener(Workspace::on_terminal_mouse_down), - ) - .on_mouse_move(cx.listener(Workspace::on_terminal_mouse_move)) - .on_mouse_up( - gpui::MouseButton::Left, - cx.listener(Workspace::on_terminal_mouse_up), - ) - .on_mouse_up( - gpui::MouseButton::Right, - cx.listener(Workspace::on_terminal_mouse_up), - ) - .on_scroll_wheel(cx.listener(Workspace::on_terminal_scroll_wheel)) - .child(selection_overlay) - .children(lines_elements) - .children(block_elements) - .children(box_drawing_elements) - .child(scrollbar_element) - .child(ime_composition_overlay) - .when(!workspace.settings_open, move |this| { - this.child( - canvas( - |_bounds, _window, _cx| {}, - move |bounds, _prepaint, window, cx| { - window.handle_input( - &focus_handle, - ElementInputHandler::new(bounds, workspace_entity.clone()), - cx, - ); - }, - ) - .absolute() - .inset_0(), - ) - }) + ( + pal, + selection_overlay, + lines_elements, + box_drawing_elements, + block_elements, + scrollbar_element, + ime_composition_overlay, + ) } #[cfg(test)] diff --git a/crates/workspace/src/state.rs b/crates/workspace/src/state.rs index 1c17f5d..321942f 100644 --- a/crates/workspace/src/state.rs +++ b/crates/workspace/src/state.rs @@ -3,7 +3,7 @@ use std::fs; use std::path::PathBuf; use theme::Theme; -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, Debug)] pub struct TerminalData { pub name: String, #[serde(default)] @@ -12,10 +12,428 @@ pub struct TerminalData { pub session_name: Option, } +/// Split direction for a pane container. +/// - `Vertical` lays panes out left-to-right (a vertical divider). +/// - `Horizontal` stacks panes top-to-bottom (a horizontal divider). +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)] +pub enum SplitDir { + Vertical, + Horizontal, +} + +/// Binary split tree over a tab's panes. Leaves hold indices into +/// [`TabData::panes`]; internal nodes split the available rect by `ratio`. +#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] +pub enum SplitNode { + Leaf(usize), + Split { + dir: SplitDir, + #[serde(default = "default_split_ratio")] + ratio: f32, + first: Box, + second: Box, + }, +} + +fn default_split_ratio() -> f32 { + 0.5 +} + +/// One tab: a set of panes plus the split tree that lays them out. +/// Stored in the `terminals` field of [`WorkspaceData`] (kept field name so +/// pre-split `state.json` files keep loading — see custom `Deserialize`). +#[derive(Serialize, Clone, Debug)] +pub struct TabData { + pub name: String, + #[serde(default)] + pub panes: Vec, + #[serde(default)] + pub active_pane: usize, + #[serde(default)] + pub root: SplitNode, +} + +impl Default for SplitNode { + fn default() -> Self { + SplitNode::Leaf(0) + } +} + +impl<'de> Deserialize<'de> for TabData { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + // New format has a `panes` key; the pre-split format is a bare + // `TerminalData` map ({name, cwd?, session_name?}) — one tab, one pane. + let value = serde_json::Value::deserialize(deserializer)?; + if value.get("panes").is_some() { + let name = value + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Terminal") + .to_string(); + let panes: Vec = value + .get("panes") + .map(|v| serde_json::from_value(v.clone())) + .transpose() + .map_err(serde::de::Error::custom)? + .unwrap_or_default(); + let active_pane = value + .get("active_pane") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as usize; + let root: SplitNode = value + .get("root") + .map(|v| serde_json::from_value(v.clone())) + .transpose() + .map_err(serde::de::Error::custom)? + .unwrap_or(SplitNode::Leaf(0)); + let mut tab = TabData { + name, + panes, + active_pane, + root, + }; + tab.normalize(); + Ok(tab) + } else { + let pane: TerminalData = + serde_json::from_value(value).map_err(serde::de::Error::custom)?; + Ok(TabData::single(pane)) + } + } +} + +impl TabData { + pub fn single(pane: TerminalData) -> Self { + let name = pane.name.clone(); + Self { + name, + panes: vec![pane], + active_pane: 0, + root: SplitNode::Leaf(0), + } + } + + pub fn display_name(&self) -> &str { + if self.name.is_empty() { + self.panes + .first() + .map(|p| p.name.as_str()) + .unwrap_or("Terminal") + } else { + &self.name + } + } + + /// Leaves in left-to-right, top-to-bottom order. + pub fn leaves(&self) -> Vec { + let mut out = Vec::new(); + fn walk(node: &SplitNode, out: &mut Vec) { + match node { + SplitNode::Leaf(i) => out.push(*i), + SplitNode::Split { first, second, .. } => { + walk(first, out); + walk(second, out); + } + } + } + walk(&self.root, &mut out); + out + } + + /// Repairs a corrupt tree (dangling/duplicate leaves) while keeping a + /// valid persisted tree — orientations and ratios — exactly as saved. + pub fn normalize(&mut self) { + if self.panes.is_empty() { + self.panes.push(TerminalData { + name: if self.name.is_empty() { + "Terminal".to_string() + } else { + self.name.clone() + }, + cwd: None, + session_name: None, + }); + } + if covers_exactly_once(&self.root, self.panes.len()) { + // Valid tree: only clamp wild ratios from hand-edited state. + self.root = clamp_ratios(std::mem::replace(&mut self.root, SplitNode::Leaf(0))); + } else { + // Keep only leaves pointing at live panes, preserving order. + let mut seen = vec![false; self.panes.len()]; + let mut ordered = Vec::new(); + for leaf in self.leaves() { + if leaf < self.panes.len() && !seen[leaf] { + seen[leaf] = true; + ordered.push(leaf); + } + } + for (i, used) in seen.iter().enumerate() { + if !used { + ordered.push(i); + } + } + self.root = build_balanced(&ordered, 0); + } + self.active_pane = self.active_pane.min(self.panes.len().saturating_sub(1)); + if !self.leaves().contains(&self.active_pane) { + self.active_pane = self.leaves()[0]; + } + } + + /// Splits `pane_idx`, appending the new pane and returning its index. + pub fn split_pane(&mut self, pane_idx: usize, dir: SplitDir) -> usize { + let pane_idx = pane_idx.min(self.panes.len().saturating_sub(1)); + let base = self.panes[pane_idx].clone(); + let new_idx = self.panes.len(); + self.panes.push(TerminalData { + name: format!("{} {}", base.name, new_idx + 1), + cwd: base.cwd.clone(), + session_name: None, + }); + self.root = replace_leaf( + std::mem::replace(&mut self.root, SplitNode::Leaf(0)), + pane_idx, + SplitNode::Split { + dir, + ratio: 0.5, + first: Box::new(SplitNode::Leaf(pane_idx)), + second: Box::new(SplitNode::Leaf(new_idx)), + }, + ); + self.active_pane = new_idx; + new_idx + } + + /// Closes `pane_idx`. Returns false when it was the last pane. + pub fn close_pane(&mut self, pane_idx: usize) -> bool { + if self.panes.len() <= 1 { + return false; + } + let pane_idx = pane_idx.min(self.panes.len() - 1); + self.panes.remove(pane_idx); + let renumber = |i: usize| if i > pane_idx { i - 1 } else { i }; + self.root = remove_leaf( + std::mem::replace(&mut self.root, SplitNode::Leaf(0)), + pane_idx, + ) + .map(|root| renumber_tree(root, &renumber)) + .unwrap_or(SplitNode::Leaf(0)); + self.normalize(); + if self.active_pane >= self.panes.len() { + self.active_pane = self.panes.len() - 1; + } + true + } + + /// Lays the split tree out over a `avail_w × avail_h` box, returning one + /// `(pane_idx, x, y, w, h)` rect per leaf in render order. Pure geometry + /// shared by the renderer and mouse→cell mapping so they never disagree. + pub fn allocations(&self, avail_w: f32, avail_h: f32) -> Vec<(usize, f32, f32, f32, f32)> { + let mut out = Vec::new(); + alloc_node(&self.root, 0.0, 0.0, avail_w, avail_h, &mut out); + out + } +} + +fn build_balanced(order: &[usize], depth: usize) -> SplitNode { + match order { + [] => SplitNode::Leaf(0), + [single] => SplitNode::Leaf(*single), + _ => { + let mid = order.len() / 2; + let dir = if depth.is_multiple_of(2) { + SplitDir::Vertical + } else { + SplitDir::Horizontal + }; + SplitNode::Split { + dir, + ratio: 0.5, + first: Box::new(build_balanced(&order[..mid], depth + 1)), + second: Box::new(build_balanced(&order[mid..], depth + 1)), + } + } + } +} + +fn replace_leaf(node: SplitNode, target: usize, with: SplitNode) -> SplitNode { + match node { + SplitNode::Leaf(i) if i == target => with, + SplitNode::Leaf(i) => SplitNode::Leaf(i), + SplitNode::Split { + dir, + ratio, + first, + second, + } => SplitNode::Split { + dir, + ratio: ratio.clamp(0.15, 0.85), + first: Box::new(replace_leaf(*first, target, with.clone())), + second: Box::new(replace_leaf(*second, target, with)), + }, + } +} + +/// Removes the leaf holding `target`, promoting its sibling. Returns `None` +/// when the tree was a single leaf. +fn remove_leaf(node: SplitNode, target: usize) -> Option { + match node { + SplitNode::Leaf(i) if i == target => None, + SplitNode::Leaf(i) => Some(SplitNode::Leaf(i)), + SplitNode::Split { + dir, + ratio, + first, + second, + } => { + let first_has = contains_leaf(&first, target); + let second_has = contains_leaf(&second, target); + match (first_has, second_has) { + (true, true) => { + // Shouldn't happen (leaf twice), drop from the left side. + match remove_leaf(*first, target) { + Some(rest) => Some(SplitNode::Split { + dir, + ratio, + first: Box::new(rest), + second, + }), + None => Some(*second), + } + } + (true, false) => match remove_leaf(*first, target) { + Some(rest) => Some(SplitNode::Split { + dir, + ratio, + first: Box::new(rest), + second, + }), + None => Some(*second), + }, + (false, true) => match remove_leaf(*second, target) { + Some(rest) => Some(SplitNode::Split { + dir, + ratio, + first, + second: Box::new(rest), + }), + None => Some(*first), + }, + (false, false) => Some(SplitNode::Split { + dir, + ratio, + first, + second, + }), + } + } + } +} + +fn contains_leaf(node: &SplitNode, target: usize) -> bool { + match node { + SplitNode::Leaf(i) => *i == target, + SplitNode::Split { first, second, .. } => { + contains_leaf(first, target) || contains_leaf(second, target) + } + } +} + +fn renumber_tree(node: SplitNode, renumber: &dyn Fn(usize) -> usize) -> SplitNode { + match node { + SplitNode::Leaf(i) => SplitNode::Leaf(renumber(i)), + SplitNode::Split { + dir, + ratio, + first, + second, + } => SplitNode::Split { + dir, + ratio, + first: Box::new(renumber_tree(*first, renumber)), + second: Box::new(renumber_tree(*second, renumber)), + }, + } +} + +/// True when the tree references every pane index exactly once — the +/// condition under which `normalize` keeps the persisted tree untouched. +fn covers_exactly_once(node: &SplitNode, pane_count: usize) -> bool { + fn walk(node: &SplitNode, seen: &mut [bool]) -> bool { + match node { + SplitNode::Leaf(i) => { + if *i >= seen.len() || seen[*i] { + return false; + } + seen[*i] = true; + true + } + SplitNode::Split { first, second, .. } => walk(first, seen) && walk(second, seen), + } + } + pane_count > 0 && { + let mut seen = vec![false; pane_count]; + walk(node, &mut seen) && seen.into_iter().all(|used| used) + } +} + +fn clamp_ratios(node: SplitNode) -> SplitNode { + match node { + SplitNode::Leaf(i) => SplitNode::Leaf(i), + SplitNode::Split { + dir, + ratio, + first, + second, + } => SplitNode::Split { + dir, + ratio: ratio.clamp(0.15, 0.85), + first: Box::new(clamp_ratios(*first)), + second: Box::new(clamp_ratios(*second)), + }, + } +} + +fn alloc_node( + node: &SplitNode, + x: f32, + y: f32, + w: f32, + h: f32, + out: &mut Vec<(usize, f32, f32, f32, f32)>, +) { + match node { + SplitNode::Leaf(i) => out.push((*i, x, y, w, h)), + SplitNode::Split { + dir, + ratio, + first, + second, + } => { + let r = ratio.clamp(0.15, 0.85); + match dir { + SplitDir::Vertical => { + alloc_node(first, x, y, w * r, h, out); + alloc_node(second, x + w * r, y, w * (1.0 - r), h, out); + } + SplitDir::Horizontal => { + alloc_node(first, x, y, w, h * r, out); + alloc_node(second, x, y + h * r, w, h * (1.0 - r), out); + } + } + } + } +} + #[derive(Serialize, Deserialize, Clone)] pub struct WorkspaceData { pub name: String, - pub terminals: Vec, + /// Tabs (kept serialized name for pre-split `state.json` compat: each + /// element used to be a bare `TerminalData`, now a `TabData`). + pub terminals: Vec, pub active_term: usize, } @@ -56,11 +474,11 @@ impl AppState { Self { workspaces: vec![WorkspaceData { name: name.clone(), - terminals: vec![TerminalData { + terminals: vec![TabData::single(TerminalData { name: name.clone(), cwd: None, session_name: None, - }], + })], active_term: 0, }], active_workspace: 0, @@ -97,21 +515,24 @@ impl AppState { let name = Self::default_dir_name(); state.workspaces.push(WorkspaceData { name: name.clone(), - terminals: vec![TerminalData { + terminals: vec![TabData::single(TerminalData { name: name.clone(), cwd: None, session_name: None, - }], + })], active_term: 0, }); } for workspace in &mut state.workspaces { if workspace.terminals.is_empty() { - workspace.terminals.push(TerminalData { + workspace.terminals.push(TabData::single(TerminalData { name: workspace.name.clone(), cwd: None, session_name: None, - }); + })); + } + for tab in &mut workspace.terminals { + tab.normalize(); } workspace.active_term = workspace .active_term @@ -133,11 +554,11 @@ mod tests { let state = AppState { workspaces: vec![WorkspaceData { name: "Workspace".into(), - terminals: vec![TerminalData { + terminals: vec![TabData::single(TerminalData { name: "Terminal".into(), cwd: None, session_name: None, - }], + })], active_term: 0, }], active_workspace: 0, @@ -148,6 +569,109 @@ mod tests { assert_eq!(state.workspaces.len(), 1); assert_eq!(state.workspaces[0].name, "Workspace"); assert_eq!(state.workspaces[0].terminals.len(), 1); - assert_eq!(state.workspaces[0].terminals[0].name, "Terminal"); + assert_eq!(state.workspaces[0].terminals[0].panes[0].name, "Terminal"); + } + + fn pane(name: &str) -> TerminalData { + TerminalData { + name: name.to_string(), + cwd: None, + session_name: None, + } + } + + #[test] + fn old_single_pane_json_loads_as_one_tab() { + // Pre-split state.json stored tabs as bare TerminalData maps. + let json = r#"{"name":"Terminal","cwd":"/tmp","session_name":null}"#; + let tab: TabData = serde_json::from_str(json).unwrap(); + assert_eq!(tab.panes.len(), 1); + assert_eq!(tab.panes[0].cwd.as_deref(), Some("/tmp")); + assert_eq!(tab.root, SplitNode::Leaf(0)); + } + + #[test] + fn split_then_close_round_trips() { + let mut tab = TabData::single(pane("Term")); + let second = tab.split_pane(0, SplitDir::Vertical); + assert_eq!(second, 1); + assert_eq!(tab.panes.len(), 2); + assert_eq!(tab.leaves(), vec![0, 1]); + assert!(matches!( + tab.root, + SplitNode::Split { + dir: SplitDir::Vertical, + .. + } + )); + + let third = tab.split_pane(1, SplitDir::Horizontal); + assert_eq!(tab.panes.len(), 3); + assert_eq!(tab.leaves().len(), 3); + + assert!(tab.close_pane(third)); + assert_eq!(tab.panes.len(), 2); + assert_eq!(tab.leaves(), vec![0, 1]); + assert!(tab.close_pane(1)); + assert_eq!(tab.root, SplitNode::Leaf(0)); + assert!(!tab.close_pane(0), "last pane must not close"); + } + + #[test] + fn normalize_repairs_dangling_trees() { + let mut tab = TabData { + name: "T".into(), + panes: vec![pane("a")], + active_pane: 7, + root: SplitNode::Leaf(9), + }; + tab.normalize(); + assert_eq!(tab.root, SplitNode::Leaf(0)); + assert_eq!(tab.active_pane, 0); + } + + #[test] + fn allocations_tile_without_gaps_or_overlap() { + let mut tab = TabData::single(pane("a")); + tab.split_pane(0, SplitDir::Vertical); + tab.split_pane(0, SplitDir::Horizontal); + let rects = tab.allocations(900.0, 600.0); + assert_eq!(rects.len(), 3); + // Area is conserved. + let area: f32 = rects.iter().map(|(_, _, _, w, h)| w * h).sum(); + assert!((area - 900.0 * 600.0).abs() < 1.0); + // First-level vertical split at 50%: left stack is 450 wide. + let left: Vec<_> = rects.iter().filter(|(_, x, _, _, _)| *x < 450.0).collect(); + assert_eq!(left.len(), 2); + } + + #[test] + fn normalize_keeps_valid_custom_layout() { + let mut tab = TabData::single(pane("a")); + tab.split_pane(0, SplitDir::Vertical); + // Customize orientation and ratio away from the balanced defaults. + if let SplitNode::Split { dir, ratio, .. } = &mut tab.root { + *dir = SplitDir::Horizontal; + *ratio = 0.3; + } else { + panic!("expected a split root"); + } + // Serde round-trip (what AppState::load does) must not rescramble it. + let json = serde_json::to_string(&tab).unwrap(); + let reloaded: TabData = serde_json::from_str(&json).unwrap(); + assert!( + matches!( + reloaded.root, + SplitNode::Split { + dir: SplitDir::Horizontal, + .. + } + ), + "orientation lost: {:?}", + reloaded.root + ); + if let SplitNode::Split { ratio, .. } = reloaded.root { + assert!((ratio - 0.3).abs() < f32::EPSILON, "ratio lost: {ratio}"); + } } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index f362cdd..8ff8821 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -3,7 +3,7 @@ use crate::components::sidebar::render_sidebar; use crate::components::tab_bar::render_tab_bar; use crate::components::terminal::render_terminal_view; use crate::components::title_bar::render_title_bar; -use crate::state::AppState; +use crate::state::{AppState, SplitDir, SplitNode, TabData}; use crate::{git_command, process_cwd, remove_staged_path}; use gpui::prelude::*; use gpui::*; @@ -29,10 +29,20 @@ const TITLE_BAR_HEIGHT: f32 = 32.0; const TAB_BAR_HEIGHT: f32 = 32.0; const PANE_PAD: f32 = 16.0; +/// Runtime panes for one tab: one PTY entity per persisted pane plus the +/// split tree and focus state (mirrors [`crate::state::TabData`]). +pub struct TabPanes { + pub entities: Vec>, + pub active_pane: usize, + pub root: SplitNode, + pub name: String, +} + pub struct Workspace { pub state: AppState, pub focus_handle: gpui::FocusHandle, - pub terminals: Vec>>, + /// `terminals[ws][tab]` parallels `state.workspaces[ws].terminals[tab]`. + pub terminals: Vec>, pub tab_context_menu: Option<(usize, gpui::Point)>, pub tab_drop_target: Option, pub dir_drop_target: Option, @@ -77,10 +87,12 @@ impl Workspace { pub fn get_active_terminal_cwd(&self, cx: &mut Context) -> Option { let ws_idx = self.state.active_workspace; let ws = self.state.workspaces.get(ws_idx)?; - let term_idx = ws.active_term; - let term_entity = self.terminals.get(ws_idx)?.get(term_idx)?; + let tab = ws.terminals.get(ws.active_term)?; + let tab_panes = self.terminals.get(ws_idx)?.get(ws.active_term)?; + let pane_idx = tab_panes.active_pane; + let term_entity = tab_panes.entities.get(pane_idx)?; let pid = term_entity.read(cx).child_pid?; - process_cwd(pid).or_else(|| ws.terminals.get(term_idx).and_then(|t| t.cwd.clone())) + process_cwd(pid).or_else(|| tab.panes.get(pane_idx).and_then(|t| t.cwd.clone())) } pub fn new(window: &mut Window, cx: &mut Context) -> Self { @@ -89,22 +101,33 @@ impl Workspace { Self::terminal_size_for_window(window.viewport_size(), state.font_size, window); let mut state_changed = false; let terminal_colors = theme_terminal_colors(&state.theme); - let mut terminals = Vec::new(); + let mut terminals: Vec> = Vec::new(); for ws in &mut state.workspaces { let mut ws_terms = Vec::new(); - for term_data in &mut ws.terminals { - let cwd = term_data.cwd.clone(); - let session_name = term_data.session_name.clone(); - let colors = terminal_colors.clone(); - let term = cx - .new(|cx| PtyTerminal::new_with_cwd(cwd, session_name, rows, cols, colors, cx)); - let actual_session = term.read(cx).session_name.clone(); - if term_data.session_name != actual_session { - term_data.session_name = actual_session; - state_changed = true; + for tab_data in &mut ws.terminals { + tab_data.normalize(); + let mut entities = Vec::new(); + for pane_data in &mut tab_data.panes { + let cwd = pane_data.cwd.clone(); + let session_name = pane_data.session_name.clone(); + let colors = terminal_colors.clone(); + let term = cx.new(|cx| { + PtyTerminal::new_with_cwd(cwd, session_name, rows, cols, colors, cx) + }); + let actual_session = term.read(cx).session_name.clone(); + if pane_data.session_name != actual_session { + pane_data.session_name = actual_session; + state_changed = true; + } + cx.observe(&term, |_, _, cx| cx.notify()).detach(); + entities.push(term); } - cx.observe(&term, |_, _, cx| cx.notify()).detach(); - ws_terms.push(term); + ws_terms.push(TabPanes { + entities, + active_pane: tab_data.active_pane, + root: tab_data.root.clone(), + name: tab_data.name.clone(), + }); } terminals.push(ws_terms); } @@ -183,14 +206,8 @@ impl Workspace { font_size: f32, cell_w: f32, ) -> (u16, u16) { - let cell_h = font_size * (20.0 / 14.0); - let cols = ((f32::from(viewport.width) - SIDEBAR_WIDTH - PANE_PAD * 2.0) / cell_w).max(10.0) - as u16; - let rows = - ((f32::from(viewport.height) - TITLE_BAR_HEIGHT - TAB_BAR_HEIGHT - PANE_PAD * 2.0) - / cell_h) - .max(10.0) as u16; - (rows, cols) + let (avail_w, avail_h) = Self::terminal_area_for_window(viewport); + Self::terminal_size_for_area(avail_w, avail_h, cell_w, font_size) } fn poll_git_branch(&mut self, cx: &mut Context) { @@ -199,13 +216,21 @@ impl Workspace { let ws_idx = self.state.active_workspace; if let Some(ws) = self.state.workspaces.get(ws_idx) { let term_idx = ws.active_term; - if let Some(term_entity) = self + if let Some(tab) = self .terminals .get(ws_idx) .and_then(|terms| terms.get(term_idx)) { - active_pid = term_entity.read(cx).child_pid; - fallback_cwd = ws.terminals.get(term_idx).and_then(|t| t.cwd.clone()); + let pane_idx = tab.active_pane; + active_pid = tab + .entities + .get(pane_idx) + .and_then(|e| e.read(cx).child_pid); + fallback_cwd = ws + .terminals + .get(term_idx) + .and_then(|t| t.panes.get(pane_idx)) + .and_then(|t| t.cwd.clone()); } } @@ -351,21 +376,28 @@ impl Workspace { fn snapshot_session(&mut self, cx: &mut Context) -> Result<(), String> { for (ws_idx, ws) in self.state.workspaces.iter_mut().enumerate() { - for (term_idx, term_data) in ws.terminals.iter_mut().enumerate() { - let Some(term) = self + for (tab_idx, tab_data) in ws.terminals.iter_mut().enumerate() { + let Some(tab) = self .terminals .get(ws_idx) - .and_then(|terms| terms.get(term_idx)) + .and_then(|terms| terms.get(tab_idx)) else { continue; }; - let terminal = term.read(cx); - if let Some(pid) = terminal.child_pid { - term_data.cwd = process_cwd(pid).or_else(|| term_data.cwd.clone()); - } - if term_data.session_name != terminal.session_name { - term_data.session_name = terminal.session_name.clone(); + for (pane_idx, pane_data) in tab_data.panes.iter_mut().enumerate() { + let Some(entity) = tab.entities.get(pane_idx) else { + continue; + }; + let terminal = entity.read(cx); + if let Some(pid) = terminal.child_pid { + pane_data.cwd = process_cwd(pid).or_else(|| pane_data.cwd.clone()); + } + if pane_data.session_name != terminal.session_name { + pane_data.session_name = terminal.session_name.clone(); + } } + tab_data.active_pane = tab.active_pane; + tab_data.root = tab.root.clone(); } } self.state.save().map_err(|error| error.to_string()) @@ -533,6 +565,14 @@ impl Workspace { cx.notify(); } + /// The PTY entity under the active pane of the active tab. + fn active_term_entity(&self) -> Option> { + let ws_idx = self.state.active_workspace; + let ws = self.state.workspaces.get(ws_idx)?; + let tab = self.terminals.get(ws_idx)?.get(ws.active_term)?; + tab.entities.get(tab.active_pane).cloned() + } + pub fn add_dir(&mut self, window: &mut Window, cx: &mut Context) { let name = "Workspace".to_string(); @@ -545,29 +585,36 @@ impl Workspace { let session_name = term.read(cx).session_name.clone(); let new_ws = crate::state::WorkspaceData { name: format!("{} {}", name, self.state.workspaces.len() + 1), - terminals: vec![crate::state::TerminalData { + terminals: vec![TabData::single(crate::state::TerminalData { name, cwd: cwd.clone(), session_name, - }], + })], active_term: 0, }; self.state.workspaces.push(new_ws); self.state.active_workspace = self.state.workspaces.len() - 1; cx.observe(&term, |_, _, cx| cx.notify()).detach(); - self.terminals.push(vec![term]); + self.terminals.push(vec![TabPanes { + entities: vec![term], + active_pane: 0, + root: SplitNode::Leaf(0), + name: String::new(), + }]); self.state.save().ok(); cx.notify(); } pub fn close_tab(&mut self, ws_idx: usize, tab_idx: usize, cx: &mut Context) { - if let Some(term) = self + if let Some(tab) = self .terminals .get(ws_idx) .and_then(|terms| terms.get(tab_idx)) { - term.update(cx, |term, _| term.shutdown()); + for entity in &tab.entities { + entity.update(cx, |term, _| term.shutdown()); + } } if let Some(ws) = self.state.workspaces.get_mut(ws_idx) { if ws.terminals.len() > 1 { @@ -602,32 +649,89 @@ impl Workspace { } fn active_screen_size(&self, cx: &App) -> (u16, u16) { - let ws_idx = self.state.active_workspace; - if let Some(ws) = self.state.workspaces.get(ws_idx) - && let Some(term) = self - .terminals - .get(ws_idx) - .and_then(|t| t.get(ws.active_term)) - { + if let Some(term) = self.active_term_entity() { return term.read(cx).size(); } (24, 80) } fn active_cursor(&self, cx: &App) -> Option<(u16, u16)> { + let term = self.active_term_entity()?; + term.read(cx).cursor_position() + } + + /// Available terminal area (px) after sidebar/title/tab chrome. + pub(crate) fn terminal_area_for_window(viewport: Size) -> (f32, f32) { + let w = (f32::from(viewport.width) - SIDEBAR_WIDTH).max(10.0); + let h = (f32::from(viewport.height) - TITLE_BAR_HEIGHT - TAB_BAR_HEIGHT).max(10.0); + (w, h) + } + + /// Grid size for an explicit pixel area. The full-window path feeds the + /// whole content area; split panes feed their own rect share. + pub(crate) fn terminal_size_for_area( + avail_w: f32, + avail_h: f32, + cell_w: f32, + font_size: f32, + ) -> (u16, u16) { + let cell_h = font_size * (20.0 / 14.0); + let cols = ((avail_w - PANE_PAD * 2.0) / cell_w).max(10.0) as u16; + let rows = ((avail_h - PANE_PAD * 2.0) / cell_h).max(10.0) as u16; + (rows, cols) + } + + /// Text-area origin (px, window coords) of a pane in the active tab: + /// content origin + split-tree allocation + per-leaf padding. + pub(crate) fn pane_text_origin(&self, window: &Window, pane_idx: usize) -> Option<(f32, f32)> { + let (avail_w, avail_h) = Self::terminal_area_for_window(window.viewport_size()); + let ws = self.state.workspaces.get(self.state.active_workspace)?; + let tab = ws.terminals.get(ws.active_term)?; + let (_, x, y, _, _) = tab + .allocations(avail_w, avail_h) + .into_iter() + .find(|(i, _, _, _, _)| *i == pane_idx)?; + Some(( + SIDEBAR_WIDTH + x + PANE_PAD, + TITLE_BAR_HEIGHT + TAB_BAR_HEIGHT + y + PANE_PAD, + )) + } + + /// Focused pane index of the active tab. + fn focused_pane_idx(&self) -> usize { let ws_idx = self.state.active_workspace; - let ws = self.state.workspaces.get(ws_idx)?; - let term = self.terminals.get(ws_idx)?.get(ws.active_term)?.read(cx); - term.cursor_position() + self.terminals + .get(ws_idx) + .and_then(|tabs| { + self.state + .workspaces + .get(ws_idx) + .and_then(|ws| tabs.get(ws.active_term)) + }) + .map(|tab| tab.active_pane) + .unwrap_or(0) } pub fn cell_at(&self, pos: gpui::Point, cx: &App) -> (u16, u16) { + self.cell_at_with_origin( + pos, + SIDEBAR_WIDTH + PANE_PAD, + TITLE_BAR_HEIGHT + TAB_BAR_HEIGHT + PANE_PAD, + cx, + ) + } + + pub(crate) fn cell_at_with_origin( + &self, + pos: gpui::Point, + origin_x: f32, + origin_y: f32, + cx: &App, + ) -> (u16, u16) { let font_size = self.state.font_size; let cell_w = Self::terminal_cell_width_from_app(cx, font_size); let cell_h = font_size * (20.0 / 14.0); let (rows, cols) = self.active_screen_size(cx); - let origin_x = SIDEBAR_WIDTH + PANE_PAD; - let origin_y = TITLE_BAR_HEIGHT + TAB_BAR_HEIGHT + PANE_PAD; let col = (((f32::from(pos.x) - origin_x) / cell_w).floor()).clamp(0.0, (cols as f32) - 1.0) as u16; let row = (((f32::from(pos.y) - origin_y) / cell_h).floor()).clamp(0.0, (rows as f32) - 1.0) @@ -637,10 +741,8 @@ impl Workspace { pub fn selected_text(&self, cx: &App) -> Option { let (start, end) = self.selection?; - let ws_idx = self.state.active_workspace; - let ws = self.state.workspaces.get(ws_idx)?; - let term = self.terminals.get(ws_idx)?.get(ws.active_term)?.read(cx); - Some(term.text_in_range(start, end)) + let term = self.active_term_entity()?; + Some(term.read(cx).text_in_range(start, end)) } pub fn copy_selection(&mut self, cx: &mut Context) { @@ -671,25 +773,13 @@ impl Workspace { } pub fn write_active(&mut self, bytes: &[u8], cx: &mut App) { - let ws_idx = self.state.active_workspace; - if let Some(ws) = self.state.workspaces.get(ws_idx) - && let Some(term) = self - .terminals - .get(ws_idx) - .and_then(|t| t.get(ws.active_term)) - { + if let Some(term) = self.active_term_entity() { term.update(cx, |term, _| term.write(bytes)); } } fn write_active_text(&mut self, text: &str, cx: &mut App) { - let ws_idx = self.state.active_workspace; - if let Some(ws) = self.state.workspaces.get(ws_idx) - && let Some(term) = self - .terminals - .get(ws_idx) - .and_then(|t| t.get(ws.active_term)) - { + if let Some(term) = self.active_term_entity() { term.update(cx, |term, _| term.write_text(text)); } } @@ -702,40 +792,49 @@ impl Workspace { } } - pub fn on_terminal_mouse_down( + /// Mouse handlers are bound per split pane. Down focuses the clicked pane + /// first, then starts selection in it; move/up operate on the focused + /// pane so drags crossing pane boundaries stay coherent. + pub fn on_pane_mouse_down( &mut self, + pane_idx: usize, event: &gpui::MouseDownEvent, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) { + self.focus_pane(pane_idx, cx); if event.button == gpui::MouseButton::Right { self.paste_clipboard(cx); return; } - if event.button == gpui::MouseButton::Left { - let cell = self.cell_at(event.position, cx); + if event.button == gpui::MouseButton::Left + && let Some((origin_x, origin_y)) = self.pane_text_origin(window, pane_idx) + { + let cell = self.cell_at_with_origin(event.position, origin_x, origin_y, cx); self.selecting = true; self.selection = Some((cell, cell)); cx.notify(); } } - pub fn on_terminal_mouse_move( + pub fn on_pane_mouse_move( &mut self, event: &gpui::MouseMoveEvent, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) { if self.selecting && let Some((start, _)) = self.selection + && let Some((origin_x, origin_y)) = + self.pane_text_origin(window, self.focused_pane_idx()) { - let cell = self.cell_at(event.position, cx); + let cell = self.cell_at_with_origin(event.position, origin_x, origin_y, cx); self.selection = Some((start, cell)); cx.notify(); } } - pub fn on_terminal_mouse_up( + pub fn on_pane_mouse_up( &mut self, _event: &gpui::MouseUpEvent, _window: &mut Window, @@ -752,19 +851,20 @@ impl Workspace { cx.notify(); } - pub fn on_terminal_scroll_wheel( + /// Scrolls the pane under the cursor (each leaf binds its own index). + /// Font zoom stays global — size is app-wide, not per pane. + pub fn on_pane_scroll_wheel( &mut self, + pane_idx: usize, event: &gpui::ScrollWheelEvent, - _window: &mut Window, cx: &mut Context, ) { - if event.modifiers.platform { + if ui::shortcut_modifier(&event.modifiers) { let delta = f32::from(event.delta.pixel_delta(px(16.0)).y); self.adjust_font_size(delta * 0.1, cx); return; } - let ws_idx = self.state.active_workspace; let font_size = self.state.font_size; let cell_h = font_size * (20.0 / 14.0); let delta_pixels = event.delta.pixel_delta(px(font_size)).y; @@ -772,12 +872,15 @@ impl Workspace { // time rather than accumulating many tiny sub-line deltas. let delta_lines = f32::from(delta_pixels) / cell_h * 3.0; - if let Some(ws) = self.state.workspaces.get(ws_idx) - && let Some(term_entity) = self - .terminals - .get(ws_idx) - .and_then(|terms| terms.get(ws.active_term)) - { + let ws_idx = self.state.active_workspace; + let entity = self.state.workspaces.get(ws_idx).and_then(|ws| { + self.terminals + .get(ws_idx)? + .get(ws.active_term)? + .entities + .get(pane_idx) + }); + if let Some(term_entity) = entity { term_entity.update(cx, |term, _| { term.scroll(delta_lines); }); @@ -881,7 +984,7 @@ impl Workspace { return; } - if event.keystroke.modifiers.platform { + if ui::shortcut_modifier(&event.keystroke.modifiers) { match event.keystroke.key.as_str() { "=" | "+" => { self.adjust_font_size(1.0, cx); @@ -898,9 +1001,21 @@ impl Workspace { return; } "c" => { - self.copy_selection(cx); - cx.stop_propagation(); - return; + // Copy when something is selected. Ctrl+C with no + // selection falls through to the shell as SIGINT (0x03); + // Cmd+C on macOS is swallowed as before. + let has_text = self + .selected_text(cx) + .is_some_and(|text| !text.trim().is_empty()); + if has_text { + self.copy_selection(cx); + cx.stop_propagation(); + return; + } + if !event.keystroke.modifiers.control { + cx.stop_propagation(); + return; + } } "a" => { // Select the entire visible screen, like Cmd+A in other @@ -934,9 +1049,32 @@ impl Workspace { return; } "w" => { - if let Some(ws) = self.state.workspaces.get(ws_idx) { - self.delete_term(ws.active_term, cx); - } + self.close_active_pane_or_tab(cx); + cx.stop_propagation(); + return; + } + "d" | "D" => { + // Split side-by-side, or stacked with Shift. Shift may + // arrive folded into the key as "D" without the flag, so + // either signal selects the stacked direction. + let shifted = + event.keystroke.modifiers.shift || event.keystroke.key.as_str() == "D"; + let dir = if shifted { + SplitDir::Horizontal + } else { + SplitDir::Vertical + }; + self.split_active_pane(dir, window, cx); + cx.stop_propagation(); + return; + } + "[" => { + self.cycle_active_pane(-1, cx); + cx.stop_propagation(); + return; + } + "]" => { + self.cycle_active_pane(1, cx); cx.stop_propagation(); return; } @@ -944,46 +1082,39 @@ impl Workspace { } } - if let Some(ws) = self.state.workspaces.get(ws_idx) { - let term_idx = ws.active_term; - if let Some(term_entity) = self - .terminals - .get(ws_idx) - .and_then(|terms| terms.get(term_idx)) - { - let key = event.keystroke.key.as_str(); - let modifiers = &event.keystroke.modifiers; - - let bytes = match key { - "enter" => vec![b'\r'], - "backspace" => vec![0x7f], - "tab" => vec![b'\t'], - "escape" => vec![0x1b], - "up" => vec![0x1b, b'[', b'A'], - "down" => vec![0x1b, b'[', b'B'], - "right" => vec![0x1b, b'[', b'C'], - "left" => vec![0x1b, b'[', b'D'], - "space" => vec![b' '], - _ if modifiers.control && key.chars().count() == 1 => { - let c = key.chars().next().unwrap(); - if c.is_ascii_lowercase() { - vec![(c as u8) - b'a' + 1] - } else { - vec![] - } + if let Some(term_entity) = self.active_term_entity() { + let key = event.keystroke.key.as_str(); + let modifiers = &event.keystroke.modifiers; + + let bytes = match key { + "enter" => vec![b'\r'], + "backspace" => vec![0x7f], + "tab" => vec![b'\t'], + "escape" => vec![0x1b], + "up" => vec![0x1b, b'[', b'A'], + "down" => vec![0x1b, b'[', b'B'], + "right" => vec![0x1b, b'[', b'C'], + "left" => vec![0x1b, b'[', b'D'], + "space" => vec![b' '], + _ if modifiers.control && !modifiers.alt && key.chars().count() == 1 => { + let c = key.chars().next().unwrap(); + if c.is_ascii_lowercase() { + vec![(c as u8) - b'a' + 1] + } else { + vec![] } - _ => vec![], - }; - - if !bytes.is_empty() { - term_entity.update(cx, |term, _| { - term.write(&bytes); - }); - // Prevent GPUI's tab focus-traversal (and other default - // key handling) from swallowing keys like Tab/arrows so - // they reach the shell. - cx.stop_propagation(); } + _ => vec![], + }; + + if !bytes.is_empty() { + term_entity.update(cx, |term, _| { + term.write(&bytes); + }); + // Prevent GPUI's tab focus-traversal (and other default + // key handling) from swallowing keys like Tab/arrows so + // they reach the shell. + cx.stop_propagation(); } } } @@ -1028,9 +1159,11 @@ impl Workspace { pub fn delete_dir(&mut self, idx: usize, cx: &mut Context) { if self.state.workspaces.len() > 1 { - if let Some(terms) = self.terminals.get(idx) { - for term in terms { - term.update(cx, |term, _| term.shutdown()); + if let Some(tabs) = self.terminals.get(idx) { + for tab in tabs { + for term in &tab.entities { + term.update(cx, |term, _| term.shutdown()); + } } } self.state.workspaces.remove(idx); @@ -1109,18 +1242,217 @@ impl Workspace { let session_name = term.read(cx).session_name.clone(); if let Some(ws) = self.state.workspaces.get_mut(ws_idx) { - ws.terminals.push(crate::state::TerminalData { + ws.terminals + .push(TabData::single(crate::state::TerminalData { + name: name.clone(), + cwd, + session_name, + })); + ws.active_term = ws.terminals.len() - 1; + self.terminals[ws_idx].push(TabPanes { + entities: vec![term], + active_pane: 0, + root: SplitNode::Leaf(0), name, - cwd, - session_name, }); - ws.active_term = ws.terminals.len() - 1; - self.terminals[ws_idx].push(term); self.state.save().ok(); cx.notify(); } } + /// Splits the active pane of the active tab. The new pane inherits the + /// active pane's cwd and takes focus. + pub fn split_active_pane( + &mut self, + dir: SplitDir, + window: &mut Window, + cx: &mut Context, + ) { + let ws_idx = self.state.active_workspace; + let Some(tab_idx) = self.state.workspaces.get(ws_idx).map(|ws| ws.active_term) else { + return; + }; + let cwd = self.get_active_terminal_cwd(cx); + let (rows, cols) = + Self::terminal_size_for_window(window.viewport_size(), self.state.font_size, window); + let colors = self.terminal_colors.clone(); + let term = + cx.new(|cx| PtyTerminal::new_with_cwd(cwd.clone(), None, rows, cols, colors, cx)); + cx.observe(&term, |_, _, cx| cx.notify()).detach(); + let session_name = term.read(cx).session_name.clone(); + + let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { + return; + }; + let Some(tab_data) = ws.terminals.get_mut(tab_idx) else { + return; + }; + let Some(tab) = self + .terminals + .get_mut(ws_idx) + .and_then(|tabs| tabs.get_mut(tab_idx)) + else { + return; + }; + let active = tab_data + .active_pane + .min(tab_data.panes.len().saturating_sub(1)); + let new_idx = tab_data.split_pane(active, dir); + // Point the new pane at the session we just spawned. + if let Some(pane) = tab_data.panes.get_mut(new_idx) { + pane.session_name = session_name; + } + tab.entities.push(term); + tab.active_pane = new_idx; + tab.root = tab_data.root.clone(); + self.state.save().ok(); + cx.notify(); + } + + /// Focuses `pane_idx` within the active tab. + pub fn focus_pane(&mut self, pane_idx: usize, cx: &mut Context) { + let ws_idx = self.state.active_workspace; + let (tab_idx, len) = match self.state.workspaces.get(ws_idx) { + Some(ws) => ( + ws.active_term, + ws.terminals.get(ws.active_term).map(|t| t.panes.len()), + ), + None => return, + }; + let Some(len) = len else { return }; + if pane_idx >= len { + return; + } + // Clicking the already-focused pane must not rewrite state.json. + if self.focused_pane_idx() == pane_idx + && self + .state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.terminals.get(tab_idx)) + .is_some_and(|tab| tab.active_pane == pane_idx) + { + return; + } + if let Some(ws) = self.state.workspaces.get_mut(ws_idx) + && let Some(tab_data) = ws.terminals.get_mut(tab_idx) + { + tab_data.active_pane = pane_idx; + } + if let Some(tab) = self + .terminals + .get_mut(ws_idx) + .and_then(|tabs| tabs.get_mut(tab_idx)) + { + tab.active_pane = pane_idx.min(tab.entities.len().saturating_sub(1)); + } + self.selection = None; + self.state.save().ok(); + cx.notify(); + } + + /// Cycles pane focus within the active tab. + pub fn cycle_active_pane(&mut self, delta: isize, cx: &mut Context) { + let ws_idx = self.state.active_workspace; + let (tab_idx, len) = match self.state.workspaces.get(ws_idx) { + Some(ws) => ( + ws.active_term, + ws.terminals + .get(ws.active_term) + .map(|t| t.panes.len()) + .unwrap_or(0), + ), + None => return, + }; + if len < 2 { + return; + } + let cur = self + .terminals + .get(ws_idx) + .and_then(|tabs| tabs.get(tab_idx)) + .map(|t| t.active_pane) + .unwrap_or(0); + let next = (cur as isize + delta).rem_euclid(len as isize) as usize; + self.focus_pane(next, cx); + } + + /// Closes the active pane; when it was the tab's last pane, closes the + /// tab instead (matching the old Cmd+W behaviour). + pub fn close_active_pane_or_tab(&mut self, cx: &mut Context) { + let ws_idx = self.state.active_workspace; + let tab_idx = match self.state.workspaces.get(ws_idx) { + Some(ws) => ws.active_term, + None => return, + }; + let pane_count = self + .state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.terminals.get(tab_idx)) + .map(|t| t.panes.len()) + .unwrap_or(0); + if pane_count > 1 { + self.close_pane(ws_idx, tab_idx, None, cx); + } else if let Some(ws) = self.state.workspaces.get(ws_idx) { + self.delete_term(ws.active_term, cx); + } + } + + /// Closes one pane of a tab (default: the focused one), collapsing the + /// split tree around it. + pub fn close_pane( + &mut self, + ws_idx: usize, + tab_idx: usize, + pane_idx: Option, + cx: &mut Context, + ) { + let pane_idx = pane_idx.unwrap_or_else(|| { + self.terminals + .get(ws_idx) + .and_then(|tabs| tabs.get(tab_idx)) + .map(|t| t.active_pane) + .unwrap_or(0) + }); + // Persist first: a rejected close (last pane) must leave the runtime + // entities untouched so state and runtime stay in lockstep. + if let Some(ws) = self.state.workspaces.get_mut(ws_idx) + && let Some(tab_data) = ws.terminals.get_mut(tab_idx) + { + if !tab_data.close_pane(pane_idx) { + return; + } + } else { + return; + } + if let Some(tab) = self + .terminals + .get_mut(ws_idx) + .and_then(|tabs| tabs.get_mut(tab_idx)) + && pane_idx < tab.entities.len() + { + tab.entities[pane_idx].update(cx, |term, _| term.shutdown()); + tab.entities.remove(pane_idx); + } + if let Some(tab_data) = self + .state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.terminals.get(tab_idx)) + && let Some(tab) = self + .terminals + .get_mut(ws_idx) + .and_then(|tabs| tabs.get_mut(tab_idx)) + { + tab.active_pane = tab_data.active_pane; + tab.root = tab_data.root.clone(); + } + self.selection = None; + self.state.save().ok(); + cx.notify(); + } + pub fn start_renaming_tab(&mut self, idx: usize, cx: &mut Context) { let ws_idx = self.state.active_workspace; if let Some(ws) = self.state.workspaces.get(ws_idx)