diff --git a/crates/gpui_dock/src/dock.rs b/crates/gpui_dock/src/dock.rs index ccb41ce..252f45e 100644 --- a/crates/gpui_dock/src/dock.rs +++ b/crates/gpui_dock/src/dock.rs @@ -10,7 +10,7 @@ use gpui::{ use gpui_component::StyledExt; use serde::{Deserialize, Serialize}; -use super::{DockArea, DockItem, Panel, PanelView, TabPanel}; +use super::{DockArea, DockEvent, DockItem, Panel, PanelView, TabPanel}; use crate::resizable::{PANEL_MIN_SIZE, resize_handle}; #[derive(Clone)] @@ -275,12 +275,13 @@ impl Dock { } /// Set the size of the Dock. - pub fn set_size(&mut self, size: Pixels, _: &mut Window, cx: &mut Context) { + pub fn set_size(&mut self, size: Pixels, window: &mut Window, cx: &mut Context) { let mut size = size.max(self.min_size); if let Some(max) = self.max_size { size = size.min(max); } self.size = size; + self.emit_layout_changed(window, cx); cx.notify(); } @@ -291,9 +292,17 @@ impl Dock { cx.defer_in(window, move |_, window, cx| { item.set_collapsed(!open, window, cx); }); + self.emit_layout_changed(window, cx); cx.notify(); } + fn emit_layout_changed(&self, window: &mut Window, cx: &mut Context) { + let dock_area = self.dock_area.clone(); + cx.defer_in(window, move |_, _window, cx| { + let _ = dock_area.update(cx, |_, cx| cx.emit(DockEvent::LayoutChanged)); + }); + } + /// Add item to the Dock. pub fn add_panel( &mut self, diff --git a/crates/gpui_dock/src/lib.rs b/crates/gpui_dock/src/lib.rs index 36501ea..0ba948d 100644 --- a/crates/gpui_dock/src/lib.rs +++ b/crates/gpui_dock/src/lib.rs @@ -1190,6 +1190,17 @@ impl DockArea { .collect() } + pub fn all_tab_panels(&self, cx: &App) -> Vec> { + let mut out = self.center.tab_panels(cx); + for dock in [&self.left_dock, &self.right_dock, &self.bottom_dock] + .into_iter() + .flatten() + { + out.extend(dock.read(cx).panel.tab_panels(cx)); + } + out + } + fn render_items(&self, _window: &mut Window, _cx: &mut Context) -> AnyElement { match &self.center { DockItem::Split { view, .. } => view.clone().into_any_element(), @@ -1343,6 +1354,65 @@ mod tests { } } + #[gpui::test] + fn empty_layout_containers_dump_their_container_type(cx: &mut gpui::TestAppContext) { + cx.update(|app| { + gpui_component::init(app); + crate::init(app); + }); + + let window_cx = cx.add_empty_window(); + window_cx.update(|window, app| { + let dock_area = app.new(|cx| DockArea::new("dock", None, window, cx)); + let dock_weak = dock_area.downgrade(); + + let tabs = DockItem::tabs(Vec::new(), &dock_weak, window, app); + assert!(matches!(tabs.view().dump(app).info, PanelInfo::Tabs { .. })); + + let stack = DockItem::v_split(Vec::new(), &dock_weak, window, app); + assert!(matches!( + stack.view().dump(app).info, + PanelInfo::Stack { .. } + )); + }); + } + + #[gpui::test] + fn bare_side_dock_panel_stays_bare_after_state_round_trip(cx: &mut gpui::TestAppContext) { + cx.update(|app| { + gpui_component::init(app); + crate::init(app); + register_panel(app, "fake.panel", |_, _, _, _, cx| { + Box::new(cx.new(|cx| FakePanel::new("restored", cx))) + }); + }); + + let window_cx = cx.add_empty_window(); + window_cx.update(|window, app| { + let source = app.new(|cx| DockArea::new("source", None, window, cx)); + let panel: Arc = Arc::new(app.new(|cx| FakePanel::new("source", cx))); + source.update(app, |dock, cx| { + dock.set_left_dock(DockItem::panel(panel), None, true, window, cx); + }); + let state = source.read(app).dump(app); + + let restored = app.new(|cx| DockArea::new("restored", None, window, cx)); + restored + .update(app, |dock, cx| dock.load(state, window, cx)) + .expect("restore dock state"); + + assert!(matches!( + restored + .read(app) + .left_dock() + .expect("left dock") + .read(app) + .panel(), + DockItem::Panel { .. } + )); + }); + } + #[gpui::test] fn dock_area_visible_tab_panels_excludes_collapsed_docks(cx: &mut gpui::TestAppContext) { cx.update(|app| { diff --git a/crates/gpui_dock/src/panel.rs b/crates/gpui_dock/src/panel.rs index e0bfe0a..d2a128d 100644 --- a/crates/gpui_dock/src/panel.rs +++ b/crates/gpui_dock/src/panel.rs @@ -79,6 +79,11 @@ pub trait Panel: EventEmitter + Render + Focusable { /// Once you have defined a panel name, this must not be changed. fn panel_name(&self) -> &'static str; + /// Whether this panel should be included in persisted dock state. + fn persistable(&self, cx: &App) -> bool { + true + } + /// The name of the tab of the panel, default is `None`. /// /// Used to display in the already collapsed tab panel. @@ -225,6 +230,7 @@ pub trait Panel: EventEmitter + Render + Focusable { #[allow(unused_variables)] pub trait PanelView: 'static + Send + Sync { fn panel_name(&self, cx: &App) -> &'static str; + fn persistable(&self, cx: &App) -> bool; fn panel_id(&self, cx: &App) -> EntityId; fn tab_name(&self, cx: &App) -> Option; fn tab_tooltip(&self, window: &mut Window, cx: &mut App) -> Option; @@ -254,6 +260,10 @@ impl PanelView for Entity { self.read(cx).panel_name() } + fn persistable(&self, cx: &App) -> bool { + self.read(cx).persistable(cx) + } + fn panel_id(&self, _: &App) -> EntityId { self.entity_id() } diff --git a/crates/gpui_dock/src/stack_panel.rs b/crates/gpui_dock/src/stack_panel.rs index e9ed3c7..b59dfce 100644 --- a/crates/gpui_dock/src/stack_panel.rs +++ b/crates/gpui_dock/src/stack_panel.rs @@ -39,9 +39,9 @@ impl Panel for StackPanel { fn dump(&self, cx: &App) -> PanelState { let sizes = self.state.read(cx).sizes().clone(); let mut state = PanelState::new(self); + state.info = PanelInfo::stack(sizes, self.axis); for panel in &self.panels { state.add_child(panel.dump(cx)); - state.info = PanelInfo::stack(sizes.clone(), self.axis); } state diff --git a/crates/gpui_dock/src/state.rs b/crates/gpui_dock/src/state.rs index f1f16a1..1e6421b 100644 --- a/crates/gpui_dock/src/state.rs +++ b/crates/gpui_dock/src/state.rs @@ -42,6 +42,14 @@ impl DockState { } } + pub fn panel_state(&self) -> &PanelState { + &self.panel + } + + pub fn set_panel_state(&mut self, panel: PanelState) { + self.panel = panel; + } + /// Convert the DockState to Dock pub fn to_dock( &self, @@ -49,7 +57,20 @@ impl DockState { window: &mut Window, cx: &mut App, ) -> Entity { - let item = self.panel.to_item(dock_area.clone(), window, cx); + let item = match &self.panel.info { + PanelInfo::Panel(_) => { + let view = PanelRegistry::build_panel( + &self.panel.panel_name, + dock_area.clone(), + &self.panel, + &self.panel.info, + window, + cx, + ); + DockItem::panel(view.into()) + } + _ => self.panel.to_item(dock_area.clone(), window, cx), + }; cx.new(|cx| { Dock::from_state( dock_area.clone(), diff --git a/crates/gpui_dock/src/tab_panel.rs b/crates/gpui_dock/src/tab_panel.rs index 15bd0fc..a1e3c13 100644 --- a/crates/gpui_dock/src/tab_panel.rs +++ b/crates/gpui_dock/src/tab_panel.rs @@ -140,10 +140,17 @@ impl Panel for TabPanel { fn dump(&self, cx: &App) -> PanelState { let mut state = PanelState::new(self); - for panel in self.panels.iter() { + let mut active_ix = 0; + for (ix, panel) in self.panels.iter().enumerate() { + if !panel.persistable(cx) { + continue; + } + if ix < self.active_ix { + active_ix += 1; + } state.add_child(panel.dump(cx)); - state.info = PanelInfo::tabs(self.active_ix); } + state.info = PanelInfo::tabs(active_ix.min(state.children.len().saturating_sub(1))); state } @@ -154,6 +161,10 @@ impl Panel for TabPanel { } impl TabPanel { + pub fn panels(&self) -> &[Arc] { + &self.panels + } + pub fn new( stack_panel: Option>, dock_area: WeakEntity, @@ -405,6 +416,32 @@ impl TabPanel { cx.notify(); } + pub fn replace_panel( + &mut self, + old_panel: Arc, + new_panel: Arc, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(ix) = self + .panels + .iter() + .position(|panel| panel.view() == old_panel.view()) + else { + return false; + }; + + old_panel.on_removed(window, cx); + new_panel.on_added_to(cx.entity().downgrade(), window, cx); + self.panels[ix] = new_panel; + if self.active_ix == ix { + self.set_active_ix(ix, window, cx); + } + cx.emit(PanelEvent::LayoutChanged); + cx.notify(); + true + } + fn detach_panel( &mut self, panel: Arc, @@ -1690,6 +1727,65 @@ mod tests { } } + struct FakeTransientPanel { + focus: FocusHandle, + } + + impl EventEmitter for FakeTransientPanel {} + + impl Focusable for FakeTransientPanel { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus.clone() + } + } + + impl Panel for FakeTransientPanel { + fn panel_name(&self) -> &'static str { + "fake.panel.transient" + } + + fn persistable(&self, _cx: &App) -> bool { + false + } + } + + impl Render for FakeTransientPanel { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + } + } + + #[gpui::test] + fn tab_panel_dump_omits_transient_panels_and_preserves_active_panel( + cx: &mut gpui::TestAppContext, + ) { + cx.update(|app| { + gpui_component::init(app); + crate::init(app); + }); + + let (tab_panel, window_cx) = cx.add_window_view(|window, cx| { + let dock_area = cx.new(|cx| DockArea::new("dock", None, window, cx)); + TabPanel::new(None, dock_area.downgrade(), window, cx) + }); + window_cx.update(|window, cx| { + let transient: Arc = Arc::new(cx.new(|cx| FakeTransientPanel { + focus: cx.focus_handle(), + })); + let active: Arc = Arc::new(cx.new(FakePanelWithoutIcon::new)); + tab_panel.update(cx, |tabs, cx| { + tabs.add_panel(transient, window, cx); + tabs.add_panel(active, window, cx); + tabs.active_ix = 1; + }); + + let state = tab_panel.read(cx).dump(cx); + assert_eq!(state.children.len(), 1); + assert_eq!(state.children[0].panel_name, "fake.panel.without_icon"); + assert_eq!(state.info.active_index(), Some(0)); + }); + } + #[gpui::test] fn tab_panel_close_all_calls_panel_on_close(cx: &mut gpui::TestAppContext) { cx.update(|app| { diff --git a/crates/gpui_sftp/src/lib.rs b/crates/gpui_sftp/src/lib.rs index f4d23b5..2277479 100644 --- a/crates/gpui_sftp/src/lib.rs +++ b/crates/gpui_sftp/src/lib.rs @@ -7,7 +7,7 @@ mod state; mod view; pub use state::{Entry, EntryKind, SortColumn, SortDirection, SortSpec, TreeState, VisibleRow}; -pub use view::{SftpEvent, SftpView}; +pub use view::{SftpEvent, SftpStatus, SftpView}; actions!( sftp, diff --git a/crates/gpui_sftp/src/view/mod.rs b/crates/gpui_sftp/src/view/mod.rs index 6a1396e..e986eea 100644 --- a/crates/gpui_sftp/src/view/mod.rs +++ b/crates/gpui_sftp/src/view/mod.rs @@ -171,6 +171,18 @@ pub enum SftpEvent { }, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct SftpStatus { + pub items: usize, + pub selected: usize, +} + +impl SftpStatus { + pub const fn new(items: usize, selected: usize) -> Self { + Self { items, selected } + } +} + #[derive(Copy, Clone, Debug, Eq, PartialEq)] enum ContextMenuTarget { Background, @@ -398,6 +410,7 @@ impl SftpView { let path_sub = Self::subscribe_path_input(&path_input, &table, window, cx); let table_events = Self::subscribe_table_events(&table, window, cx); + let table_observer = cx.observe(&table, |_, _, cx| cx.notify()); Self { table, @@ -412,10 +425,14 @@ impl SftpView { show_preview: false, last_pushed_notification_epoch: 0, focus_handle, - _subscriptions: vec![path_sub, table_events], + _subscriptions: vec![path_sub, table_events, table_observer], } } + pub fn status(&self, cx: &App) -> SftpStatus { + self.table.read(cx).delegate().status() + } + fn subscribe_path_input( path_input: &Entity, table: &Entity>, @@ -544,6 +561,21 @@ impl SftpView { .update(cx, |state, cx| state.delegate_mut().disconnect(cx)); } + pub fn current_dir(&self, cx: &App) -> Option { + self.table + .read(cx) + .delegate() + .tree + .as_ref() + .map(|tree| tree.root.clone()) + } + + pub fn change_dir(&mut self, dir: String, cx: &mut Context) { + self.close_preview(cx); + self.table + .update(cx, |state, cx| state.delegate_mut().cd(dir, cx)); + } + fn on_refresh(&mut self, _: &Refresh, _window: &mut Window, cx: &mut Context) { let table = self.table.clone(); table.update(cx, |state, cx| { diff --git a/crates/gpui_sftp/src/view/table/navigation.rs b/crates/gpui_sftp/src/view/table/navigation.rs index 7a3eb33..cf6d43f 100644 --- a/crates/gpui_sftp/src/view/table/navigation.rs +++ b/crates/gpui_sftp/src/view/table/navigation.rs @@ -3,6 +3,10 @@ use rust_i18n::t; use super::*; impl SftpTable { + pub(in crate::view) fn status(&self) -> SftpStatus { + SftpStatus::new(self.visible.len(), self.selected_ids.len()) + } + pub(in crate::view) fn new(sftp: wezterm_ssh::Sftp) -> Self { let tree = TreeState::new(Entry::new(".", "~", EntryKind::Dir)); let sort = SortSpec::default(); diff --git a/crates/gpui_sftp/src/view/tests.rs b/crates/gpui_sftp/src/view/tests.rs index 4c2ded7..2e942d8 100644 --- a/crates/gpui_sftp/src/view/tests.rs +++ b/crates/gpui_sftp/src/view/tests.rs @@ -574,6 +574,25 @@ fn clear_selection_removes_selected_rows_and_anchor() { assert_eq!(d.selection_anchor_id, None); } +#[test] +fn status_counts_visible_items_and_selected_items() { + let mut tree = TreeState::new(Entry::new("/", "/", EntryKind::Dir)); + tree.upsert_children( + "/", + vec![ + Entry::new("/a", "a", EntryKind::File), + Entry::new("/b", "b", EntryKind::File), + Entry::new("/c", "c", EntryKind::Dir), + ], + ); + let mut d = delegate_with_tree(tree); + d.rebuild_visible(); + d.selected_ids.insert("/a".to_string()); + d.selected_ids.insert("/c".to_string()); + + assert_eq!(d.status(), SftpStatus::new(3, 2)); +} + #[gpui::test] fn begin_blank_table_drag_select_clears_selection_and_starts_drag(cx: &mut gpui::TestAppContext) { gpui_component::init(&mut cx.app.borrow_mut()); diff --git a/crates/gpui_term/src/backends/alacritty/mod.rs b/crates/gpui_term/src/backends/alacritty/mod.rs index 9e2912a..9ee68ab 100644 --- a/crates/gpui_term/src/backends/alacritty/mod.rs +++ b/crates/gpui_term/src/backends/alacritty/mod.rs @@ -49,7 +49,10 @@ use crate::{ SelectionRange as ModelSelectionRange, SerialOptions, SshOptions, TermColor, Terminal, TerminalBackend, TerminalBounds, TerminalContent, TerminalMode, TerminalShutdownPolicy, TerminalType, backends, - cast::{CastHeader, CastRecorderSender, CastRecorderState, start_cast_recorder}, + cast::{ + CastHeader, CastRecorderSender, CastRecorderState, serialize_pre_cursor_cells, + start_cast_recorder, + }, settings::{CursorShape, TerminalSettings}, }; @@ -88,6 +91,33 @@ fn selection_from_lines(start_line: i32, end_line: i32, last_col: usize) -> Sele selection } +fn command_prefix(term: &Term) -> Vec { + let grid = term.grid(); + let cursor = grid.cursor.point; + let mut first_line = cursor.line; + while first_line.0 > 0 + && grid[Line(first_line.0 - 1)][grid.last_column()] + .flags + .contains(Flags::WRAPLINE) + { + first_line = Line(first_line.0 - 1); + } + + let mut cells = Vec::new(); + for line_index in first_line.0..=cursor.line.0 { + let line = Line(line_index); + let end = if line == cursor.line { + cursor.column + } else { + Column(grid.columns()) + }; + cells.extend(grid[line][..end].iter().cloned().map(map_cell)); + } + + let cursor_style = map_cell(grid.cursor.template.clone()); + serialize_pre_cursor_cells(&cells, &cursor_style) +} + fn local_pty_options(env: std::collections::HashMap) -> Options { local_pty_options_for_program_exists(env, crate::shell::program_exists_on_path) } @@ -1106,8 +1136,15 @@ impl TerminalBackend for AlacrittyBackend { env: BTreeMap::new(), }; + let prefix = command_prefix(&self.term.lock_unfair()); let (sender, state) = start_cast_recorder(opts.path, header, opts.include_input)?; - *self.record.slot.lock() = Some(sender.clone()); + { + let mut slot = self.record.slot.lock(); + if !prefix.is_empty() { + sender.output(&prefix); + } + *slot = Some(sender.clone()); + } self.record.sender = Some(sender); self.record.state = Some(state); Ok(()) @@ -1712,14 +1749,17 @@ mod shell_tests { #[cfg(test)] mod selection_tests { - use std::{collections::VecDeque, io, sync::Arc}; + use std::{collections::VecDeque, io, sync::Arc, time::SystemTime}; use alacritty_terminal::{ Term, event::{OnResize, WindowSize}, event_loop::EventLoop, + index::{Column, Line, Point as AlacPoint}, sync::FairMutex, + term::cell::Flags, tty::{ChildEvent, EventedPty, EventedReadWrite}, + vte::ansi::Color as AlacColor, }; use gpui::{AppContext, Bounds, Modifiers, MouseButton, MouseDownEvent, point, px, size}; use parking_lot::Mutex; @@ -1855,6 +1895,86 @@ mod selection_tests { ); } + #[test] + fn cast_recording_starts_with_colored_command_prefix() { + let mut backend = test_backend(); + let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(800.0), px(240.0))); + backend.content.terminal_bounds = TerminalBounds::new(px(10.0), px(10.0), bounds); + + { + let mut term = backend.term.lock_unfair(); + term.resize(backend.content.terminal_bounds); + term.grid_mut()[Line(0)][Column(0)].c = '$'; + term.grid_mut()[Line(0)][Column(0)].fg = AlacColor::Indexed(4); + term.grid_mut()[Line(0)][Column(1)].c = ' '; + term.grid_mut()[Line(0)][Column(1)].fg = AlacColor::Indexed(4); + term.grid_mut().cursor.point = AlacPoint::new(Line(0), Column(2)); + } + backend.rebuild_snapshot(); + backend.content.display_offset = 1; + backend.content.cursor.point.line = -1; + backend.content.cells.clear(); + + let path = std::env::temp_dir().join(format!( + "termua-prefix-test-{}-{}.cast", + std::process::id(), + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + backend + .start_cast_recording(crate::CastRecordingOptions { + path: path.clone(), + include_input: false, + }) + .unwrap(); + backend + .record + .sender + .as_ref() + .unwrap() + .output(b"echo hi\r\n"); + backend.stop_cast_recording(); + + let cast = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_file(path); + let first_output = cast + .lines() + .skip(1) + .map(|line| serde_json::from_str::(line).unwrap()) + .find(|event| event[1] == "o") + .and_then(|event| event[2].as_str().map(str::to_owned)) + .unwrap(); + + assert!( + first_output.starts_with("\x1b[0;38;5;4;49m$ \x1b[0;39;49m"), + "expected colored command prefix, got {first_output:?}" + ); + } + + #[test] + fn command_prefix_includes_previous_wrapped_rows() { + let backend = test_backend(); + let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(40.0), px(20.0))); + let terminal_bounds = TerminalBounds::new(px(10.0), px(10.0), bounds); + let mut term = backend.term.lock_unfair(); + term.resize(terminal_bounds); + for (column, character) in "abcd".chars().enumerate() { + term.grid_mut()[Line(0)][Column(column)].c = character; + } + term.grid_mut()[Line(0)][Column(3)] + .flags + .insert(Flags::WRAPLINE); + term.grid_mut()[Line(1)][Column(0)].c = 'e'; + term.grid_mut()[Line(1)][Column(1)].c = 'f'; + term.grid_mut().cursor.point = AlacPoint::new(Line(1), Column(2)); + + let prefix = String::from_utf8(super::command_prefix(&term)).unwrap(); + + assert_eq!(prefix.replace("\x1b[0;39;49m", ""), "abcdef"); + } + #[test] fn triple_click_selects_current_line() { let (events_tx, _events_rx) = futures::channel::mpsc::unbounded(); diff --git a/crates/gpui_term/src/backends/wezterm/mod.rs b/crates/gpui_term/src/backends/wezterm/mod.rs index b18b030..f8e4a4d 100644 --- a/crates/gpui_term/src/backends/wezterm/mod.rs +++ b/crates/gpui_term/src/backends/wezterm/mod.rs @@ -16,7 +16,7 @@ use gpui::{ use parking_lot::Mutex; use portable_pty::{ChildKiller, CommandBuilder, MasterPty, PtySize, PtySystem, native_pty_system}; use smol::channel::{Receiver, Sender}; -use wezterm_surface::{CursorShape as WezCursorShape, CursorVisibility}; +use wezterm_surface::{CursorShape as WezCursorShape, CursorVisibility, Line as WezLine}; use wezterm_term::{ Alert, AlertHandler, Cell as WezCell, CellAttributes, Intensity, PhysRowIndex, Screen, StableRowIndex, Terminal, TerminalConfiguration, TerminalSize, Underline, @@ -32,7 +32,10 @@ use crate::{ TerminalBackend, TerminalBounds, TerminalContent, TerminalMode, TerminalShutdownPolicy, TerminalType, backends::{self, ssh}, - cast::{CastHeader, CastRecorderSender, CastRecorderState, start_cast_recorder}, + cast::{ + CastHeader, CastRecorderSender, CastRecorderState, serialize_pre_cursor_cells, + start_cast_recorder, + }, serial::{serial2_char_size, serial2_flow_control, serial2_parity, serial2_stop_bits}, settings::{CursorShape, TerminalSettings}, terminal::Terminal as WidgetTerminal, @@ -410,6 +413,99 @@ pub struct WezTermBackend { } impl WezTermBackend { + fn map_line_cells(line: &WezLine, cols: usize) -> Vec { + let row_wrapped = line.last_cell_was_wrapped(); + let mut source_cells: Vec> = vec![None; cols]; + for cellref in line.visible_cells() { + let index = cellref.cell_index(); + if index < cols { + source_cells[index] = Some((cellref.as_cell(), cellref.width())); + } + } + + let mut cells = Vec::with_capacity(cols); + let mut skip = 0usize; + let mut wide_spacer_style: Option<(TermColor, TermColor, CellFlags)> = None; + for (column, source) in source_cells.iter().enumerate() { + if skip > 0 { + skip -= 1; + let (fg, bg, flags) = wide_spacer_style.unwrap_or(( + TermColor::Named(NamedColor::Foreground), + TermColor::Named(NamedColor::Background), + CellFlags::empty(), + )); + let mut flags = flags | CellFlags::WIDE_CHAR_SPACER; + if row_wrapped && column + 1 == cols { + flags |= CellFlags::WRAPLINE; + } + cells.push(Cell { + c: ' ', + fg, + bg, + flags, + hyperlink: None, + zerowidth: Vec::new(), + }); + if skip == 0 { + wide_spacer_style = None; + } + continue; + } + + let (wez_cell, width) = source.clone().unwrap_or_else(|| (WezCell::blank(), 1)); + let mut cell = map_cell(&wez_cell); + if row_wrapped && column + 1 == cols { + cell.flags |= CellFlags::WRAPLINE; + } + if width > 1 { + skip = width.saturating_sub(1); + wide_spacer_style = Some((cell.fg, cell.bg, cell.flags)); + } + cells.push(cell); + } + cells + } + + fn command_prefix(term: &Terminal) -> Vec { + let cursor = term.cursor_pos(); + let screen = term.screen(); + let cols = term.get_size().cols; + if cols == 0 { + return Vec::new(); + } + + let cursor_row = screen.phys_row(cursor.y); + let mut first_row = cursor_row; + while first_row > 0 { + let previous = screen.lines_in_phys_range(first_row - 1..first_row); + if !previous.first().is_some_and(WezLine::last_cell_was_wrapped) { + break; + } + first_row -= 1; + } + + let lines = screen.lines_in_phys_range(first_row..cursor_row + 1); + let mut cells = Vec::new(); + for (index, line) in lines.iter().enumerate() { + let mut line_cells = Self::map_line_cells(line, cols); + if index + 1 == lines.len() { + line_cells.truncate(cursor.x.min(cols)); + } + cells.extend(line_cells); + } + + let pen = term.pen(); + let cursor_style = Cell { + c: ' ', + fg: map_color(pen.foreground(), false), + bg: map_color(pen.background(), true), + flags: map_flags(&pen), + hyperlink: None, + zerowidth: Vec::new(), + }; + serialize_pre_cursor_cells(&cells, &cursor_style) + } + fn map_modifiers(modifiers: &Modifiers) -> KeyModifiers { let mut mods = KeyModifiers::default(); if modifiers.shift { @@ -844,60 +940,9 @@ impl WezTermBackend { let mut cursor_char = ' '; let mut cells = Vec::with_capacity(cols * rows); for (r, line) in lines.iter().enumerate() { - let row_wrapped = line.last_cell_was_wrapped(); - let mut row_cells: Vec> = vec![None; cols]; - for cellref in line.visible_cells() { - let idx = cellref.cell_index(); - if idx < cols { - row_cells[idx] = Some((cellref.as_cell(), cellref.width())); - } - } - - let mut skip = 0usize; - // When synthesizing spacer cells for wide glyphs, preserve the original cell style - // so background runs (e.g. bracketed-paste highlight/inverse) cover the full width. - let mut wide_spacer_style: Option<(TermColor, TermColor, CellFlags)> = None; - for (c, slot) in row_cells.iter().enumerate() { + for (c, mapped) in Self::map_line_cells(line, cols).into_iter().enumerate() { let point = GridPoint::new(r as i32 - plan.display_offset as i32, c); - if skip > 0 { - skip -= 1; - let (fg, bg, flags) = wide_spacer_style.unwrap_or(( - TermColor::Named(NamedColor::Foreground), - TermColor::Named(NamedColor::Background), - CellFlags::empty(), - )); - let mut flags = flags | CellFlags::WIDE_CHAR_SPACER; - if row_wrapped && c + 1 == cols { - flags |= CellFlags::WRAPLINE; - } - cells.push(crate::IndexedCell { - point, - cell: Cell { - c: ' ', - fg, - bg, - flags, - hyperlink: None, - zerowidth: Vec::new(), - }, - }); - if skip == 0 { - wide_spacer_style = None; - } - continue; - } - - let (wcell, width) = slot.clone().unwrap_or_else(|| (WezCell::blank(), 1)); - let mut mapped = map_cell(&wcell); - if row_wrapped && c + 1 == cols { - mapped.flags |= CellFlags::WRAPLINE; - } - if width > 1 { - skip = width.saturating_sub(1); - wide_spacer_style = Some((mapped.fg, mapped.bg, mapped.flags)); - } - if !cursor_hidden && cursor_row == Some(r) && cursor_col == c { cursor_char = mapped.c; } @@ -1511,10 +1556,13 @@ impl TerminalBackend for WezTermBackend { } } fn select_all(&mut self) { - let rows = self.content.terminal_bounds.num_lines().max(1); - let last_col = self.content.terminal_bounds.last_column(); - let top = -(self.content.display_offset as i32); - let bottom = top + rows.saturating_sub(1) as i32; + let term = self.term.lock(); + let plan = self.compute_viewport_plan(term.screen()); + let top = (plan.top_stable as i64 - plan.base_stable as i64) + .clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32; + let bottom = plan.rows.saturating_sub(1).min(i32::MAX as usize) as i32; + let last_col = plan.cols.saturating_sub(1); + drop(term); self.selection.range = Some(crate::SelectionRange { start: GridPoint::new(top, 0), @@ -1689,8 +1737,15 @@ impl TerminalBackend for WezTermBackend { env: BTreeMap::new(), }; + let prefix = Self::command_prefix(&self.term.lock()); let (sender, state) = start_cast_recorder(opts.path, header, opts.include_input)?; - *self.record.slot.lock() = Some(sender.clone()); + { + let mut slot = self.record.slot.lock(); + if !prefix.is_empty() { + sender.output(&prefix); + } + *slot = Some(sender.clone()); + } self.record.sender = Some(sender); self.record.state = Some(state); Ok(()) @@ -2502,6 +2557,65 @@ mod tests { ); } + #[test] + fn select_all_includes_scrollback_history() { + let cast_slot = Arc::new(Mutex::new(None)); + let writer = SharedWriter { + inner: Arc::new(Mutex::new( + Box::new(std::io::sink()) as Box + )), + cast: Arc::clone(&cast_slot), + }; + let mut wezterm_term = Terminal::new( + TerminalSize { + rows: 3, + cols: 10, + pixel_width: 0, + pixel_height: 0, + dpi: 0, + }, + Arc::new(TestConfig { scrollback: 100 }), + "termua", + "0", + Box::new(writer.clone()), + ); + wezterm_term.advance_bytes(b"one\r\ntwo\r\nthree\r\nfour\r\nfive"); + + let mut backend = WezTermBackend { + master: Box::new(DummyMasterPty), + writer, + term: Arc::new(Mutex::new(wezterm_term)), + child_killer: Box::new(DummyChildKiller), + shutdown: super::ShutdownState::default(), + pending_ops: VecDeque::new(), + viewport_top_stable: None, + last_clicked_line: None, + search: super::SearchState::default(), + content: TerminalContent { + terminal_bounds: crate::TerminalBounds::new( + px(10.0), + px(10.0), + Bounds::new(point(px(0.0), px(0.0)), size(px(100.0), px(30.0))), + ), + ..TerminalContent::default() + }, + exited: false, + last_mouse_pos: None, + selection: super::SelectionState::default(), + default_cursor_shape: crate::CursorShape::default(), + scroll_px: px(0.0), + sftp: None, + record: super::RecordState::new(cast_slot), + }; + + backend.select_all(); + let selection = backend.selection.range.as_ref().unwrap(); + assert_eq!( + backend.selection_to_string(selection), + "one\ntwo\nthree\nfour\nfive" + ); + } + #[test] fn stable_viewport_survives_scrollback_eviction() { let rows = 3usize; @@ -2708,6 +2822,149 @@ mod tests { ); } + #[test] + fn cast_recording_starts_with_colored_command_prefix() { + let cast_slot = Arc::new(Mutex::new(None)); + let writer = SharedWriter { + inner: Arc::new(Mutex::new( + Box::new(std::io::sink()) as Box + )), + cast: Arc::clone(&cast_slot), + }; + let mut wezterm_term = Terminal::new( + TerminalSize { + rows: 3, + cols: 10, + pixel_width: 0, + pixel_height: 0, + dpi: 0, + }, + Arc::new(TestConfig { scrollback: 0 }), + "termua", + "0", + Box::new(writer.clone()), + ); + wezterm_term.advance_bytes("\x1b[38;5;4m❯ \x1b[0m".as_bytes()); + let content = TerminalContent { + terminal_bounds: crate::TerminalBounds::new( + px(10.0), + px(10.0), + Bounds::new(point(px(0.0), px(0.0)), size(px(100.0), px(30.0))), + ), + cursor: crate::Cursor { + shape: crate::CursorRenderShape::Hidden, + point: crate::GridPoint::new(-1, 2), + }, + display_offset: 1, + ..TerminalContent::default() + }; + + let mut backend = WezTermBackend { + master: Box::new(DummyMasterPty), + writer, + term: Arc::new(Mutex::new(wezterm_term)), + child_killer: Box::new(DummyChildKiller), + shutdown: super::ShutdownState::default(), + pending_ops: VecDeque::new(), + viewport_top_stable: None, + last_clicked_line: None, + search: super::SearchState::default(), + content, + exited: false, + last_mouse_pos: None, + selection: super::SelectionState::default(), + default_cursor_shape: crate::CursorShape::default(), + scroll_px: px(0.0), + sftp: None, + record: super::RecordState::new(cast_slot), + }; + let path = std::env::temp_dir().join(format!( + "termua-wezterm-prefix-test-{}-{}.cast", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + + backend + .start_cast_recording(CastRecordingOptions { + path: path.clone(), + include_input: false, + }) + .unwrap(); + backend + .record + .sender + .as_ref() + .unwrap() + .output(b"echo hi\r\n"); + backend.stop_cast_recording(); + + let cast = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_file(path); + let first_output = cast + .lines() + .skip(1) + .map(|line| serde_json::from_str::(line).unwrap()) + .find(|event| event[1] == "o") + .and_then(|event| event[2].as_str().map(str::to_owned)) + .unwrap(); + + assert!( + first_output.starts_with("\x1b[0;38;5;4;49m❯ \x1b[0;39;49m"), + "expected colored command prefix, got {first_output:?}" + ); + } + + #[test] + fn line_mapping_marks_wide_spacers_and_wrapped_last_cell() { + let mut term = Terminal::new( + TerminalSize { + rows: 2, + cols: 3, + pixel_width: 0, + pixel_height: 0, + dpi: 0, + }, + Arc::new(TestConfig { scrollback: 0 }), + "termua", + "0", + Box::new(std::io::sink()), + ); + term.advance_bytes("界xy".as_bytes()); + + let line = term.screen().lines_in_phys_range(0..1).remove(0); + let cells = WezTermBackend::map_line_cells(&line, 3); + + assert_eq!(cells.len(), 3); + assert_eq!(cells[0].c, '界'); + assert!(cells[1].flags.contains(crate::CellFlags::WIDE_CHAR_SPACER)); + assert!(cells[2].flags.contains(crate::CellFlags::WRAPLINE)); + } + + #[test] + fn command_prefix_includes_previous_wrapped_rows() { + let mut term = Terminal::new( + TerminalSize { + rows: 2, + cols: 3, + pixel_width: 0, + pixel_height: 0, + dpi: 0, + }, + Arc::new(TestConfig { scrollback: 0 }), + "termua", + "0", + Box::new(std::io::sink()), + ); + term.advance_bytes(b"abcd"); + + let prefix = String::from_utf8(WezTermBackend::command_prefix(&term)).unwrap(); + + assert_eq!(prefix.replace("\x1b[0;39;49m", ""), "abcd"); + } + #[gpui::test] fn pty_exit_stops_cast_recording(cx: &mut gpui::TestAppContext) { let cast_slot = Arc::new(Mutex::new(None)); diff --git a/crates/gpui_term/src/cast.rs b/crates/gpui_term/src/cast.rs index e8a8bb7..ed79886 100644 --- a/crates/gpui_term/src/cast.rs +++ b/crates/gpui_term/src/cast.rs @@ -10,6 +10,8 @@ use std::{ use gpui::Global; use serde::Serialize; +use crate::{Cell, CellFlags, NamedColor, TermColor}; + /// Asciinema cast v2 header. #[derive(Debug, Clone)] pub struct CastHeader { @@ -78,6 +80,124 @@ impl CastWriter { } } +#[derive(Clone, Copy, PartialEq)] +struct CellStyle { + fg: TermColor, + bg: TermColor, + flags: CellFlags, +} + +impl From<&Cell> for CellStyle { + fn from(cell: &Cell) -> Self { + Self { + fg: cell.fg, + bg: cell.bg, + flags: cell.flags, + } + } +} + +pub(crate) fn serialize_pre_cursor_cells(cells: &[Cell], cursor_style: &Cell) -> Vec { + let mut output = Vec::new(); + let mut active_style = None; + for cell in cells { + if cell.flags.contains(CellFlags::WIDE_CHAR_SPACER) { + continue; + } + + let style = CellStyle::from(cell); + if active_style != Some(style) { + write_style(&mut output, style); + active_style = Some(style); + } + push_char(&mut output, cell.c); + if let Some(zerowidth) = cell.zerowidth() { + for character in zerowidth { + push_char(&mut output, *character); + } + } + } + + let cursor_style = CellStyle::from(cursor_style); + if active_style != Some(cursor_style) { + write_style(&mut output, cursor_style); + } + output +} + +fn push_char(output: &mut Vec, character: char) { + let mut buf = [0; 4]; + output.extend_from_slice(character.encode_utf8(&mut buf).as_bytes()); +} + +fn write_style(output: &mut Vec, style: CellStyle) { + let mut params = vec!["0".to_string()]; + if style.flags.contains(CellFlags::BOLD) { + params.push("1".to_string()); + } + if style.flags.contains(CellFlags::DIM) { + params.push("2".to_string()); + } + if style.flags.contains(CellFlags::ITALIC) { + params.push("3".to_string()); + } + if style.flags.contains(CellFlags::DOUBLE_UNDERLINE) { + params.push("4:2".to_string()); + } else if style.flags.contains(CellFlags::CURLY_UNDERLINE) { + params.push("4:3".to_string()); + } else if style.flags.contains(CellFlags::DOTTED_UNDERLINE) { + params.push("4:4".to_string()); + } else if style.flags.contains(CellFlags::DASHED_UNDERLINE) { + params.push("4:5".to_string()); + } else if style.flags.contains(CellFlags::UNDERLINE) { + params.push("4".to_string()); + } + if style.flags.contains(CellFlags::INVERSE) { + params.push("7".to_string()); + } + if style.flags.contains(CellFlags::STRIKEOUT) { + params.push("9".to_string()); + } + push_color(&mut params, style.fg, false); + push_color(&mut params, style.bg, true); + + output.extend_from_slice(b"\x1b["); + output.extend_from_slice(params.join(";").as_bytes()); + output.push(b'm'); +} + +fn push_color(params: &mut Vec, color: TermColor, background: bool) { + let base = if background { 48 } else { 38 }; + match color { + TermColor::Rgb(r, g, b) => params.push(format!("{base};2;{r};{g};{b}")), + TermColor::Indexed(index) => params.push(format!("{base};5;{index}")), + TermColor::Named(named) => params.push(named_color_code(named, background).to_string()), + } +} + +fn named_color_code(color: NamedColor, background: bool) -> u8 { + let offset = u8::from(background) * 10; + match color { + NamedColor::Black => 30 + offset, + NamedColor::Red => 31 + offset, + NamedColor::Green => 32 + offset, + NamedColor::Yellow => 33 + offset, + NamedColor::Blue => 34 + offset, + NamedColor::Magenta => 35 + offset, + NamedColor::Cyan => 36 + offset, + NamedColor::White => 37 + offset, + NamedColor::BrightBlack => 90 + offset, + NamedColor::BrightRed => 91 + offset, + NamedColor::BrightGreen => 92 + offset, + NamedColor::BrightYellow => 93 + offset, + NamedColor::BrightBlue => 94 + offset, + NamedColor::BrightMagenta => 95 + offset, + NamedColor::BrightCyan => 96 + offset, + NamedColor::BrightWhite => 97 + offset, + NamedColor::Foreground | NamedColor::Background | NamedColor::Cursor => 39 + offset, + } +} + #[derive(Clone, Debug)] pub struct CastRecordingOptions { pub path: PathBuf, @@ -242,6 +362,113 @@ mod tests { } } + fn styled_cell(c: char, fg: TermColor, bg: TermColor, flags: CellFlags) -> Cell { + Cell { + c, + fg, + bg, + flags, + ..Cell::default() + } + } + + #[test] + fn pre_cursor_serialization_preserves_text_and_skips_wide_spacers() { + let mut combined = Cell { + c: 'e', + zerowidth: vec!['\u{301}'], + ..Cell::default() + }; + combined.fg = TermColor::Indexed(7); + let spacer = Cell { + c: 'x', + flags: CellFlags::WIDE_CHAR_SPACER, + ..Cell::default() + }; + let cursor = Cell::default(); + + assert_eq!( + serialize_pre_cursor_cells(&[combined, spacer], &cursor), + b"\x1b[0;38;5;7;49me\xcc\x81\x1b[0;39;49m" + ); + } + + #[test] + fn pre_cursor_serialization_emits_all_text_styles_and_rgb_colors() { + let flags = CellFlags::BOLD + | CellFlags::DIM + | CellFlags::ITALIC + | CellFlags::DOUBLE_UNDERLINE + | CellFlags::INVERSE + | CellFlags::STRIKEOUT; + let cell = styled_cell('x', TermColor::Rgb(1, 2, 3), TermColor::Rgb(4, 5, 6), flags); + + assert_eq!( + serialize_pre_cursor_cells(std::slice::from_ref(&cell), &cell), + b"\x1b[0;1;2;3;4:2;7;9;38;2;1;2;3;48;2;4;5;6mx" + ); + } + + #[test] + fn pre_cursor_serialization_emits_each_underline_variant() { + let variants = [ + (CellFlags::CURLY_UNDERLINE, "4:3"), + (CellFlags::DOTTED_UNDERLINE, "4:4"), + (CellFlags::DASHED_UNDERLINE, "4:5"), + (CellFlags::UNDERLINE, "4"), + ]; + + for (flags, code) in variants { + let cell = styled_cell( + 'x', + TermColor::Named(NamedColor::Foreground), + TermColor::Named(NamedColor::Background), + flags, + ); + assert_eq!( + String::from_utf8(serialize_pre_cursor_cells( + std::slice::from_ref(&cell), + &cell + )) + .unwrap(), + format!("\x1b[0;{code};39;49mx") + ); + } + } + + #[test] + fn named_color_codes_cover_normal_bright_and_default_colors() { + let colors = [ + NamedColor::Black, + NamedColor::Red, + NamedColor::Green, + NamedColor::Yellow, + NamedColor::Blue, + NamedColor::Magenta, + NamedColor::Cyan, + NamedColor::White, + NamedColor::BrightBlack, + NamedColor::BrightRed, + NamedColor::BrightGreen, + NamedColor::BrightYellow, + NamedColor::BrightBlue, + NamedColor::BrightMagenta, + NamedColor::BrightCyan, + NamedColor::BrightWhite, + NamedColor::Foreground, + NamedColor::Background, + NamedColor::Cursor, + ]; + let foreground = [ + 30, 31, 32, 33, 34, 35, 36, 37, 90, 91, 92, 93, 94, 95, 96, 97, 39, 39, 39, + ]; + + for (index, color) in colors.into_iter().enumerate() { + assert_eq!(named_color_code(color, false), foreground[index]); + assert_eq!(named_color_code(color, true), foreground[index] + 10); + } + } + #[test] fn cast_writer_emits_header_then_events() { let mut buf = Vec::::new(); diff --git a/locales/en.yml b/locales/en.yml index 6519066..9ec1847 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -144,6 +144,9 @@ ThemeEditor: Hint: "Edit theme colors (preview only)." Footbar: + Sftp: + Items: "%{count} items" + Selected: "%{count} selected" Tooltip: Sessions: "Sessions" MultiExecute: "Multi Execute" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 17d596f..eec8ffd 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -143,6 +143,9 @@ ThemeEditor: Hint: "编辑主题颜色(仅预览)。" Footbar: + Sftp: + Items: "%{count} 个项目" + Selected: "已选择 %{count} 个" Tooltip: Sessions: "会话" MultiExecute: "批量执行" diff --git a/termua/src/app_state.rs b/termua/src/app_state.rs index 10b3856..02c6293 100644 --- a/termua/src/app_state.rs +++ b/termua/src/app_state.rs @@ -12,7 +12,7 @@ pub(crate) struct SshParams { pub(crate) opts: SshOptions, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub(crate) struct SerialParams { pub(crate) name: String, pub(crate) port: String, diff --git a/termua/src/assistant.rs b/termua/src/assistant.rs index 4e0d39e..0e7fbb5 100644 --- a/termua/src/assistant.rs +++ b/termua/src/assistant.rs @@ -13,7 +13,7 @@ Rules: - When suggesting terminal commands, put them in a single fenced code block (```sh ... ```). - Otherwise, reply in plain text. Keep it concise."#; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub enum AssistantRole { User, Assistant, diff --git a/termua/src/cast_player.rs b/termua/src/cast_player.rs index 33aabe5..54bd895 100644 --- a/termua/src/cast_player.rs +++ b/termua/src/cast_player.rs @@ -1,4 +1,5 @@ use std::{ + borrow::Cow, io::{self, BufRead, Write}, time::{Duration, Instant}, }; @@ -7,6 +8,41 @@ use anyhow::Context; use crate::env::{CAST_PLAYER_ENV_MODE, CAST_PLAYER_ENV_PATH, CAST_PLAYER_ENV_SPEED}; +#[cfg(unix)] +struct PlaybackTtyGuard { + fd: std::os::fd::RawFd, + original: libc::termios, +} + +#[cfg(unix)] +impl PlaybackTtyGuard { + fn disable(fd: std::os::fd::RawFd) -> io::Result> { + if unsafe { libc::isatty(fd) } == 0 { + return Ok(None); + } + + let mut original = std::mem::MaybeUninit::::uninit(); + if unsafe { libc::tcgetattr(fd, original.as_mut_ptr()) } != 0 { + return Err(io::Error::last_os_error()); + } + let original = unsafe { original.assume_init() }; + let mut playback = original; + playback.c_lflag &= !(libc::ECHO | libc::ECHONL); + if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &playback) } != 0 { + return Err(io::Error::last_os_error()); + } + + Ok(Some(Self { fd, original })) + } +} + +#[cfg(unix)] +impl Drop for PlaybackTtyGuard { + fn drop(&mut self) { + let _ = unsafe { libc::tcsetattr(self.fd, libc::TCSANOW, &self.original) }; + } +} + #[derive(Debug, Clone, PartialEq)] enum CastEvent { Output { t: f64, text: String }, @@ -29,6 +65,44 @@ impl Default for PlayOptions { } } +// Fish briefly paints this width-dependent sequence while checking whether command output ended +// with a newline. It is erased in the live terminal, but can survive playback at another width. +fn strip_fish_newline_probes(text: &str) -> Cow<'_, str> { + const PREFIX: &str = "\x1b[2m⏎\x1b[m"; + const SUFFIX: &str = "\r⏎ \r\x1b[K"; + + let mut search_from = 0; + let mut last_kept = 0; + let mut output = None::; + + while let Some(relative_start) = text[search_from..].find(PREFIX) { + let start = search_from + relative_start; + let padding_start = start + PREFIX.len(); + let Some(relative_end) = text[padding_start..].find(SUFFIX) else { + break; + }; + let end = padding_start + relative_end; + let padding = &text[padding_start..end]; + + if !padding.is_empty() && padding.bytes().all(|byte| byte == b' ') { + let output = output.get_or_insert_with(|| String::with_capacity(text.len())); + output.push_str(&text[last_kept..start]); + search_from = end + SUFFIX.len(); + last_kept = search_from; + } else { + search_from = padding_start; + } + } + + match output { + Some(mut output) => { + output.push_str(&text[last_kept..]); + Cow::Owned(output) + } + None => Cow::Borrowed(text), + } +} + fn parse_event_line(line: &str) -> anyhow::Result { let v: serde_json::Value = serde_json::from_str(line).with_context(|| format!("invalid cast event json: {line:?}"))?; @@ -78,6 +152,10 @@ pub fn play_cast( return Err(anyhow::anyhow!("speed must be > 0")); } + #[cfg(unix)] + // Cast output may query the host terminal; do not echo its replies as visible `^[...` text. + let _tty_guard = PlaybackTtyGuard::disable(libc::STDIN_FILENO)?; + let mut header = String::new(); let n = reader.read_line(&mut header)?; if n == 0 { @@ -127,7 +205,7 @@ pub fn play_cast( match ev { CastEvent::Output { text, .. } => { - writer.write_all(text.as_bytes())?; + writer.write_all(strip_fish_newline_probes(&text).as_bytes())?; writer.flush()?; } CastEvent::Input { .. } => {} @@ -136,7 +214,7 @@ pub fn play_cast( } // Playback finished marker. - writer.write_all(b"\r\n[Recorder] Playback finished.\r\n")?; + writer.write_all(b"\r\n[Recorder] Playback finished.")?; writer.flush()?; // Playback finished: stop cursor blinking and hide it, so the recorder tab looks "frozen". @@ -205,8 +283,65 @@ pub fn try_run_from_env() -> anyhow::Result { mod tests { use super::*; + #[cfg(unix)] + #[test] + fn playback_tty_guard_ignores_non_tty() { + use std::os::fd::AsRawFd as _; + + let dev_null = std::fs::File::open("/dev/null").unwrap(); + assert!( + PlaybackTtyGuard::disable(dev_null.as_raw_fd()) + .unwrap() + .is_none() + ); + } + + #[cfg(unix)] + #[test] + fn playback_tty_guard_disables_echo_and_restores_termios() { + let mut master = -1; + let mut slave = -1; + let result = unsafe { + libc::openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + assert_eq!(result, 0); + + let mut original = std::mem::MaybeUninit::::uninit(); + assert_eq!(unsafe { libc::tcgetattr(slave, original.as_mut_ptr()) }, 0); + let mut original = unsafe { original.assume_init() }; + original.c_lflag |= libc::ECHO | libc::ECHONL; + assert_eq!( + unsafe { libc::tcsetattr(slave, libc::TCSANOW, &original) }, + 0 + ); + + { + let _guard = PlaybackTtyGuard::disable(slave).unwrap().unwrap(); + let mut current = std::mem::MaybeUninit::::uninit(); + assert_eq!(unsafe { libc::tcgetattr(slave, current.as_mut_ptr()) }, 0); + let current = unsafe { current.assume_init() }; + assert_eq!(current.c_lflag & (libc::ECHO | libc::ECHONL), 0); + } + + let mut restored = std::mem::MaybeUninit::::uninit(); + assert_eq!(unsafe { libc::tcgetattr(slave, restored.as_mut_ptr()) }, 0); + let restored = unsafe { restored.assume_init() }; + assert_eq!(restored.c_lflag, original.c_lflag); + + unsafe { + libc::close(master); + libc::close(slave); + } + } + #[test] - fn play_cast_writes_output_in_order_without_sleep() { + fn play_cast_does_not_add_blank_line_after_finished_marker() { let input = r#"{"version":2,"width":80,"height":24,"timestamp":0} [0.0,"o","hi"] [0.1,"o"," there"] @@ -224,9 +359,44 @@ mod tests { assert_eq!( String::from_utf8(out).unwrap(), format!( - "hi there\r\n[Recorder] Playback finished.\r\n{}", + "hi there\r\n[Recorder] Playback finished.{}", "\u{1b}[?12l\u{1b}[?25l" ) ); } + + #[test] + fn play_cast_removes_only_complete_fish_newline_probes() { + let genuine_output = serde_json::to_string(&(0.0, "o", "user output: ⏎\r\n")).unwrap(); + let fish_probe = serde_json::to_string(&( + 0.1, + "o", + "\x1b]133;D;0\x07\x1b[?25h\x1b[2m⏎\x1b[m \r⏎ \r\x1b[K", + )) + .unwrap(); + let input = format!( + "{{\"version\":2,\"width\":6,\"height\":2,\"timestamp\":0}}\n{genuine_output}\n{fish_probe}\n" + ); + + let mut out = Vec::::new(); + play_cast( + io::BufReader::new(input.as_bytes()), + &mut out, + PlayOptions { + speed: 1.0, + sleep: false, + }, + ) + .unwrap(); + + assert_eq!( + String::from_utf8(out).unwrap(), + concat!( + "user output: ⏎\r\n", + "\x1b]133;D;0\x07\x1b[?25h", + "\r\n[Recorder] Playback finished.", + "\x1b[?12l\x1b[?25l", + ) + ); + } } diff --git a/termua/src/footbar/mod.rs b/termua/src/footbar/mod.rs index 95b2054..5340953 100644 --- a/termua/src/footbar/mod.rs +++ b/termua/src/footbar/mod.rs @@ -1,6 +1,6 @@ use gpui::{ App, Context, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, - Subscription, Window, div, prelude::FluentBuilder as _, px, + StyledImage, Subscription, Window, div, img, prelude::FluentBuilder as _, px, }; use gpui_common::{TermuaIcon, format_bytes}; use gpui_component::{ @@ -8,6 +8,8 @@ use gpui_component::{ button::{Button, ButtonVariants as _}, h_flex, }; +use gpui_sftp::SftpStatus; +use gpui_term::TerminalType; use gpui_transfer::TransferCenterState; use rust_i18n::t; @@ -21,31 +23,208 @@ use crate::{ mod transfers; +#[derive(Clone, Copy)] +struct FocusedTerminalBackend { + panel_id: usize, + backend: TerminalType, +} + +#[derive(Clone, Copy, Default)] +pub(crate) struct FocusedTerminalBackendState(Option); + +impl gpui::Global for FocusedTerminalBackendState {} + +impl FocusedTerminalBackendState { + pub(crate) fn focused(panel_id: usize, backend: TerminalType) -> Self { + Self(Some(FocusedTerminalBackend { panel_id, backend })) + } + + fn blur(&mut self, panel_id: usize) -> bool { + if self.0.is_some_and(|focused| focused.panel_id == panel_id) { + *self = Self::default(); + true + } else { + false + } + } + + pub(crate) fn backend(&self) -> Option { + self.0.map(|focused| focused.backend) + } +} + +#[derive(Clone, Copy)] +struct FocusedSftpStatus { + panel_id: gpui::EntityId, + status: SftpStatus, +} + +#[derive(Clone, Copy, Default)] +pub(crate) struct FocusedSftpStatusState(Option); + +impl gpui::Global for FocusedSftpStatusState {} + +impl FocusedSftpStatusState { + pub(crate) fn focused(panel_id: gpui::EntityId, status: SftpStatus) -> Self { + Self(Some(FocusedSftpStatus { panel_id, status })) + } + + fn status(&self) -> Option { + self.0.map(|focused| focused.status) + } + + fn update(&mut self, panel_id: gpui::EntityId, status: SftpStatus) -> bool { + let Some(focused) = self + .0 + .as_mut() + .filter(|focused| focused.panel_id == panel_id) + else { + return false; + }; + focused.status = status; + true + } + + fn blur(&mut self, panel_id: gpui::EntityId) -> bool { + if self.0.is_some_and(|focused| focused.panel_id == panel_id) { + *self = Self::default(); + true + } else { + false + } + } +} + +pub(crate) fn focus_sftp_status( + panel_id: gpui::EntityId, + status: SftpStatus, + cx: &mut Context, +) { + clear_terminal_backend(cx); + cx.set_global(FocusedSftpStatusState::focused(panel_id, status)); +} + +pub(crate) fn update_sftp_status( + panel_id: gpui::EntityId, + status: SftpStatus, + cx: &mut Context, +) { + let Some(mut state) = cx.try_global::().copied() else { + return; + }; + if state.update(panel_id, status) { + cx.set_global(state); + } +} + +pub(crate) fn blur_sftp_status(panel_id: gpui::EntityId, cx: &mut Context) { + let Some(mut state) = cx.try_global::().copied() else { + return; + }; + if state.blur(panel_id) { + cx.set_global(state); + } +} + +fn clear_sftp_status(cx: &mut Context) { + if cx + .try_global::() + .is_some_and(|state| state.status().is_some()) + { + cx.set_global(FocusedSftpStatusState::default()); + } +} + +pub(crate) fn focus_terminal_backend( + panel_id: usize, + backend: TerminalType, + cx: &mut Context, +) { + clear_sftp_status(cx); + cx.set_global(FocusedTerminalBackendState::focused(panel_id, backend)); +} + +pub(crate) fn blur_terminal_backend(panel_id: usize, cx: &mut Context) { + let Some(mut state) = cx.try_global::().copied() else { + return; + }; + if state.blur(panel_id) { + cx.set_global(state); + } +} + +fn clear_terminal_backend(cx: &mut Context) { + if cx + .try_global::() + .is_some_and(|state| state.backend().is_some()) + { + cx.set_global(FocusedTerminalBackendState::default()); + } +} + pub(crate) struct FootbarView { _observe_app_state: Subscription, _observe_messages: Subscription, _observe_right_sidebar: Subscription, _observe_transfers: Subscription, + _observe_terminal_backend: Subscription, + _observe_sftp_status: Subscription, transfers_open: bool, } impl FootbarView { + fn backend_icon(backend: TerminalType) -> (TermuaIcon, &'static str) { + match backend { + TerminalType::Alacritty => (TermuaIcon::Alacritty, "Alacritty"), + TerminalType::WezTerm => (TermuaIcon::Wezterm, "WezTerm"), + } + } + + fn render_backend_indicator(backend: TerminalType) -> gpui::AnyElement { + let (icon, tooltip) = Self::backend_icon(backend); + Button::new("termua-footbar-backend-button") + .xsmall() + .compact() + .ghost() + .tooltip(tooltip) + .debug_selector(|| "termua-footbar-backend".to_string()) + .child( + div() + .debug_selector(|| "termua-footbar-backend-image".to_string()) + .child( + img(icon) + .w(px(16.)) + .h(px(16.)) + .flex_shrink_0() + .object_fit(gpui::ObjectFit::Contain), + ), + ) + .into_any_element() + } + pub(crate) fn new(cx: &mut Context) -> Self { ensure_ctx_global_with(cx, lock_screen::LockState::new_default); ensure_ctx_global::(cx); ensure_ctx_global::(cx); ensure_ctx_global::(cx); + ensure_ctx_global::(cx); + ensure_ctx_global::(cx); // Keep the footbar reactive to global state changes. let app_state_sub = cx.observe_global::(|_, cx| cx.notify()); let messages_sub = cx.observe_global::(|_, cx| cx.notify()); let right_sidebar_sub = cx.observe_global::(|_, cx| cx.notify()); let transfers_sub = cx.observe_global::(|_, cx| cx.notify()); + let terminal_backend_sub = + cx.observe_global::(|_, cx| cx.notify()); + let sftp_status_sub = cx.observe_global::(|_, cx| cx.notify()); Self { _observe_app_state: app_state_sub, _observe_messages: messages_sub, _observe_right_sidebar: right_sidebar_sub, _observe_transfers: transfers_sub, + _observe_terminal_backend: terminal_backend_sub, + _observe_sftp_status: sftp_status_sub, transfers_open: false, } } @@ -96,10 +275,14 @@ impl FootbarView { messages_selected: bool, assistant_enabled: bool, assistant_selected: bool, + backend: Option, ) -> gpui::AnyElement { h_flex() .items_center() .gap_1() + .when_some(backend, |this, backend| { + this.child(Self::render_backend_indicator(backend)) + }) .child( Button::new("termua-footbar-issues-link") .xsmall() @@ -176,6 +359,26 @@ impl FootbarView { } } +fn sftp_status_label_for_locale(status: SftpStatus, locale: &str) -> String { + let items = t!("Footbar.Sftp.Items", count = status.items, locale = locale); + if status.selected == 0 { + items.to_string() + } else { + format!( + "{items} - {}", + t!( + "Footbar.Sftp.Selected", + count = status.selected, + locale = locale + ) + ) + } +} + +fn sftp_status_label(status: SftpStatus) -> String { + sftp_status_label_for_locale(status, &rust_i18n::locale()) +} + fn truncate_shared(s: &SharedString, max_chars: usize) -> SharedString { if max_chars == 0 { return "".into(); @@ -228,7 +431,9 @@ impl Render for FootbarView { self.sync_transfers_popup_state(&transfers); let transfers_summary = self.render_transfers_summary(&transfers, cx); + let sftp_status = cx.global::().status(); + let backend = cx.global::().backend(); let left_controls = self.render_controls_left(sessions_visible); let right_controls = self.render_controls_right( enabled, @@ -238,6 +443,7 @@ impl Render for FootbarView { messages_selected, assistant_enabled, assistant_selected, + backend, ); div() @@ -254,8 +460,29 @@ impl Render for FootbarView { .items_center() .justify_between() .child(left_controls) - .child(transfers_summary) - .child(right_controls), + .child( + h_flex() + .flex_1() + .min_w_0() + .items_center() + .justify_end() + .when_some(sftp_status, |this, status| { + this.child( + div() + .debug_selector(|| "termua-footbar-sftp-status".to_string()) + .pr(px(10.0)) + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(sftp_status_label(status)), + ) + }) + .child(transfers_summary) + .child(right_controls), + ), ) } } @@ -277,6 +504,217 @@ mod tests { assert_eq!(FootbarView::multi_exec_icon_path(true), TermuaIcon::Dice4); } + #[test] + fn focused_terminal_backend_ignores_stale_blur() { + let mut state = FocusedTerminalBackendState::focused(2, gpui_term::TerminalType::WezTerm); + + assert!(!state.blur(1)); + assert_eq!(state.backend(), Some(gpui_term::TerminalType::WezTerm)); + + assert!(state.blur(2)); + assert_eq!(state.backend(), None); + } + + #[gpui::test] + fn focusing_terminal_clears_sftp_status(cx: &mut gpui::TestAppContext) { + let sftp_panel_id = cx.new(|_| ()).entity_id(); + let entity = cx.new(|cx| { + cx.set_global(FocusedSftpStatusState::focused( + sftp_panel_id, + SftpStatus::new(4, 1), + )); + focus_terminal_backend(9, TerminalType::WezTerm, cx); + }); + + entity.update(cx, |_, cx| { + assert_eq!(cx.global::().status(), None); + assert_eq!( + cx.global::().backend(), + Some(TerminalType::WezTerm) + ); + }); + } + + #[gpui::test] + fn focusing_sftp_clears_terminal_backend(cx: &mut gpui::TestAppContext) { + let sftp_panel_id = cx.new(|_| ()).entity_id(); + let entity = cx.new(|cx| { + cx.set_global(FocusedTerminalBackendState::focused( + 9, + TerminalType::WezTerm, + )); + focus_sftp_status(sftp_panel_id, SftpStatus::new(4, 1), cx); + }); + + entity.update(cx, |_, cx| { + assert_eq!(cx.global::().backend(), None); + assert_eq!( + cx.global::().status(), + Some(SftpStatus::new(4, 1)) + ); + }); + } + + #[test] + fn sftp_status_label_omits_empty_selection() { + assert_eq!( + sftp_status_label_for_locale(SftpStatus::new(12, 0), "en"), + "12 items" + ); + assert_eq!( + sftp_status_label_for_locale(SftpStatus::new(12, 3), "en"), + "12 items - 3 selected" + ); + assert_eq!( + sftp_status_label_for_locale(SftpStatus::new(12, 3), "zh-CN"), + "12 个项目 - 已选择 3 个" + ); + } + + #[gpui::test] + fn footbar_sftp_status_is_right_aligned_before_controls(cx: &mut gpui::TestAppContext) { + cx.update(|app| { + gpui_component::init(app); + app.activate(true); + app.set_global(TermuaAppState::default()); + let panel_id = app.new(|_| ()).entity_id(); + app.set_global(FocusedSftpStatusState::focused( + panel_id, + SftpStatus::new(12, 3), + )); + app.set_global(TransferCenterState::default()); + app.global_mut::().upsert( + TransferTask::new("sftp-status-layout", "test.bin") + .with_kind(TransferKind::Upload) + .with_status(TransferStatus::InProgress) + .with_progress(TransferProgress::Determinate(0.5)), + ); + }); + + struct Root { + footbar: gpui::Entity, + } + + impl Render for Root { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> impl IntoElement { + div().size_full().child(self.footbar.clone()) + } + } + + let (root, cx) = cx.add_window_view(|_window, cx| Root { + footbar: cx.new(FootbarView::new), + }); + cx.draw( + gpui::point(gpui::px(0.), gpui::px(0.)), + gpui::size( + gpui::AvailableSpace::Definite(gpui::px(800.)), + gpui::AvailableSpace::Definite(gpui::px(200.)), + ), + move |_, _| div().size_full().child(root), + ); + cx.run_until_parked(); + + let status = cx + .debug_bounds("termua-footbar-sftp-status") + .expect("expected SFTP status while an SFTP panel is active"); + let issues = cx + .debug_bounds("termua-footbar-issues") + .expect("expected right-side controls"); + let transfers = cx + .debug_bounds("termua-footbar-transfers-trigger") + .expect("expected transfer progress"); + assert!(status.origin.x < transfers.origin.x); + assert!(transfers.origin.x < issues.origin.x); + assert!(status.origin.x > gpui::px(300.)); + + cx.update(|_, app| app.set_global(FocusedSftpStatusState::default())); + cx.run_until_parked(); + assert!(cx.debug_bounds("termua-footbar-sftp-status").is_none()); + } + + #[gpui::test] + fn blur_without_focused_terminal_state_is_ignored(cx: &mut gpui::TestAppContext) { + let entity = cx.new(|cx| { + blur_terminal_backend(1, cx); + }); + entity.update(cx, |_, cx| blur_terminal_backend(1, cx)); + } + + #[test] + fn footbar_backend_icons_match_terminal_types() { + assert_eq!( + FootbarView::backend_icon(gpui_term::TerminalType::Alacritty), + (TermuaIcon::Alacritty, "Alacritty") + ); + assert_eq!( + FootbarView::backend_icon(gpui_term::TerminalType::WezTerm), + (TermuaIcon::Wezterm, "WezTerm") + ); + } + + #[gpui::test] + fn footbar_backend_icon_tracks_focused_terminal(cx: &mut gpui::TestAppContext) { + cx.update(|app| { + gpui_component::init(app); + app.activate(true); + app.set_global(TermuaAppState::default()); + }); + + struct Root { + footbar: gpui::Entity, + } + + impl Render for Root { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> impl IntoElement { + div().size_full().child(self.footbar.clone()) + } + } + + let (root, cx) = cx.add_window_view(|_window, cx| Root { + footbar: cx.new(FootbarView::new), + }); + cx.draw( + gpui::point(gpui::px(0.), gpui::px(0.)), + gpui::size( + gpui::AvailableSpace::Definite(gpui::px(800.)), + gpui::AvailableSpace::Definite(gpui::px(200.)), + ), + move |_, _| div().size_full().child(root), + ); + cx.run_until_parked(); + assert!(cx.debug_bounds("termua-footbar-backend").is_none()); + + cx.update(|_, app| { + app.set_global(FocusedTerminalBackendState::focused( + 1, + gpui_term::TerminalType::Alacritty, + )); + }); + cx.run_until_parked(); + assert!(cx.debug_bounds("termua-footbar-backend").is_some()); + + cx.update(|_, app| { + app.set_global(FocusedTerminalBackendState::focused( + 2, + gpui_term::TerminalType::WezTerm, + )); + }); + cx.run_until_parked(); + assert!(cx.debug_bounds("termua-footbar-backend").is_some()); + + cx.update(|_, app| app.set_global(FocusedTerminalBackendState::default())); + cx.run_until_parked(); + assert!(cx.debug_bounds("termua-footbar-backend").is_none()); + } + #[gpui::test] fn footbar_colors_match_titlebar(cx: &mut gpui::TestAppContext) { let mut app = cx.app.borrow_mut(); diff --git a/termua/src/footbar/transfers.rs b/termua/src/footbar/transfers.rs index b8b29f7..b682273 100644 --- a/termua/src/footbar/transfers.rs +++ b/termua/src/footbar/transfers.rs @@ -54,7 +54,6 @@ impl FootbarView { div() .flex() - .flex_1() .min_w_0() .items_center() .justify_end() diff --git a/termua/src/main.rs b/termua/src/main.rs index e4cdd7a..b7ec09e 100644 --- a/termua/src/main.rs +++ b/termua/src/main.rs @@ -25,6 +25,7 @@ mod ssh; mod static_suggestions; mod theme_manager; mod window; +mod workspace; pub(crate) use app_state::{PendingCommand, SerialParams, SshParams, TermuaAppState}; pub(crate) use menu::{ diff --git a/termua/src/menu.rs b/termua/src/menu.rs index fc2091e..bf09f1a 100644 --- a/termua/src/menu.rs +++ b/termua/src/menu.rs @@ -256,7 +256,7 @@ pub(crate) fn bind_menu_shortcuts(cx: &mut App) { KeyBinding::new("ctrl-n", NewLocalTerminal, None), KeyBinding::new("ctrl-q", Quit, None), KeyBinding::new("ctrl-,", OpenSettings, None), - KeyBinding::new("ctrl-shift-a", ToggleAssistantSidebar, None), + KeyBinding::new("ctrl-shift-a", ToggleAssistantSidebar, Some("!Terminal")), KeyBinding::new("ctrl-shift-m", ToggleMessagesSidebar, None), ]); @@ -266,7 +266,7 @@ pub(crate) fn bind_menu_shortcuts(cx: &mut App) { KeyBinding::new("cmd-n", NewLocalTerminal, None), KeyBinding::new("cmd-q", Quit, None), KeyBinding::new("cmd-,", OpenSettings, None), - KeyBinding::new("cmd-shift-a", ToggleAssistantSidebar, None), + KeyBinding::new("cmd-shift-a", ToggleAssistantSidebar, Some("!Terminal")), KeyBinding::new("cmd-shift-m", ToggleMessagesSidebar, None), ]); } diff --git a/termua/src/panel/assistant_panel.rs b/termua/src/panel/assistant_panel.rs index a771485..e5f9598 100644 --- a/termua/src/panel/assistant_panel.rs +++ b/termua/src/panel/assistant_panel.rs @@ -29,6 +29,36 @@ use crate::assistant::{ const PROMPT_KEY_CONTEXT: &str = "termua_assistant_prompt"; const MAX_SCROLL_TO_BOTTOM_RETRY_ATTEMPTS: u8 = 4; +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub(crate) struct AssistantPanelState { + pub(crate) messages: Vec, + pub(crate) draft: String, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub(crate) struct AssistantPanelMessageState { + pub(crate) role: AssistantRole, + pub(crate) content: String, +} + +fn persisted_messages(state: &AssistantState) -> Vec { + let mut messages = state.messages.clone(); + if state.in_flight + && messages + .last() + .is_some_and(|message| message.role == AssistantRole::User) + { + messages.pop(); + } + messages + .into_iter() + .map(|message| AssistantPanelMessageState { + role: message.role, + content: message.content.to_string(), + }) + .collect() +} + #[derive(gpui::Action, Clone, PartialEq, Eq, Deserialize)] #[action(namespace = termua, no_json)] pub(crate) struct AssistantSend; @@ -73,6 +103,35 @@ fn set_input_placeholder( } impl AssistantPanelView { + pub(crate) fn persisted_state(&self, cx: &App) -> AssistantPanelState { + let assistant = cx.global::(); + AssistantPanelState { + messages: persisted_messages(assistant), + draft: self.prompt_input.read(cx).value().to_string(), + } + } + + pub(crate) fn restore_persisted_state( + &mut self, + state: AssistantPanelState, + window: &mut Window, + cx: &mut Context, + ) { + let assistant = cx.global_mut::(); + assistant.clear(); + assistant.messages = state + .messages + .into_iter() + .map(|message| AssistantMessage { + role: message.role, + content: message.content.into(), + }) + .collect(); + self.prompt_input + .update(cx, |input, cx| input.set_value(state.draft, window, cx)); + cx.notify(); + } + fn prompt_has_sendable_text(prompt: &str) -> bool { !prompt.trim().is_empty() } @@ -1154,6 +1213,18 @@ mod tests { assert!(!AssistantPanelView::send_button_disabled(true, false, "")); } + #[test] + fn persisted_state_omits_pending_user_message() { + let mut state = AssistantState::default(); + state.push(AssistantRole::Assistant, "completed"); + state.push(AssistantRole::User, "pending"); + state.in_flight = true; + + let persisted = persisted_messages(&state); + assert_eq!(persisted.len(), 1); + assert_eq!(persisted[0].content, "completed"); + } + #[test] fn scroll_to_bottom_retry_continues_while_max_offset_grows() { let state = ScrollToBottomRetryState { diff --git a/termua/src/panel/mod.rs b/termua/src/panel/mod.rs index a34f1b0..1d4b260 100644 --- a/termua/src/panel/mod.rs +++ b/termua/src/panel/mod.rs @@ -6,9 +6,16 @@ pub mod sftp_panel; pub mod ssh_error_panel; pub mod terminal_panel; +pub(crate) const RIGHT_SIDEBAR_PANEL_NAME: &str = "termua.right_sidebar"; +pub(crate) const SESSIONS_SIDEBAR_PANEL_NAME: &str = "termua.sessions_sidebar"; +pub(crate) const SFTP_PANEL_NAME: &str = "termua.sftp_dock_panel"; +pub(crate) const SSH_ERROR_PANEL_NAME: &str = "SshErrorPanel"; +pub(crate) const TERMINAL_PANEL_NAME: &str = "TerminalPanel"; + pub(crate) use right_sidebar::RightSidebarView; pub(crate) use sessions_sidebar::{SessionsSidebarEvent, SessionsSidebarView}; pub(crate) use ssh_error_panel::SshErrorPanel; pub(crate) use terminal_panel::{ - PanelKind, TerminalPanel, local_terminal_panel_tab_name, terminal_panel_tab_name, + PanelKind, TerminalLaunchState, TerminalPanel, TerminalPanelState, + local_terminal_panel_tab_name, terminal_panel_tab_name, }; diff --git a/termua/src/panel/right_sidebar.rs b/termua/src/panel/right_sidebar.rs index 1835919..7a410d1 100644 --- a/termua/src/panel/right_sidebar.rs +++ b/termua/src/panel/right_sidebar.rs @@ -3,7 +3,7 @@ use gpui::{ ParentElement as _, Render, Styled as _, Subscription, Window, div, }; use gpui_component::{ActiveTheme, v_flex}; -use gpui_dock::{Panel, PanelControl, PanelEvent}; +use gpui_dock::{Panel, PanelControl, PanelEvent, PanelInfo, PanelState}; use crate::{ globals::ensure_ctx_global, @@ -18,6 +18,13 @@ pub struct RightSidebarView { _subscriptions: Vec, } +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub(crate) struct RightSidebarPanelState { + version: usize, + active_tab: RightSidebarTab, + assistant: crate::panel::assistant_panel::AssistantPanelState, +} + impl gpui::EventEmitter for RightSidebarView {} impl Focusable for RightSidebarView { @@ -46,12 +53,27 @@ impl RightSidebarView { } } + pub(crate) fn restore_persisted_state( + &mut self, + state: RightSidebarPanelState, + window: &mut Window, + cx: &mut Context, + ) { + if state.version != 1 { + return; + } + cx.global_mut::().active_tab = state.active_tab; + self.assistant.update(cx, |assistant, cx| { + assistant.restore_persisted_state(state.assistant, window, cx) + }); + } + // Intentionally no local tab bar: switching happens via the app-level toggle actions. } impl Panel for RightSidebarView { fn panel_name(&self) -> &'static str { - "termua.right_sidebar" + super::RIGHT_SIDEBAR_PANEL_NAME } fn tab_name(&self, _cx: &App) -> Option { @@ -73,6 +95,19 @@ impl Panel for RightSidebarView { fn inner_padding(&self, _cx: &App) -> bool { false } + + fn dump(&self, cx: &App) -> PanelState { + let mut state = PanelState::new(self); + state.info = PanelInfo::panel( + serde_json::to_value(RightSidebarPanelState { + version: 1, + active_tab: cx.global::().active_tab, + assistant: self.assistant.read(cx).persisted_state(cx), + }) + .expect("right sidebar state should serialize"), + ); + state + } } impl Render for RightSidebarView { @@ -164,4 +199,39 @@ mod tests { "expected right sidebar not to render the full tab bar" ); } + + #[gpui::test] + fn right_sidebar_ignores_unsupported_persisted_state(cx: &mut gpui::TestAppContext) { + cx.update(|app| { + init_test_app(app); + app.set_global(crate::right_sidebar::RightSidebarState { + active_tab: crate::right_sidebar::RightSidebarTab::Assistant, + ..Default::default() + }); + }); + + let (sidebar, window_cx) = + cx.add_window_view(|window, cx| RightSidebarView::new(window, cx)); + window_cx.update(|window, cx| { + sidebar.update(cx, |sidebar, cx| { + sidebar.restore_persisted_state( + RightSidebarPanelState { + version: usize::MAX, + active_tab: crate::right_sidebar::RightSidebarTab::Notifications, + assistant: crate::panel::assistant_panel::AssistantPanelState { + messages: Vec::new(), + draft: "ignored".to_string(), + }, + }, + window, + cx, + ); + }); + assert_eq!( + cx.global::() + .active_tab, + crate::right_sidebar::RightSidebarTab::Assistant + ); + }); + } } diff --git a/termua/src/panel/sessions_sidebar/actions.rs b/termua/src/panel/sessions_sidebar/actions.rs index 6f5aa13..06e1272 100644 --- a/termua/src/panel/sessions_sidebar/actions.rs +++ b/termua/src/panel/sessions_sidebar/actions.rs @@ -20,6 +20,22 @@ fn set_input_placeholder( } impl SessionsSidebarView { + pub(crate) fn restore_persisted_state( + &mut self, + state: super::SessionsSidebarPanelState, + window: &mut Window, + cx: &mut Context, + ) { + if state.version != 1 { + return; + } + self.query = state.query.clone(); + self.search_input + .update(cx, |input, cx| input.set_value(state.query, window, cx)); + self.rebuild_tree(window, cx); + cx.notify(); + } + pub fn new(window: &mut Window, cx: &mut Context) -> Self { crate::settings::ensure_language_state_with_default(crate::settings::Language::English, cx); diff --git a/termua/src/panel/sessions_sidebar/mod.rs b/termua/src/panel/sessions_sidebar/mod.rs index dd2d86c..7579153 100644 --- a/termua/src/panel/sessions_sidebar/mod.rs +++ b/termua/src/panel/sessions_sidebar/mod.rs @@ -5,6 +5,7 @@ mod state; mod tree; pub(super) use state::SessionsSidebarError; +pub(crate) use state::SessionsSidebarPanelState; pub use state::{SessionsSidebarEvent, SessionsSidebarView}; #[cfg(test)] diff --git a/termua/src/panel/sessions_sidebar/state.rs b/termua/src/panel/sessions_sidebar/state.rs index 4441565..190591e 100644 --- a/termua/src/panel/sessions_sidebar/state.rs +++ b/termua/src/panel/sessions_sidebar/state.rs @@ -5,7 +5,7 @@ use gpui_component::{ input::InputState, tree::{TreeItem, TreeState}, }; -use gpui_dock::{Panel, PanelEvent}; +use gpui_dock::{Panel, PanelEvent, PanelInfo, PanelState}; use super::tree::SessionTreeSummary; use crate::store::Session; @@ -51,6 +51,12 @@ pub struct SessionsSidebarView { pub(super) _subscriptions: Vec, } +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub(crate) struct SessionsSidebarPanelState { + pub(crate) version: usize, + pub(crate) query: String, +} + impl EventEmitter for SessionsSidebarView {} impl EventEmitter for SessionsSidebarView {} @@ -62,6 +68,18 @@ impl Focusable for SessionsSidebarView { impl Panel for SessionsSidebarView { fn panel_name(&self) -> &'static str { - "termua.sessions_sidebar" + crate::panel::SESSIONS_SIDEBAR_PANEL_NAME + } + + fn dump(&self, _cx: &App) -> PanelState { + let mut state = PanelState::new(self); + state.info = PanelInfo::panel( + serde_json::to_value(SessionsSidebarPanelState { + version: 1, + query: self.query.clone(), + }) + .expect("sessions sidebar state should serialize"), + ); + state } } diff --git a/termua/src/panel/sftp_panel.rs b/termua/src/panel/sftp_panel.rs index 077b8b8..00a1202 100644 --- a/termua/src/panel/sftp_panel.rs +++ b/termua/src/panel/sftp_panel.rs @@ -5,16 +5,26 @@ use gpui::{ IntoElement, ParentElement, Render, Styled, Subscription, Window, div, }; use gpui_common::TermuaIcon; -use gpui_dock::{Panel, PanelEvent, PanelView, TabIcon}; +use gpui_dock::{Panel, PanelEvent, PanelInfo, PanelState, PanelView, TabIcon}; use gpui_sftp::{SftpEvent, SftpView}; use gpui_term::{Event as TerminalEvent, Terminal, TerminalView}; use crate::{lock_screen::LockState, notification}; +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub(crate) struct SftpPanelState { + pub(crate) version: usize, + pub(crate) tab_label: String, + pub(crate) terminal_id: usize, + pub(crate) current_dir: Option, +} + pub struct SftpDockPanel { tab_label: gpui::SharedString, + terminal_id: usize, focus_handle: FocusHandle, - sftp_view: gpui::Entity, + sftp_view: Option>, + restored_current_dir: Option, _subscriptions: Vec, } @@ -40,9 +50,31 @@ fn sftp_event_message(event: &SftpEvent) -> Option<(notification::MessageKind, S } impl SftpDockPanel { + fn subscribe_footbar_status( + sftp_view: &gpui::Entity, + window: &mut Window, + cx: &mut Context, + ) -> [Subscription; 2] { + let status_sub = cx.observe_in(sftp_view, window, |_this, view, _window, cx| { + let status = view.read(cx).status(cx); + crate::footbar::update_sftp_status(cx.entity_id(), status, cx); + }); + let sftp_focus = sftp_view.read(cx).focus_handle(cx); + let focus_in_sub = cx.on_focus_in(&sftp_focus, window, { + let sftp_view = sftp_view.clone(); + move |_this, _window, cx| { + let status = sftp_view.read(cx).status(cx); + crate::footbar::focus_sftp_status(cx.entity_id(), status, cx); + } + }); + + [status_sub, focus_in_sub] + } + pub fn open_for_terminal_view( terminal_view: gpui::Entity, tab_label: gpui::SharedString, + terminal_id: usize, window: &mut Window, cx: &mut Context, ) -> anyhow::Result> { @@ -52,7 +84,7 @@ impl SftpDockPanel { let terminal: gpui::Entity = terminal_view.read(cx).terminal.clone(); - let panel = cx.new(|cx| { + let panel = cx.new(|cx: &mut Context| { let focus_handle = cx.focus_handle(); let sftp_view = cx.new(|cx| SftpView::new(sftp, window, cx)); @@ -72,17 +104,83 @@ impl SftpDockPanel { notification::record(kind, message, cx); } }); + let footbar_subs = Self::subscribe_footbar_status(&sftp_view, window, cx); + + let mut subscriptions = vec![terminal_sub, toast_sub]; + subscriptions.extend(footbar_subs); Self { tab_label, + terminal_id, focus_handle, - sftp_view, - _subscriptions: vec![terminal_sub, toast_sub], + sftp_view: Some(sftp_view), + restored_current_dir: None, + _subscriptions: subscriptions, } }); Ok(Arc::new(panel) as Arc) } + + pub(crate) fn restoring(state: SftpPanelState, cx: &mut Context) -> Self { + Self { + tab_label: state.tab_label.into(), + terminal_id: state.terminal_id, + focus_handle: cx.focus_handle(), + sftp_view: None, + restored_current_dir: state.current_dir, + _subscriptions: Vec::new(), + } + } + + pub(crate) fn terminal_id(&self) -> usize { + self.terminal_id + } + + pub(crate) fn is_connected(&self) -> bool { + self.sftp_view.is_some() + } + + pub(crate) fn connect_to_terminal_view( + &mut self, + terminal_view: gpui::Entity, + window: &mut Window, + cx: &mut Context, + ) -> anyhow::Result<()> { + let Some(sftp) = terminal_view.read(cx).terminal.read(cx).sftp() else { + anyhow::bail!("SFTP is only available for SSH terminals"); + }; + let terminal: gpui::Entity = terminal_view.read(cx).terminal.clone(); + let sftp_view = cx.new(|cx| SftpView::new(sftp, window, cx)); + if let Some(dir) = self.restored_current_dir.take() { + sftp_view.update(cx, |view, cx| view.change_dir(dir, cx)); + } + + self._subscriptions + .push(cx.subscribe_in(&terminal, window, { + let sftp_view = sftp_view.clone(); + move |_, _terminal, ev, _window, cx| { + if matches!(ev, TerminalEvent::CloseTerminal) { + sftp_view.update(cx, |view, cx| view.disconnect(cx)); + } + } + })); + self._subscriptions.push(cx.subscribe_in( + &sftp_view, + window, + move |_, _sftp_view, ev, _window, cx| { + let Some((kind, message)) = sftp_event_message(ev) else { + return; + }; + notification::record(kind, message, cx); + }, + )); + self._subscriptions + .extend(Self::subscribe_footbar_status(&sftp_view, window, cx)); + self.sftp_view = Some(sftp_view); + cx.notify(); + Ok(()) + } } impl EventEmitter for SftpDockPanel {} @@ -95,7 +193,11 @@ impl Focusable for SftpDockPanel { impl Panel for SftpDockPanel { fn panel_name(&self) -> &'static str { - "termua.sftp_dock_panel" + super::SFTP_PANEL_NAME + } + + fn persistable(&self, _cx: &App) -> bool { + false } fn tab_icon(&self, _cx: &App) -> Option { @@ -114,10 +216,39 @@ impl Panel for SftpDockPanel { fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) { if active { - let focus = self.sftp_view.read(cx).focus_handle(cx); - window.focus(&focus, cx); + if let Some(sftp_view) = &self.sftp_view { + let status = sftp_view.read(cx).status(cx); + crate::footbar::focus_sftp_status(cx.entity_id(), status, cx); + let focus = sftp_view.read(cx).focus_handle(cx); + window.focus(&focus, cx); + } + } else { + crate::footbar::blur_sftp_status(cx.entity_id(), cx); } } + + fn on_removed(&mut self, _window: &mut Window, cx: &mut Context) { + crate::footbar::blur_sftp_status(cx.entity_id(), cx); + } + + fn dump(&self, cx: &App) -> PanelState { + let current_dir = self + .sftp_view + .as_ref() + .and_then(|view| view.read(cx).current_dir(cx)) + .or_else(|| self.restored_current_dir.clone()); + let mut state = PanelState::new(self); + state.info = PanelInfo::panel( + serde_json::to_value(SftpPanelState { + version: 1, + tab_label: self.tab_label.to_string(), + terminal_id: self.terminal_id, + current_dir, + }) + .expect("sftp panel state should serialize"), + ); + state + } } impl Render for SftpDockPanel { @@ -143,14 +274,98 @@ impl Render for SftpDockPanel { cx.global::().report_activity(); } })) - .child(self.sftp_view.clone()) + .child(match &self.sftp_view { + Some(view) => div().size_full().child(view.clone()), + None => div() + .size_full() + .flex() + .items_center() + .justify_center() + .child("Waiting for SSH connection..."), + }) } } #[cfg(test)] mod tests { + use gpui::AppContext as _; + use gpui_dock::{Panel as _, PanelInfo}; + use super::*; + #[gpui::test] + fn sftp_panel_is_not_persistable(cx: &mut gpui::TestAppContext) { + let panel = cx.new(|cx| { + SftpDockPanel::restoring( + SftpPanelState { + version: 1, + tab_label: "prod".to_string(), + terminal_id: 42, + current_dir: None, + }, + cx, + ) + }); + + assert!(!panel.read_with(cx, |panel, app| panel.persistable(app))); + } + + #[test] + fn sftp_panel_state_round_trips_terminal_link_and_directory() { + let state = SftpPanelState { + version: 1, + tab_label: "prod".to_string(), + terminal_id: 42, + current_dir: Some("/srv/app".to_string()), + }; + + let json = serde_json::to_value(&state).expect("serialize sftp panel state"); + let restored: SftpPanelState = + serde_json::from_value(json).expect("deserialize sftp panel state"); + + assert_eq!(restored, state); + } + + #[gpui::test] + fn restoring_sftp_panel_exposes_metadata_and_dumps_directory(cx: &mut gpui::TestAppContext) { + let panel = cx.new(|cx| { + SftpDockPanel::restoring( + SftpPanelState { + version: 1, + tab_label: "prod".to_string(), + terminal_id: 42, + current_dir: Some("/srv/app".to_string()), + }, + cx, + ) + }); + + assert_eq!(panel.read_with(cx, |panel, _| panel.terminal_id()), 42); + assert!(!panel.read_with(cx, |panel, _| panel.is_connected())); + assert_eq!( + panel.read_with(cx, |panel, app| panel.tab_name(app)), + Some("prod".into()) + ); + assert_eq!( + panel.read_with(cx, |panel, _| panel.panel_name()), + super::super::SFTP_PANEL_NAME + ); + + let dumped = panel.read_with(cx, |panel, app| panel.dump(app)); + let PanelInfo::Panel(value) = dumped.info else { + panic!("expected sftp panel state"); + }; + assert_eq!( + serde_json::from_value::(value).unwrap(), + SftpPanelState { + version: 1, + tab_label: "prod".to_string(), + terminal_id: 42, + current_dir: Some("/srv/app".to_string()), + } + ); + } + #[test] fn sftp_toast_event_message_includes_detail_for_message_center() { let event = SftpEvent::Toast { @@ -167,4 +382,30 @@ mod tests { assert!(message.contains("/from/a.txt"), "message={message:?}"); assert!(message.contains("/to/a.txt"), "message={message:?}"); } + + #[test] + fn sftp_toast_event_message_maps_levels_and_ignores_blank_detail() { + let cases = [ + ( + gpui::PromptLevel::Warning, + notification::MessageKind::Warning, + ), + ( + gpui::PromptLevel::Critical, + notification::MessageKind::Error, + ), + ]; + + for (level, expected_kind) in cases { + let event = SftpEvent::Toast { + level, + title: "Transfer failed".to_string(), + detail: Some(" ".to_string()), + }; + assert_eq!( + sftp_event_message(&event), + Some((expected_kind, "Transfer failed".to_string())) + ); + } + } } diff --git a/termua/src/panel/ssh_error_panel.rs b/termua/src/panel/ssh_error_panel.rs index 97025bf..42f1e73 100644 --- a/termua/src/panel/ssh_error_panel.rs +++ b/termua/src/panel/ssh_error_panel.rs @@ -1,16 +1,28 @@ use gpui::{ App, Context, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, - SharedString, Styled, Window, div, + SharedString, Styled, WeakEntity, Window, div, }; use gpui_common::TermuaIcon; use gpui_component::{ActiveTheme as _, v_flex}; -use gpui_dock::{Panel, PanelEvent}; +use gpui_dock::{Panel, PanelEvent, PanelInfo, PanelState, TabPanel}; + +use super::{PanelKind, TerminalLaunchState, TerminalPanelState}; +use crate::panel::terminal_panel::tab_icon_for_terminal_panel; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SshErrorPanelStatus { + Restoring, + Error, +} pub(crate) struct SshErrorPanel { id: usize, tab_label: SharedString, tab_tooltip: Option, message: SharedString, + terminal_state: Option, + status: SshErrorPanelStatus, + parent_tab: Option>, focus_handle: FocusHandle, } @@ -27,9 +39,53 @@ impl SshErrorPanel { tab_label, tab_tooltip, message, + terminal_state: None, + status: SshErrorPanelStatus::Error, + parent_tab: None, focus_handle: cx.focus_handle(), } } + + pub(crate) fn restoring( + state: TerminalPanelState, + message: SharedString, + cx: &mut Context, + ) -> Self { + Self { + id: state.id, + tab_label: state.tab_label.clone().into(), + tab_tooltip: state.tab_tooltip.clone().map(Into::into), + message, + terminal_state: Some(state), + status: SshErrorPanelStatus::Restoring, + parent_tab: None, + focus_handle: cx.focus_handle(), + } + } + + pub(crate) fn with_terminal_error( + state: TerminalPanelState, + message: SharedString, + cx: &mut Context, + ) -> Self { + let mut panel = Self::restoring(state, message, cx); + panel.status = SshErrorPanelStatus::Error; + panel + } + + pub(crate) fn terminal_state(&self) -> Option { + self.terminal_state.clone() + } + + pub(crate) fn parent_tab(&self) -> Option> { + self.parent_tab.clone() + } + + pub(crate) fn set_message(&mut self, message: impl Into, cx: &mut Context) { + self.message = message.into(); + self.status = SshErrorPanelStatus::Error; + cx.notify(); + } } impl Drop for SshErrorPanel { @@ -48,10 +104,22 @@ impl Focusable for SshErrorPanel { impl Panel for SshErrorPanel { fn panel_name(&self) -> &'static str { - "SshErrorPanel" + super::SSH_ERROR_PANEL_NAME } fn tab_icon(&self, _cx: &App) -> Option { + if self.status == SshErrorPanelStatus::Restoring + && let Some(state) = &self.terminal_state + { + let kind = match state.launch { + TerminalLaunchState::Local { .. } => PanelKind::Local, + TerminalLaunchState::Ssh { .. } => PanelKind::Ssh, + TerminalLaunchState::Serial { .. } => PanelKind::Serial, + TerminalLaunchState::Recorder { .. } => PanelKind::Recorder, + }; + return Some(tab_icon_for_terminal_panel(kind)); + } + Some(gpui_dock::TabIcon::Monochrome { path: TermuaIcon::Bug.into(), color: Some(gpui::red()), @@ -74,6 +142,29 @@ impl Panel for SshErrorPanel { fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { self.tab_name(cx).unwrap_or_else(|| "ssh".into()) } + + fn on_added_to( + &mut self, + tab_panel: WeakEntity, + _window: &mut Window, + _cx: &mut Context, + ) { + self.parent_tab = Some(tab_panel); + } + + fn dump(&self, _cx: &App) -> PanelState { + let Some(terminal_state) = self.terminal_state.clone() else { + return PanelState::new(self); + }; + PanelState { + panel_name: super::TERMINAL_PANEL_NAME.to_string(), + children: Vec::new(), + info: PanelInfo::panel( + serde_json::to_value(terminal_state) + .expect("restoring terminal state should serialize"), + ), + } + } } impl Render for SshErrorPanel { @@ -89,3 +180,160 @@ impl Render for SshErrorPanel { .child(self.message.clone()) } } + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, path::PathBuf}; + + use gpui::AppContext as _; + use gpui_dock::{Panel as _, PanelInfo}; + + use super::*; + use crate::panel::{ + PanelKind, TerminalLaunchState, terminal_panel::tab_icon_for_terminal_panel, + }; + + fn terminal_state(launch: TerminalLaunchState) -> TerminalPanelState { + TerminalPanelState { + version: 1, + id: 7, + tab_label: "saved terminal".to_string(), + tab_tooltip: Some("saved tooltip".to_string()), + launch, + } + } + + #[gpui::test] + fn restoring_ssh_panel_uses_ssh_terminal_icon(cx: &mut gpui::TestAppContext) { + let panel = cx.new(|cx| { + SshErrorPanel::restoring( + TerminalPanelState { + version: 1, + id: 1, + tab_label: "ssh".to_string(), + tab_tooltip: None, + launch: TerminalLaunchState::Ssh { + backend_type: gpui_term::TerminalType::WezTerm, + session_id: Some(1), + }, + }, + "Restoring...".into(), + cx, + ) + }); + + assert_eq!( + panel.read_with(cx, |panel, app| panel.tab_icon(app)), + Some(tab_icon_for_terminal_panel(PanelKind::Ssh)) + ); + } + + #[gpui::test] + fn restoring_panels_use_their_terminal_icons(cx: &mut gpui::TestAppContext) { + let cases = [ + ( + TerminalLaunchState::Local { + backend_type: gpui_term::TerminalType::Alacritty, + env: HashMap::new(), + }, + PanelKind::Local, + ), + ( + TerminalLaunchState::Serial { + backend_type: gpui_term::TerminalType::Alacritty, + params: crate::SerialParams { + name: "serial".to_string(), + port: "test".to_string(), + baud: 9600, + data_bits: 8, + parity: crate::store::SerialParity::None, + stop_bits: crate::store::SerialStopBits::One, + flow_control: crate::store::SerialFlowControl::None, + }, + session_id: None, + }, + PanelKind::Serial, + ), + ( + TerminalLaunchState::Recorder { + cast_path: PathBuf::from("recording.cast"), + playback_speed: 1.0, + }, + PanelKind::Recorder, + ), + ]; + + for (launch, kind) in cases { + let panel = cx.new(|cx| { + SshErrorPanel::restoring(terminal_state(launch), "Restoring...".into(), cx) + }); + assert_eq!( + panel.read_with(cx, |panel, app| panel.tab_icon(app)), + Some(tab_icon_for_terminal_panel(kind)) + ); + } + } + + #[gpui::test] + fn setting_message_changes_restoring_panel_to_error(cx: &mut gpui::TestAppContext) { + let panel = cx.new(|cx| { + SshErrorPanel::restoring( + terminal_state(TerminalLaunchState::Ssh { + backend_type: gpui_term::TerminalType::WezTerm, + session_id: Some(1), + }), + "Restoring...".into(), + cx, + ) + }); + + panel.update(cx, |panel, cx| panel.set_message("failed", cx)); + + assert_eq!( + panel.read_with(cx, |panel, app| panel.tab_icon(app)), + Some(gpui_dock::TabIcon::Monochrome { + path: TermuaIcon::Bug.into(), + color: Some(gpui::red()), + }) + ); + } + + #[gpui::test] + fn dump_preserves_restoring_terminal_state(cx: &mut gpui::TestAppContext) { + let state = terminal_state(TerminalLaunchState::Ssh { + backend_type: gpui_term::TerminalType::WezTerm, + session_id: Some(12), + }); + let panel = cx.new(|cx| SshErrorPanel::restoring(state.clone(), "Restoring...".into(), cx)); + + let dumped = panel.read_with(cx, |panel, app| panel.dump(app)); + + assert_eq!(dumped.panel_name, super::super::TERMINAL_PANEL_NAME); + let PanelInfo::Panel(value) = dumped.info else { + panic!("expected terminal panel state"); + }; + assert_eq!( + serde_json::from_value::(value).unwrap(), + state + ); + } + + #[gpui::test] + fn new_error_panel_dumps_its_own_panel_state(cx: &mut gpui::TestAppContext) { + let panel = cx.new(|cx| { + SshErrorPanel::new( + 3, + "failed".into(), + Some("details".into()), + "connection failed".into(), + cx, + ) + }); + + let dumped = panel.read_with(cx, |panel, app| panel.dump(app)); + + assert_eq!(dumped.panel_name, super::super::SSH_ERROR_PANEL_NAME); + assert_eq!(panel.read_with(cx, |panel, _| panel.terminal_state()), None); + assert!(panel.read_with(cx, |panel, _| panel.parent_tab()).is_none()); + } +} diff --git a/termua/src/panel/terminal_panel.rs b/termua/src/panel/terminal_panel.rs index cb0c0fc..332492b 100644 --- a/termua/src/panel/terminal_panel.rs +++ b/termua/src/panel/terminal_panel.rs @@ -7,7 +7,7 @@ use gpui::{ }; use gpui_common::TermuaIcon; use gpui_component::{ActiveTheme, scroll::ScrollableElement}; -use gpui_dock::{Panel, PanelEvent}; +use gpui_dock::{Panel, PanelEvent, PanelInfo, PanelState}; use gpui_term::{TerminalMode, TerminalShutdownPolicy, TerminalView}; use crate::notification::{self, MessageKind}; @@ -56,6 +56,37 @@ pub(crate) enum PanelKind { Recorder, } +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub(crate) enum TerminalLaunchState { + Local { + backend_type: gpui_term::TerminalType, + env: HashMap, + }, + Ssh { + backend_type: gpui_term::TerminalType, + session_id: Option, + }, + Serial { + backend_type: gpui_term::TerminalType, + params: crate::SerialParams, + session_id: Option, + }, + Recorder { + cast_path: PathBuf, + playback_speed: f64, + }, +} + +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub(crate) struct TerminalPanelState { + pub(crate) version: usize, + pub(crate) id: usize, + pub(crate) tab_label: String, + pub(crate) tab_tooltip: Option, + pub(crate) launch: TerminalLaunchState, +} + pub(crate) fn terminal_panel_tab_name(kind: PanelKind, id: usize) -> SharedString { match kind { PanelKind::Local => format!("local {id}").into(), @@ -113,23 +144,37 @@ pub(crate) struct TerminalPanel { kind: PanelKind, tab_label: SharedString, tab_tooltip: Option, + launch_state: Option, terminal_view: gpui::Entity, pending_sftp_upload: Option, } impl TerminalPanel { + #[cfg(test)] pub(crate) fn new( id: usize, kind: PanelKind, tab_label: SharedString, tab_tooltip: Option, terminal_view: gpui::Entity, + ) -> Self { + Self::new_with_launch_state(id, kind, tab_label, tab_tooltip, None, terminal_view) + } + + pub(crate) fn new_with_launch_state( + id: usize, + kind: PanelKind, + tab_label: SharedString, + tab_tooltip: Option, + launch_state: Option, + terminal_view: gpui::Entity, ) -> Self { Self { id, kind, tab_label, tab_tooltip, + launch_state, terminal_view, pending_sftp_upload: None, } @@ -151,6 +196,11 @@ impl TerminalPanel { self.tab_label.clone() } + pub(crate) fn cleanup_runtime_state(id: usize, cx: &mut Context) { + crate::assistant::unregister_terminal_target(cx, id); + crate::footbar::blur_terminal_backend(id, cx); + } + fn terminal_has_sftp(&self, cx: &App) -> bool { self.terminal_view .read(cx) @@ -465,13 +515,22 @@ impl gpui::Focusable for TerminalPanel { impl Panel for TerminalPanel { fn panel_name(&self) -> &'static str { - "TerminalPanel" + super::TERMINAL_PANEL_NAME } fn tab_icon(&self, _cx: &App) -> Option { Some(tab_icon_for_terminal_panel(self.kind)) } + fn set_active(&mut self, active: bool, _window: &mut Window, cx: &mut Context) { + if active { + let backend = self.terminal_view.read(cx).terminal.read(cx).backend_type(); + crate::footbar::focus_terminal_backend(self.id, backend, cx); + } else { + crate::footbar::blur_terminal_backend(self.id, cx); + } + } + fn on_removed(&mut self, _window: &mut Window, _cx: &mut Context) { // This may run during tab drag/drop (detach/attach), so it must not terminate the session. log::debug!("termua: TerminalPanel on_removed (id={})", self.id); @@ -483,7 +542,7 @@ impl Panel for TerminalPanel { self.id ); - crate::assistant::unregister_terminal_target(cx, self.id); + Self::cleanup_runtime_state(self.id, cx); // Ensure the backend releases its PTY/process resources when the tab is explicitly closed. self.terminal_view.update(cx, |terminal_view, cx| { @@ -512,6 +571,24 @@ impl Panel for TerminalPanel { fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { self.tab_name(cx).unwrap_or_else(|| "local".into()) } + + fn dump(&self, _cx: &App) -> PanelState { + let mut state = PanelState::new(self); + let Some(launch) = self.launch_state.clone() else { + return state; + }; + let panel_state = TerminalPanelState { + version: 1, + id: self.id, + tab_label: self.tab_label.to_string(), + tab_tooltip: self.tab_tooltip.as_ref().map(ToString::to_string), + launch, + }; + state.info = PanelInfo::panel( + serde_json::to_value(panel_state).expect("terminal panel state should serialize"), + ); + state + } } impl Render for TerminalPanel { @@ -584,6 +661,26 @@ mod tests { ); } + #[test] + fn terminal_panel_state_round_trips_local_launch_parameters() { + let state = TerminalPanelState { + version: 1, + id: 7, + tab_label: "bash".to_string(), + tab_tooltip: None, + launch: TerminalLaunchState::Local { + backend_type: gpui_term::TerminalType::WezTerm, + env: HashMap::from([("TERMUA_SHELL".to_string(), "bash".to_string())]), + }, + }; + + let json = serde_json::to_value(&state).expect("serialize terminal panel state"); + let restored: TerminalPanelState = + serde_json::from_value(json).expect("deserialize terminal panel state"); + + assert_eq!(restored, state); + } + #[test] fn local_tabs_fall_back_to_local_prefix_without_shell() { let mut counts = HashMap::new(); diff --git a/termua/src/right_sidebar.rs b/termua/src/right_sidebar.rs index de1effa..cdc8f21 100644 --- a/termua/src/right_sidebar.rs +++ b/termua/src/right_sidebar.rs @@ -1,6 +1,6 @@ use gpui::{Pixels, px}; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub enum RightSidebarTab { Notifications, Assistant, diff --git a/termua/src/session/store.rs b/termua/src/session/store.rs index 98e5ae4..e210daa 100644 --- a/termua/src/session/store.rs +++ b/termua/src/session/store.rs @@ -85,20 +85,20 @@ pub enum SshAuthType { Config, } -#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub enum SerialParity { None, Even, Odd, } -#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub enum SerialStopBits { One, Two, } -#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub enum SerialFlowControl { None, Software, diff --git a/termua/src/settings.rs b/termua/src/settings.rs index 963a086..985acea 100644 --- a/termua/src/settings.rs +++ b/termua/src/settings.rs @@ -993,6 +993,11 @@ pub fn override_settings_json_path(path: PathBuf) -> SettingsJsonPathOverrideGua SettingsJsonPathOverrideGuard { prev } } +#[cfg(test)] +pub(crate) fn settings_json_path_is_overridden() -> bool { + SETTINGS_JSON_PATH_OVERRIDE.with(|slot| slot.borrow().is_some()) +} + #[cfg(test)] mod tests { use gpui::px; diff --git a/termua/src/ssh/mod.rs b/termua/src/ssh/mod.rs index 730ec9e..37bc5cb 100644 --- a/termua/src/ssh/mod.rs +++ b/termua/src/ssh/mod.rs @@ -5,11 +5,25 @@ use std::{ sync::Arc, }; -use gpui::SharedString; -use gpui_term::{Authentication, SshOptions, TerminalBuilder, TerminalType}; +use gpui::{Context, SharedString}; +use gpui_term::{Authentication, SshOptions, Terminal, TerminalBuilder, TerminalType}; + +pub(crate) trait SshTerminalFactory: Send { + fn build(self: Box, cx: &mut Context) -> Terminal; +} + +impl SshTerminalFactory for TerminalBuilder { + fn build(self: Box, cx: &mut Context) -> Terminal { + (*self).subscribe(cx) + } +} pub(crate) type SshTerminalBuilderFn = Arc< - dyn Fn(TerminalType, HashMap, SshOptions) -> anyhow::Result + dyn Fn( + TerminalType, + HashMap, + SshOptions, + ) -> anyhow::Result> + Send + Sync, >; diff --git a/termua/src/window/main_window/actions/sftp.rs b/termua/src/window/main_window/actions/sftp.rs index 858480f..bea9865 100644 --- a/termua/src/window/main_window/actions/sftp.rs +++ b/termua/src/window/main_window/actions/sftp.rs @@ -167,6 +167,10 @@ impl TermuaWindow { window: &mut Window, cx: &mut Context, ) { + if let Ok(terminal_panel) = panel.view().downcast::() { + let id = terminal_panel.read(cx).id(); + TerminalPanel::cleanup_runtime_state(id, cx); + } self.dock_area.update(cx, |dock, cx| { dock.remove_panel_from_all_docks(panel, window, cx); }); diff --git a/termua/src/window/main_window/actions/ssh.rs b/termua/src/window/main_window/actions/ssh.rs index af99c7a..fe1e73e 100644 --- a/termua/src/window/main_window/actions/ssh.rs +++ b/termua/src/window/main_window/actions/ssh.rs @@ -12,7 +12,7 @@ use gpui_component::{ h_flex, v_flex, }; use gpui_dock::{DockPlacement, PanelView}; -use gpui_term::{Authentication, SshOptions, TerminalBuilder, TerminalType}; +use gpui_term::{Authentication, SshOptions, TerminalType}; use rust_i18n::t; use super::TermuaWindow; @@ -429,6 +429,18 @@ impl TermuaWindow { session_id: Option, window: &mut Window, cx: &mut Context, + ) { + self.add_ssh_terminal_with_params_at(backend_type, params, session_id, None, window, cx); + } + + fn add_ssh_terminal_with_params_at( + &mut self, + backend_type: TerminalType, + params: SshParams, + session_id: Option, + replacement: Option>, + window: &mut Window, + cx: &mut Context, ) { // Building the SSH PTY involves a blocking login handshake. Run that work in a background // thread and only attach the terminal panel on success. @@ -486,6 +498,7 @@ impl TermuaWindow { backend_type, params_for_finish, session_id, + replacement, window, cx, ); @@ -499,31 +512,40 @@ impl TermuaWindow { fn finish_add_ssh_terminal_task( &mut self, - result: anyhow::Result, + result: anyhow::Result>, backend_type: TerminalType, params: SshParams, session_id: Option, + replacement: Option>, window: &mut Window, cx: &mut Context, ) { match result { - Ok(builder) => { - let panel = self.build_ssh_panel_from_builder( - builder, + Ok(factory) => { + let panel = self.build_ssh_panel_from_factory( + factory, params.name, params.opts, + Some(crate::panel::TerminalLaunchState::Ssh { + backend_type, + session_id, + }), window, cx, ); - self.dock_area.update(cx, |dock, cx| { - dock.add_panel( - Arc::new(panel) as Arc, - DockPlacement::Center, - None, - window, - cx, - ); - }); + if let Some(replacement) = replacement { + self.replace_ssh_restore_panel(replacement, panel, window, cx); + } else { + self.dock_area.update(cx, |dock, cx| { + dock.add_panel( + Arc::new(panel) as Arc, + DockPlacement::Center, + None, + window, + cx, + ); + }); + } self.clear_connecting_session(session_id, cx); cx.notify(); } @@ -541,6 +563,13 @@ impl TermuaWindow { self.clear_connecting_session(session_id, cx); return; } + if let Some(replacement) = replacement { + let message = ssh_connect_failure_message(¶ms.opts, &err); + replacement.update(cx, |panel, cx| panel.set_message(message, cx)); + self.clear_connecting_session(session_id, cx); + return; + } + let id = self.next_terminal_id; self.next_terminal_id += 1; @@ -550,7 +579,20 @@ impl TermuaWindow { let message = ssh_connect_failure_message(¶ms.opts, &err); let panel = cx.new(|cx| { - SshErrorPanel::new(id, tab_label, Some(tab_tooltip), message.into(), cx) + SshErrorPanel::with_terminal_error( + crate::panel::TerminalPanelState { + version: 1, + id, + tab_label: tab_label.to_string(), + tab_tooltip: Some(tab_tooltip.to_string()), + launch: crate::panel::TerminalLaunchState::Ssh { + backend_type, + session_id, + }, + }, + message.into(), + cx, + ) }); self.dock_area.update(cx, |dock, cx| { @@ -584,6 +626,18 @@ impl TermuaWindow { session_id: i64, window: &mut Window, cx: &mut Context, + ) { + self.open_saved_ssh_session_at(backend_type, session, session_id, None, window, cx); + } + + fn open_saved_ssh_session_at( + &mut self, + backend_type: TerminalType, + session: crate::store::Session, + session_id: i64, + replacement: Option>, + window: &mut Window, + cx: &mut Context, ) { let Some(host) = session.ssh_host.as_deref() else { return; @@ -635,12 +689,98 @@ impl TermuaWindow { tcp_nodelay: session.ssh_tcp_nodelay, tcp_keepalive: session.ssh_tcp_keepalive, }; - self.add_ssh_terminal_with_params( + self.add_ssh_terminal_with_params_at( backend_type, SshParams { env, name, opts }, Some(session_id), + replacement, window, cx, ); } + + pub(in crate::window::main_window) fn restore_pending_ssh_panels( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + let panels = self + .dock_area + .read(cx) + .all_tab_panels(cx) + .into_iter() + .flat_map(|tabs| tabs.read(cx).panels().to_vec()) + .filter_map(|panel| panel.view().downcast::().ok()) + .collect::>(); + + for panel in panels { + let Some(state) = panel.read(cx).terminal_state() else { + continue; + }; + let crate::panel::TerminalLaunchState::Ssh { + backend_type, + session_id, + } = state.launch + else { + continue; + }; + let Some(session_id) = session_id else { + panel.update(cx, |panel, cx| { + panel.set_message( + "This SSH tab was not linked to a saved session and cannot be reconnected.", + cx, + ); + }); + continue; + }; + let Ok(Some(session)) = crate::store::load_session(session_id) else { + panel.update(cx, |panel, cx| { + panel.set_message("The saved SSH session no longer exists.", cx); + }); + continue; + }; + self.open_saved_ssh_session_at( + backend_type, + session, + session_id, + Some(panel), + window, + cx, + ); + } + } + + fn replace_ssh_restore_panel( + &mut self, + old_panel: gpui::Entity, + new_panel: gpui::Entity, + window: &mut Window, + cx: &mut Context, + ) { + let parent = old_panel.read(cx).parent_tab(); + let replaced = parent.is_some_and(|parent| { + parent + .update(cx, |tabs, cx| { + tabs.replace_panel( + Arc::new(old_panel.clone()) as Arc, + Arc::new(new_panel.clone()) as Arc, + window, + cx, + ) + }) + .unwrap_or(false) + }); + if !replaced { + self.dock_area.update(cx, |dock, cx| { + dock.add_panel( + Arc::new(new_panel) as Arc, + DockPlacement::Center, + None, + window, + cx, + ); + }); + } + self.restore_pending_sftp_panels(window, cx); + } } diff --git a/termua/src/window/main_window/actions/terminal.rs b/termua/src/window/main_window/actions/terminal.rs index 5abd9cb..b193a88 100644 --- a/termua/src/window/main_window/actions/terminal.rs +++ b/termua/src/window/main_window/actions/terminal.rs @@ -7,12 +7,12 @@ use gpui_term::{ TerminalSettings, TerminalType, TerminalView, UserInput as TerminalUserInput, }; -use super::TermuaWindow; +use super::{super::state::RecorderContextMenuProvider, TermuaWindow}; use crate::{ SerialParams, TermuaAppState, env::{build_terminal_env, cast_player_child_env}, lock_screen, notification, - panel::{PanelKind, TerminalPanel, terminal_panel_tab_name}, + panel::{PanelKind, TerminalLaunchState, TerminalPanel, terminal_panel_tab_name}, ssh::{dedupe_tab_label, ssh_tab_tooltip}, }; @@ -58,8 +58,17 @@ impl TermuaWindow { .unwrap_or(1.0); let env = cast_player_child_env(&cast_path, playback_speed); - let panel = - self.build_terminal_panel(PanelKind::Recorder, TerminalType::WezTerm, env, window, cx); + let panel = self.build_terminal_panel( + PanelKind::Recorder, + TerminalType::WezTerm, + env, + Some(TerminalLaunchState::Recorder { + cast_path, + playback_speed, + }), + window, + cx, + ); self.dock_area.update(cx, |dock, cx| { dock.add_panel( @@ -72,14 +81,25 @@ impl TermuaWindow { }); } - pub(super) fn add_local_terminal_with_params( + pub(in crate::window::main_window) fn add_local_terminal_with_params( &mut self, backend_type: TerminalType, env: HashMap, window: &mut Window, cx: &mut Context, ) { - let panel = self.build_terminal_panel(PanelKind::Local, backend_type, env, window, cx); + let launch_state = TerminalLaunchState::Local { + backend_type, + env: env.clone(), + }; + let panel = self.build_terminal_panel( + PanelKind::Local, + backend_type, + env, + Some(launch_state), + window, + cx, + ); self.dock_area.update(cx, |dock, cx| { dock.add_panel( Arc::new(panel) as Arc, @@ -177,7 +197,19 @@ impl TermuaWindow { } }; - let panel = self.build_serial_panel_from_builder(builder, params.name, opts, window, cx); + let launch_state = TerminalLaunchState::Serial { + backend_type, + params: params.clone(), + session_id, + }; + let panel = self.build_serial_panel_from_builder( + builder, + params.name, + opts, + Some(launch_state), + window, + cx, + ); self.dock_area.update(cx, |dock, cx| { dock.add_panel( Arc::new(panel) as Arc, @@ -296,10 +328,12 @@ impl TermuaWindow { id: usize, tab_label: SharedString, terminal_view: &gpui::Entity, - terminal_weak: gpui::WeakEntity, + terminal: &gpui::Entity, window: &mut Window, cx: &mut Context, ) { + let terminal_weak = terminal.downgrade(); + let backend = terminal.read(cx).backend_type(); crate::assistant::register_terminal_target(cx, id, tab_label, terminal_weak.clone()); let focused_terminal_view = terminal_view.downgrade(); @@ -308,6 +342,7 @@ impl TermuaWindow { let sub = cx.on_focus_in(&focus_handle, window, move |this, _window, cx| { this.focused_terminal_view = Some(focused_terminal_view.clone()); crate::assistant::set_focused_terminal(cx, Some(id), Some(focused_terminal.clone())); + crate::footbar::focus_terminal_backend(id, backend, cx); }); self._subscriptions.push(sub); } @@ -361,7 +396,7 @@ impl TermuaWindow { self._subscriptions.push(subscription); } - fn create_terminal_view( + pub(in crate::window::main_window) fn create_terminal_view( &self, kind: PanelKind, terminal: gpui::Entity, @@ -369,7 +404,8 @@ impl TermuaWindow { cx: &mut Context, ) -> gpui::Entity { if kind == PanelKind::Recorder { - return cx.new(|cx| TerminalView::new_with_context_menu(terminal, window, cx, false)); + return cx + .new(|cx| RecorderContextMenuProvider::new_terminal_view(terminal, window, cx)); } let provider = self.terminal_context_menu_provider.clone(); @@ -378,12 +414,13 @@ impl TermuaWindow { }) } - fn build_wired_terminal_panel( + pub(in crate::window::main_window) fn build_wired_terminal_panel( &mut self, id: usize, kind: PanelKind, tab_label: SharedString, tab_tooltip: Option, + launch_state: Option, terminal: gpui::Entity, window: &mut Window, cx: &mut Context, @@ -396,14 +433,13 @@ impl TermuaWindow { cx, ); - let terminal_weak = terminal.downgrade(); - let terminal_view = self.create_terminal_view(kind, terminal, window, cx); + let terminal_view = self.create_terminal_view(kind, terminal.clone(), window, cx); self.register_terminal_target_and_focus( id, tab_label.clone(), &terminal_view, - terminal_weak, + &terminal, window, cx, ); @@ -411,15 +447,27 @@ impl TermuaWindow { let focus: FocusHandle = terminal_view.read(cx).focus_handle.clone(); window.focus(&focus, cx); + let backend = terminal.read(cx).backend_type(); + crate::footbar::focus_terminal_backend(id, backend, cx); - cx.new(|_| TerminalPanel::new(id, kind, tab_label, tab_tooltip, terminal_view)) + cx.new(|_| { + TerminalPanel::new_with_launch_state( + id, + kind, + tab_label, + tab_tooltip, + launch_state, + terminal_view, + ) + }) } - pub(super) fn build_ssh_panel_from_builder( + pub(super) fn build_ssh_panel_from_factory( &mut self, - builder: TerminalBuilder, + factory: Box, name: String, opts: SshOptions, + launch_state: Option, window: &mut Window, cx: &mut Context, ) -> gpui::Entity { @@ -429,12 +477,13 @@ impl TermuaWindow { let tab_label = dedupe_tab_label(&mut self.ssh_tab_label_counts, name.as_str()); let tab_tooltip = ssh_tab_tooltip(&opts); - let terminal = cx.new(move |cx| builder.subscribe(cx)); + let terminal = cx.new(move |cx| factory.build(cx)); self.build_wired_terminal_panel( id, PanelKind::Ssh, tab_label, Some(tab_tooltip), + launch_state, terminal, window, cx, @@ -446,6 +495,7 @@ impl TermuaWindow { builder: TerminalBuilder, name: String, opts: SerialOptions, + launch_state: Option, window: &mut Window, cx: &mut Context, ) -> gpui::Entity { @@ -461,6 +511,7 @@ impl TermuaWindow { PanelKind::Serial, tab_label, Some(tab_tooltip), + launch_state, terminal, window, cx, @@ -472,6 +523,7 @@ impl TermuaWindow { kind: PanelKind, backend_type: TerminalType, env: HashMap, + launch_state: Option, window: &mut Window, cx: &mut Context, ) -> gpui::Entity { @@ -494,7 +546,104 @@ impl TermuaWindow { .expect("local terminal builder should succeed") .subscribe(cx) }); - self.build_wired_terminal_panel(id, kind, tab_label, None, terminal, window, cx) + self.build_wired_terminal_panel( + id, + kind, + tab_label, + None, + launch_state, + terminal, + window, + cx, + ) + } + + pub(in crate::window::main_window) fn wire_restored_terminal_panels( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + let panels = self + .dock_area + .read(cx) + .all_tab_panels(cx) + .into_iter() + .flat_map(|tabs| tabs.read(cx).panels().to_vec()) + .collect::>(); + + for panel in panels { + let Ok(panel) = panel.view().downcast::() else { + continue; + }; + let (id, tab_label, terminal_view) = { + let panel = panel.read(cx); + (panel.id(), panel.tab_label(), panel.terminal_view()) + }; + self.next_terminal_id = self.next_terminal_id.max(id.saturating_add(1)); + let terminal = terminal_view.read(cx).terminal.clone(); + self.subscribe_terminal_events_for_messages( + terminal.clone(), + id, + tab_label.clone(), + window, + cx, + ); + self.register_terminal_target_and_focus( + id, + tab_label, + &terminal_view, + &terminal, + window, + cx, + ); + self.subscribe_terminal_view_events(&terminal_view, window, cx); + } + } + + pub(in crate::window::main_window) fn restore_pending_sftp_panels( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + let panels = self + .dock_area + .read(cx) + .all_tab_panels(cx) + .into_iter() + .flat_map(|tabs| tabs.read(cx).panels().to_vec()) + .collect::>(); + let terminals = panels + .iter() + .filter_map(|panel| panel.view().downcast::().ok()) + .map(|panel| { + let panel = panel.read(cx); + (panel.id(), panel.terminal_view()) + }) + .collect::>(); + let sftp_panels = panels + .into_iter() + .filter_map(|panel| { + panel + .view() + .downcast::() + .ok() + }) + .collect::>(); + + for panel in sftp_panels { + let terminal_id = panel.read(cx).terminal_id(); + if panel.read(cx).is_connected() { + continue; + } + let Some(terminal_view) = terminals.get(&terminal_id).cloned() else { + continue; + }; + if let Err(err) = panel.update(cx, |panel, cx| { + panel.connect_to_terminal_view(terminal_view, window, cx) + }) { + log::warn!("termua: failed to reconnect restored SFTP panel: {err:#}"); + } + } } pub(super) fn open_sftp_for_terminal_view( @@ -504,6 +653,7 @@ impl TermuaWindow { cx: &mut Context, ) { let mut tab_label: gpui::SharedString = "SFTP".into(); + let mut terminal_id = None; let tab_panels = self.dock_area.read(cx).visible_tab_panels(cx); for tab_panel in tab_panels { let Some(active_panel) = tab_panel.read(cx).active_panel(cx) else { @@ -517,13 +667,25 @@ impl TermuaWindow { let terminal_panel = terminal_panel.read(cx); if terminal_panel.terminal_view().entity_id() == terminal_view.entity_id() { tab_label = terminal_panel.tab_label(); + terminal_id = Some(terminal_panel.id()); break; } } + let Some(terminal_id) = terminal_id else { + notification::notify_deferred( + notification::MessageKind::Error, + "Unable to associate SFTP with its SSH terminal.", + window, + cx, + ); + return; + }; + let panel = match crate::panel::sftp_panel::SftpDockPanel::open_for_terminal_view( terminal_view, tab_label, + terminal_id, window, cx, ) { @@ -539,13 +701,19 @@ impl TermuaWindow { } }; + self.add_sftp_panel(panel, window, cx); + cx.notify(); + } + + pub(in crate::window::main_window) fn add_sftp_panel( + &mut self, + panel: Arc, + window: &mut Window, + cx: &mut Context, + ) { self.dock_area.update(cx, |dock, cx| { - dock.add_panel(panel, DockPlacement::Bottom, None, window, cx); - if !dock.is_dock_open(DockPlacement::Bottom, cx) { - dock.toggle_dock(DockPlacement::Bottom, window, cx); - } + dock.add_panel(panel, DockPlacement::Center, None, window, cx); }); - cx.notify(); } fn close_exited_terminal_panel( diff --git a/termua/src/window/main_window/state.rs b/termua/src/window/main_window/state.rs index 7f6522f..ccb6ecd 100644 --- a/termua/src/window/main_window/state.rs +++ b/termua/src/window/main_window/state.rs @@ -5,7 +5,10 @@ use std::{collections::HashMap, sync::Arc, time::Duration}; use gpui::{App, AppContext, Context, Focusable, Styled, Subscription, Window}; use gpui_common::TermuaIcon; use gpui_component::{ActiveTheme, Icon, IconName}; -use gpui_dock::{DockArea, DockItem, DockPlacement, PanelView}; +use gpui_dock::{ + DockArea, DockAreaState, DockEvent, DockItem, DockPlacement, DockState, PanelState, PanelView, + register_panel, +}; use gpui_term::{ Clear, Copy as CopyAction, CursorShape, Paste, PtySource, SelectAll, SshOptions, TerminalBuilder, TerminalType, TerminalView, ToggleCastRecording, @@ -18,11 +21,67 @@ use crate::{ footbar::FootbarView, globals::{ensure_ctx_global, ensure_ctx_global_with}, lock_screen, notification, - panel::{RightSidebarView, SessionsSidebarEvent, SessionsSidebarView}, + panel::{ + PanelKind, RightSidebarView, SessionsSidebarEvent, SessionsSidebarView, TerminalLaunchState, + }, right_sidebar, settings::{ThemeMode, set_theme_mode, theme_mode}, - ssh::SshTerminalBuilderFn, + ssh::{SshTerminalBuilderFn, SshTerminalFactory}, }; + +type RestoredTerminalBuilderFn = Arc< + dyn Fn(&TerminalLaunchState, usize) -> anyhow::Result<(PanelKind, Box)> + + Send + + Sync, +>; + +fn default_restored_terminal_builder( + launch: &TerminalLaunchState, + id: usize, +) -> anyhow::Result<(PanelKind, Box)> { + let (kind, builder) = match launch { + TerminalLaunchState::Local { backend_type, env } => ( + PanelKind::Local, + TerminalBuilder::new( + *backend_type, + env.clone(), + CursorShape::default(), + None, + id as u64, + )?, + ), + TerminalLaunchState::Serial { + backend_type, + params, + .. + } => ( + PanelKind::Serial, + TerminalBuilder::new_with_pty( + *backend_type, + PtySource::Serial { + opts: params.to_options(), + }, + CursorShape::default(), + None, + )?, + ), + TerminalLaunchState::Recorder { + cast_path, + playback_speed, + } => ( + PanelKind::Recorder, + TerminalBuilder::new( + TerminalType::WezTerm, + crate::env::cast_player_child_env(cast_path, *playback_speed), + CursorShape::default(), + None, + id as u64, + )?, + ), + TerminalLaunchState::Ssh { .. } => anyhow::bail!("SSH terminals reconnect separately"), + }; + Ok((kind, Box::new(builder))) +} pub(crate) struct TermuaWindow { pub(crate) dock_area: gpui::Entity, pub(crate) sessions_sidebar: gpui::Entity, @@ -36,11 +95,90 @@ pub(crate) struct TermuaWindow { pub(super) ssh_tab_label_counts: HashMap, pub(super) ssh_terminal_builder: SshTerminalBuilderFn, pub(super) terminal_context_menu_provider: Arc, + pub(super) workspace_save_task: Option>, pub(super) _subscriptions: Vec, } +#[cfg(test)] +const WORKSPACE_SAVE_DEBOUNCE: Duration = Duration::from_millis(10); +#[cfg(not(test))] +const WORKSPACE_SAVE_DEBOUNCE: Duration = Duration::from_millis(500); + +fn find_panel_state<'a>(state: &'a PanelState, panel_name: &str) -> Option<&'a PanelState> { + if state.panel_name == panel_name { + return Some(state); + } + state + .children + .iter() + .find_map(|child| find_panel_state(child, panel_name)) +} + +fn unwrap_fixed_dock_panel(dock: &mut Option, panel_name: &str) { + let Some(dock) = dock else { + return; + }; + if let Some(panel) = find_panel_state(dock.panel_state(), panel_name).cloned() { + dock.set_panel_state(panel); + } +} + +fn normalize_fixed_sidebar_panels(state: &mut DockAreaState) { + unwrap_fixed_dock_panel( + &mut state.left_dock, + crate::panel::SESSIONS_SIDEBAR_PANEL_NAME, + ); + unwrap_fixed_dock_panel( + &mut state.right_dock, + crate::panel::RIGHT_SIDEBAR_PANEL_NAME, + ); +} + struct TermuaContextMenuProvider; +pub(super) struct RecorderContextMenuProvider; + +impl RecorderContextMenuProvider { + pub(super) fn new_terminal_view( + terminal: gpui::Entity, + window: &mut Window, + cx: &mut Context, + ) -> TerminalView { + TerminalView::new_with_context_menu_provider( + terminal, + window, + cx, + true, + Some(Arc::new(Self)), + ) + } +} + +impl gpui_term::ContextMenuProvider for RecorderContextMenuProvider { + fn context_menu( + &self, + menu: gpui_component::menu::PopupMenu, + _terminal: gpui::Entity, + terminal_view: gpui::Entity, + window: &mut Window, + cx: &mut App, + ) -> gpui_component::menu::PopupMenu { + let focus = terminal_view.read(cx).focus_handle.clone(); + window.focus(&focus, cx); + + menu.menu_with_icon( + t!("Terminal.ContextMenu.Copy").to_string(), + IconName::Copy, + Box::new(CopyAction), + ) + .separator() + .menu( + t!("Terminal.ContextMenu.SelectAll").to_string(), + Box::new(SelectAll), + ) + } +} + impl gpui_term::ContextMenuProvider for TermuaContextMenuProvider { fn context_menu( &self, @@ -125,6 +263,7 @@ impl TermuaWindow { CursorShape::default(), None, ) + .map(|builder| Box::new(builder) as Box) }, ); @@ -135,12 +274,57 @@ impl TermuaWindow { window: &mut Window, ssh_terminal_builder: SshTerminalBuilderFn, cx: &mut Context, + ) -> Self { + Self::new_with_terminal_builders( + window, + ssh_terminal_builder, + Arc::new(default_restored_terminal_builder), + cx, + ) + } + + pub(crate) fn new_with_terminal_builders( + window: &mut Window, + ssh_terminal_builder: SshTerminalBuilderFn, + restored_terminal_builder: RestoredTerminalBuilderFn, + cx: &mut Context, ) -> Self { Self::ensure_globals(cx); - let dock_area = cx.new(|cx| DockArea::new("termua", None, window, cx)); + let dock_area = + cx.new(|cx| DockArea::new("termua", Some(crate::workspace::STATE_VERSION), window, cx)); let sessions_sidebar = cx.new(|cx| SessionsSidebarView::new(window, cx)); let right_sidebar = cx.new(|cx| RightSidebarView::new(window, cx)); + register_panel(cx, crate::panel::SESSIONS_SIDEBAR_PANEL_NAME, { + let sessions_sidebar = sessions_sidebar.clone(); + move |_, _, info, window, cx| { + if let gpui_dock::PanelInfo::Panel(value) = info + && let Ok(state) = serde_json::from_value::< + crate::panel::sessions_sidebar::SessionsSidebarPanelState, + >(value.clone()) + { + sessions_sidebar.update(cx, |sidebar, cx| { + sidebar.restore_persisted_state(state, window, cx) + }); + } + Box::new(sessions_sidebar.clone()) + } + }); + register_panel(cx, crate::panel::RIGHT_SIDEBAR_PANEL_NAME, { + let right_sidebar = right_sidebar.clone(); + move |_, _, info, window, cx| { + if let gpui_dock::PanelInfo::Panel(value) = info + && let Ok(state) = serde_json::from_value::< + crate::panel::right_sidebar::RightSidebarPanelState, + >(value.clone()) + { + right_sidebar.update(cx, |sidebar, cx| { + sidebar.restore_persisted_state(state, window, cx) + }); + } + Box::new(right_sidebar.clone()) + } + }); let footbar = cx.new(FootbarView::new); let lock_overlay = lock_screen::overlay::LockOverlayState::new(window, cx); let mut this = Self { @@ -156,6 +340,7 @@ impl TermuaWindow { ssh_tab_label_counts: HashMap::new(), ssh_terminal_builder, terminal_context_menu_provider: Arc::new(TermuaContextMenuProvider), + workspace_save_task: None, _subscriptions: Vec::new(), }; @@ -219,6 +404,178 @@ impl TermuaWindow { } }); + let terminal_context_menu_provider = this.terminal_context_menu_provider.clone(); + register_panel(cx, crate::panel::TERMINAL_PANEL_NAME, { + let restored_terminal_builder = restored_terminal_builder.clone(); + move |_, _, info, window, cx| { + let panel_state = match info { + gpui_dock::PanelInfo::Panel(value) => { + serde_json::from_value::(value.clone()) + } + _ => unreachable!("terminal factory received non-panel state"), + }; + let panel_state = match panel_state { + Ok(state) if state.version == 1 => state, + Ok(state) => { + return Box::new(cx.new(|cx| { + crate::panel::SshErrorPanel::new( + state.id, + state.tab_label.into(), + state.tab_tooltip.map(Into::into), + "This saved terminal state is from an unsupported version.".into(), + cx, + ) + })); + } + Err(err) => { + return Box::new(cx.new(|cx| { + crate::panel::SshErrorPanel::new( + 0, + "Terminal".into(), + None, + format!("Failed to restore terminal state: {err}").into(), + cx, + ) + })); + } + }; + + let id = panel_state.id; + let builder = match &panel_state.launch { + crate::panel::TerminalLaunchState::Ssh { .. } => { + return Box::new(cx.new(|cx| { + crate::panel::SshErrorPanel::restoring( + panel_state, + "Reconnecting SSH session...".into(), + cx, + ) + })); + } + launch => restored_terminal_builder(launch, id), + }; + let (kind, builder) = match builder { + Ok(builder) => builder, + Err(err) => { + return Box::new(cx.new(|cx| { + crate::panel::SshErrorPanel::with_terminal_error( + panel_state, + format!("Failed to restore terminal: {err:#}").into(), + cx, + ) + })); + } + }; + let terminal = cx.new(move |cx| builder.build(cx)); + let terminal_view = if kind == crate::panel::PanelKind::Recorder { + cx.new(|cx| { + RecorderContextMenuProvider::new_terminal_view(terminal, window, cx) + }) + } else { + cx.new(|cx| { + TerminalView::new_with_context_menu_provider( + terminal, + window, + cx, + true, + Some(terminal_context_menu_provider.clone()), + ) + }) + }; + Box::new(cx.new(|_| { + crate::panel::TerminalPanel::new_with_launch_state( + id, + kind, + panel_state.tab_label.into(), + panel_state.tab_tooltip.map(Into::into), + Some(panel_state.launch), + terminal_view, + ) + })) + } + }); + register_panel(cx, crate::panel::SFTP_PANEL_NAME, |_, _, info, _, cx| { + let state = match info { + gpui_dock::PanelInfo::Panel(value) => serde_json::from_value::< + crate::panel::sftp_panel::SftpPanelState, + >(value.clone()), + _ => unreachable!("sftp factory received non-panel state"), + }; + match state { + Ok(state) if state.version == 1 => Box::new( + cx.new(|cx| crate::panel::sftp_panel::SftpDockPanel::restoring(state, cx)), + ), + Ok(_) | Err(_) => Box::new(cx.new(|cx| { + crate::panel::SshErrorPanel::new( + 0, + "SFTP".into(), + None, + "Failed to restore SFTP panel state.".into(), + cx, + ) + })), + } + }); + + match crate::workspace::load_from_path(&crate::workspace::state_path()) { + Ok(mut state) if state.version == Some(crate::workspace::STATE_VERSION) => { + normalize_fixed_sidebar_panels(&mut state); + if let Err(err) = dock_area.update(cx, |dock, cx| dock.load(state, window, cx)) { + log::warn!("termua: failed to restore dock workspace: {err:#}"); + } + } + Ok(state) => log::info!( + "termua: ignoring dock workspace version {:?}, expected {}", + state.version, + crate::workspace::STATE_VERSION + ), + Err(err) if crate::workspace::state_path().exists() => { + log::warn!("termua: failed to read dock workspace: {err:#}"); + } + Err(_) => {} + } + let (left_dock, right_dock) = { + let dock = dock_area.read(cx); + (dock.left_dock().cloned(), dock.right_dock().cloned()) + }; + if let Some(left) = left_dock { + left.update(cx, |dock, cx| { + dock.set_min_size(gpui::px(220.0), window, cx); + dock.set_max_size(gpui::px(400.0), window, cx); + }); + } + if let Some(right) = right_dock { + right.update(cx, |dock, cx| { + dock.set_min_size(gpui::px(220.0), window, cx); + dock.set_max_size(gpui::px(400.0), window, cx); + }); + } + this.wire_restored_terminal_panels(window, cx); + this.restore_pending_ssh_panels(window, cx); + this.restore_pending_sftp_panels(window, cx); + + let (left_state, right_state) = { + let dock = dock_area.read(cx); + let left = dock + .left_dock() + .map(|left| (left.read(cx).is_open(), left.read(cx).size())); + let right = dock + .right_dock() + .map(|right| (right.read(cx).is_open(), right.read(cx).size())); + (left, right) + }; + if let Some((visible, width)) = left_state { + let state = cx.global_mut::(); + state.sessions_sidebar_visible = visible; + state.sessions_sidebar_width = width; + } + if let Some((visible, width)) = right_state { + let state = cx.global_mut::(); + state.visible = visible; + state.width = width; + } + + this.install_workspace_persistence(window, cx); + this } @@ -335,4 +692,55 @@ impl TermuaWindow { } })); } + + fn install_workspace_persistence(&mut self, window: &mut Window, cx: &mut Context) { + let dock_area = self.dock_area.clone(); + self._subscriptions.push(cx.subscribe_in( + &dock_area, + window, + |this, dock_area, event: &DockEvent, window, cx| { + if matches!(event, DockEvent::LayoutChanged) { + this.schedule_workspace_save(dock_area.clone(), window, cx); + } + }, + )); + + let dock_area = self.dock_area.clone(); + cx.on_app_quit(move |_, cx| { + let state = dock_area.read(cx).dump(cx); + let path = crate::workspace::state_path(); + cx.background_executor().spawn(async move { + if let Err(err) = crate::workspace::save_to_path(&path, state) { + log::warn!("termua: failed to save dock workspace on quit: {err:#}"); + } + }) + }) + .detach(); + } + + fn schedule_workspace_save( + &mut self, + dock_area: gpui::Entity, + window: &mut Window, + cx: &mut Context, + ) { + self.workspace_save_task = Some(cx.spawn_in(window, async move |view, window| { + window + .background_executor() + .timer(WORKSPACE_SAVE_DEBOUNCE) + .await; + let Ok(state) = view.update_in(window, move |_, _, cx| dock_area.read(cx).dump(cx)) + else { + return; + }; + let path = crate::workspace::state_path(); + if let Err(err) = window + .background_executor() + .spawn(async move { crate::workspace::save_to_path(&path, state) }) + .await + { + log::warn!("termua: failed to save dock workspace: {err:#}"); + } + })); + } } diff --git a/termua/src/window/main_window/tests.rs b/termua/src/window/main_window/tests.rs index 7618a1e..c033af9 100644 --- a/termua/src/window/main_window/tests.rs +++ b/termua/src/window/main_window/tests.rs @@ -4,17 +4,18 @@ use std::{ ops::RangeInclusive, sync::{ Arc, - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, }, time::{Duration, Instant}, }; use gpui::{ - AppContext, Bounds, Context, InteractiveElement, IntoElement, Keystroke, Modifiers, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, ScrollWheelEvent, - SharedString, Styled, Window, div, + AppContext, AsKeystroke, Bounds, Context, InteractiveElement, IntoElement, Keystroke, + Modifiers, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, + ScrollWheelEvent, SharedString, Styled, Window, div, }; use gpui_component::input::InputState; +use gpui_dock::{DockPlacement, PanelView}; use gpui_term::{ Authentication, CursorShape, Event as TerminalEvent, SshOptions, Terminal, TerminalBackend, TerminalBounds, TerminalType, TerminalView, UserInput as TerminalUserInput, @@ -26,9 +27,722 @@ use crate::{ SshParams, TermuaAppState, ToggleSessionsSidebar, lock_screen, menu::Quit, notification, - ssh::{SshHostKeyMismatchDetails, SshTerminalBuilderFn}, + ssh::{SshHostKeyMismatchDetails, SshTerminalBuilderFn, SshTerminalFactory}, }; +fn unique_workspace_settings_path(label: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock before unix epoch") + .as_nanos(); + std::env::temp_dir() + .join(format!("termua-main-window-{label}-{nanos}")) + .join("settings.json") +} + +fn add_fake_local_terminal( + window_view: &mut TermuaWindow, + backend: TerminalType, + window: &mut Window, + cx: &mut Context, +) { + add_fake_local_terminal_with_launch(window_view, backend, None, window, cx); +} + +fn add_fake_local_terminal_with_launch( + window_view: &mut TermuaWindow, + backend: TerminalType, + launch_state: Option, + window: &mut Window, + cx: &mut Context, +) { + let id = window_view.next_terminal_id; + window_view.next_terminal_id += 1; + let terminal = cx.new(|_| { + Terminal::new( + backend, + Box::new(FakeBackend::new(Arc::new(AtomicBool::new(false)))), + ) + }); + let panel = window_view.build_wired_terminal_panel( + id, + crate::panel::PanelKind::Local, + format!("terminal {id}").into(), + None, + launch_state, + terminal, + window, + cx, + ); + window_view.dock_area.update(cx, |dock, cx| { + dock.add_panel( + Arc::new(panel) as Arc, + DockPlacement::Center, + None, + window, + cx, + ); + }); +} + +#[gpui::test] +fn sftp_panel_opens_in_center_workspace_instead_of_bottom_dock(cx: &mut gpui::TestAppContext) { + cx.update(|app| { + gpui_component::init(app); + menubar::init(app); + gpui_term::init(app); + gpui_dock::init(app); + app.set_global(TermuaAppState::default()); + }); + + let (view, window_cx) = cx.add_window_view(|window, cx| TermuaWindow::new(window, cx)); + window_cx.update(|window, cx| { + view.update(cx, |this, cx| { + add_fake_local_terminal(this, TerminalType::Alacritty, window, cx); + }); + + let sftp = cx.new(|cx| { + crate::panel::sftp_panel::SftpDockPanel::restoring( + crate::panel::sftp_panel::SftpPanelState { + version: 1, + tab_label: "SFTP test".to_string(), + terminal_id: 1, + current_dir: None, + }, + cx, + ) + }); + + let panel = Arc::new(sftp) as Arc; + view.update(cx, |this, cx| { + this.add_sftp_panel(panel.clone(), window, cx); + }); + + let dock = view.read(cx).dock_area.read(cx); + assert!(dock.bottom_dock().is_none()); + assert!(dock.center().find_panel(panel.clone(), cx).is_some()); + + let shared_tabs = dock + .visible_tab_panels(cx) + .into_iter() + .find(|tabs| tabs.read(cx).panels().iter().any(|item| item == &panel)) + .expect("SFTP panel should be in a visible center tab group"); + assert!(shared_tabs.read(cx).panels().iter().any(|item| { + item.view() + .downcast::() + .is_ok() + })); + }); +} + +fn update_first_panel_state( + value: &mut serde_json::Value, + panel_name: &str, + update: &mut impl FnMut(&mut serde_json::Value), +) -> bool { + if value["panel_name"] == panel_name { + update(value); + return true; + } + match value { + serde_json::Value::Array(values) => values + .iter_mut() + .any(|value| update_first_panel_state(value, panel_name, update)), + serde_json::Value::Object(values) => values + .values_mut() + .any(|value| update_first_panel_state(value, panel_name, update)), + _ => false, + } +} + +fn save_workspace_with_modified_fake_terminal( + view: &gpui::Entity, + cx: &mut gpui::App, + mut update: impl FnMut(&mut serde_json::Value), +) { + let state = view.read(cx).dock_area.read(cx).dump(cx); + let mut value = serde_json::to_value(state).expect("serialize workspace state"); + assert!(update_first_panel_state( + &mut value, + crate::panel::TERMINAL_PANEL_NAME, + &mut update, + )); + crate::workspace::save_to_path( + &crate::workspace::state_path(), + serde_json::from_value(value).expect("deserialize modified workspace state"), + ) + .expect("save modified workspace state"); +} + +#[gpui::test] +fn main_window_restores_saved_dock_layout(cx: &mut gpui::TestAppContext) { + let settings_path = unique_workspace_settings_path("restore-layout"); + let _guard = crate::settings::override_settings_json_path(settings_path); + + cx.update(|app| { + gpui_component::init(app); + menubar::init(app); + gpui_term::init(app); + gpui_dock::init(app); + app.set_global(TermuaAppState::default()); + }); + + let (first, first_cx) = cx.add_window_view(|window, cx| TermuaWindow::new(window, cx)); + first_cx.update(|window, cx| { + first.update(cx, |this, cx| { + let dock_area = this.dock_area.clone(); + dock_area.update(cx, |dock, cx| { + dock.set_version(crate::workspace::STATE_VERSION, window, cx); + if dock.is_dock_open(gpui_dock::DockPlacement::Left, cx) { + dock.toggle_dock(gpui_dock::DockPlacement::Left, window, cx); + } + crate::workspace::save_to_path(&crate::workspace::state_path(), dock.dump(cx)) + .expect("save workspace layout"); + }); + }); + }); + + let (restored, restored_cx) = cx.add_window_view(|window, cx| TermuaWindow::new(window, cx)); + restored_cx.update(|_window, cx| { + let is_left_open = restored + .read(cx) + .dock_area + .read(cx) + .is_dock_open(gpui_dock::DockPlacement::Left, cx); + assert!(!is_left_open, "saved left dock should remain closed"); + }); + + std::fs::remove_dir_all(crate::settings::settings_dir_path()).ok(); +} + +#[gpui::test] +fn main_window_unwraps_fixed_sidebars_from_saved_tab_panels(cx: &mut gpui::TestAppContext) { + let settings_path = unique_workspace_settings_path("unwrap-fixed-sidebars"); + let _guard = crate::settings::override_settings_json_path(settings_path); + + cx.update(|app| { + gpui_component::init(app); + menubar::init(app); + gpui_term::init(app); + gpui_dock::init(app); + app.set_global(TermuaAppState::default()); + }); + + let (first, first_cx) = cx.add_window_view(|window, cx| TermuaWindow::new(window, cx)); + first_cx.update(|_window, cx| { + let state = first.read(cx).dock_area.read(cx).dump(cx); + let mut value = serde_json::to_value(state).expect("serialize workspace state"); + for dock_name in ["left_dock", "right_dock"] { + let panel = value[dock_name]["panel"].take(); + value[dock_name]["panel"] = serde_json::json!({ + "panel_name": "TabPanel", + "children": [panel], + "info": { "tabs": { "active_index": 0 } } + }); + } + let state = serde_json::from_value(value).expect("deserialize wrapped workspace state"); + crate::workspace::save_to_path(&crate::workspace::state_path(), state) + .expect("save wrapped workspace state"); + }); + + let (restored, restored_cx) = cx.add_window_view(|window, cx| TermuaWindow::new(window, cx)); + restored_cx.update(|_window, cx| { + let dock_area = restored.read(cx).dock_area.read(cx); + assert!(matches!( + dock_area.left_dock().expect("left dock").read(cx).panel(), + gpui_dock::DockItem::Panel { .. } + )); + assert!(matches!( + dock_area.right_dock().expect("right dock").read(cx).panel(), + gpui_dock::DockItem::Panel { .. } + )); + }); + + std::fs::remove_dir_all(crate::settings::settings_dir_path()).ok(); +} + +#[gpui::test] +fn main_window_saves_dock_layout_after_change(cx: &mut gpui::TestAppContext) { + let settings_path = unique_workspace_settings_path("save-layout"); + let _guard = crate::settings::override_settings_json_path(settings_path); + + cx.update(|app| { + gpui_component::init(app); + menubar::init(app); + gpui_term::init(app); + gpui_dock::init(app); + app.set_global(TermuaAppState::default()); + }); + + let (view, window_cx) = cx.add_window_view(|window, cx| TermuaWindow::new(window, cx)); + window_cx.update(|window, cx| { + view.update(cx, |this, cx| { + this.dock_area.update(cx, |dock, cx| { + dock.toggle_dock(gpui_dock::DockPlacement::Left, window, cx); + }); + }); + }); + + window_cx.run_until_parked(); + window_cx + .executor() + .advance_clock(Duration::from_millis(20)); + window_cx.run_until_parked(); + + let saved = crate::workspace::load_from_path(&crate::workspace::state_path()) + .expect("layout change should save workspace state"); + assert_eq!(saved.version, Some(crate::workspace::STATE_VERSION)); + std::fs::remove_dir_all(crate::settings::settings_dir_path()).ok(); +} + +#[gpui::test] +fn main_window_restores_local_terminal_panel(cx: &mut gpui::TestAppContext) { + let settings_path = unique_workspace_settings_path("restore-local-terminal"); + let _guard = crate::settings::override_settings_json_path(settings_path); + + cx.update(|app| { + gpui_component::init(app); + menubar::init(app); + gpui_term::init(app); + gpui_dock::init(app); + app.set_global(TermuaAppState::default()); + }); + + let restore_attempts = Arc::new(AtomicUsize::new(0)); + let restored_terminal_builder = { + let restore_attempts = restore_attempts.clone(); + Arc::new( + move |launch: &crate::panel::TerminalLaunchState, _id: usize| { + restore_attempts.fetch_add(1, Ordering::SeqCst); + let crate::panel::TerminalLaunchState::Local { backend_type, .. } = launch else { + anyhow::bail!("expected local terminal launch state"); + }; + Ok(( + crate::panel::PanelKind::Local, + Box::new(FakeSshTerminalFactory { + backend: *backend_type, + recording_active: Arc::new(AtomicBool::new(false)), + }) as Box, + )) + }, + ) + }; + let ssh_terminal_builder: SshTerminalBuilderFn = Arc::new(|backend, _env, _opts| { + Ok(Box::new(FakeSshTerminalFactory { + backend, + recording_active: Arc::new(AtomicBool::new(false)), + }) as Box) + }); + + let first_restore_builder = restored_terminal_builder.clone(); + let first_ssh_builder = ssh_terminal_builder.clone(); + let (first, first_cx) = cx.add_window_view(move |window, cx| { + TermuaWindow::new_with_terminal_builders( + window, + first_ssh_builder, + first_restore_builder, + cx, + ) + }); + first_cx.update(|window, cx| { + first.update(cx, |this, cx| { + let env = HashMap::from([("TERMUA_SHELL".to_string(), "sh".to_string())]); + add_fake_local_terminal_with_launch( + this, + TerminalType::WezTerm, + Some(crate::panel::TerminalLaunchState::Local { + backend_type: TerminalType::WezTerm, + env, + }), + window, + cx, + ); + crate::workspace::save_to_path( + &crate::workspace::state_path(), + this.dock_area.read(cx).dump(cx), + ) + .expect("save local terminal layout"); + }); + }); + + let (restored, restored_cx) = cx.add_window_view(move |window, cx| { + TermuaWindow::new_with_terminal_builders( + window, + ssh_terminal_builder, + restored_terminal_builder, + cx, + ) + }); + restored_cx.update(|_window, cx| { + let panel = restored + .read(cx) + .dock_area + .read(cx) + .visible_tab_panels(cx) + .into_iter() + .find_map(|tabs| tabs.read(cx).active_panel(cx)) + .expect("restored terminal tab"); + assert!( + panel + .view() + .downcast::() + .is_ok(), + "saved terminal panel should be rebuilt by its registered factory" + ); + }); + assert_eq!(restore_attempts.load(Ordering::SeqCst), 1); + + std::fs::remove_dir_all(crate::settings::settings_dir_path()).ok(); +} + +#[gpui::test] +fn main_window_reports_unsupported_saved_terminal_state(cx: &mut gpui::TestAppContext) { + let settings_path = unique_workspace_settings_path("unsupported-terminal-state"); + let _guard = crate::settings::override_settings_json_path(settings_path); + cx.update(|app| { + gpui_component::init(app); + menubar::init(app); + gpui_term::init(app); + gpui_dock::init(app); + app.set_global(TermuaAppState::default()); + }); + + let (first, first_cx) = cx.add_window_view(|window, cx| TermuaWindow::new(window, cx)); + first_cx.update(|window, cx| { + first.update(cx, |this, cx| { + add_fake_local_terminal(this, TerminalType::Alacritty, window, cx) + }); + save_workspace_with_modified_fake_terminal(&first, cx, |panel| { + panel["info"]["panel"]["version"] = serde_json::json!(usize::MAX); + }); + }); + + let (restored, restored_cx) = cx.add_window_view(|window, cx| TermuaWindow::new(window, cx)); + restored_cx.update(|_, cx| { + let panel = restored.read(cx).dock_area.read(cx).visible_tab_panels(cx)[0] + .read(cx) + .active_panel(cx) + .expect("restored error panel"); + assert!( + panel + .view() + .downcast::() + .is_ok() + ); + }); + std::fs::remove_dir_all(crate::settings::settings_dir_path()).ok(); +} + +#[gpui::test] +fn main_window_reports_malformed_saved_terminal_state(cx: &mut gpui::TestAppContext) { + let settings_path = unique_workspace_settings_path("malformed-terminal-state"); + let _guard = crate::settings::override_settings_json_path(settings_path); + cx.update(|app| { + gpui_component::init(app); + menubar::init(app); + gpui_term::init(app); + gpui_dock::init(app); + app.set_global(TermuaAppState::default()); + }); + + let (first, first_cx) = cx.add_window_view(|window, cx| TermuaWindow::new(window, cx)); + first_cx.update(|window, cx| { + first.update(cx, |this, cx| { + add_fake_local_terminal(this, TerminalType::WezTerm, window, cx) + }); + save_workspace_with_modified_fake_terminal(&first, cx, |panel| { + panel["info"]["panel"] = serde_json::json!({ "invalid": true }); + }); + }); + + let (restored, restored_cx) = cx.add_window_view(|window, cx| TermuaWindow::new(window, cx)); + restored_cx.update(|_, cx| { + let panel = restored.read(cx).dock_area.read(cx).visible_tab_panels(cx)[0] + .read(cx) + .active_panel(cx) + .expect("restored error panel"); + assert!( + panel + .view() + .downcast::() + .is_ok() + ); + }); + std::fs::remove_dir_all(crate::settings::settings_dir_path()).ok(); +} + +#[gpui::test] +fn main_window_reports_malformed_saved_sftp_state(cx: &mut gpui::TestAppContext) { + let settings_path = unique_workspace_settings_path("malformed-sftp-state"); + let _guard = crate::settings::override_settings_json_path(settings_path); + cx.update(|app| { + gpui_component::init(app); + menubar::init(app); + gpui_term::init(app); + gpui_dock::init(app); + app.set_global(TermuaAppState::default()); + }); + + let (first, first_cx) = cx.add_window_view(|window, cx| TermuaWindow::new(window, cx)); + first_cx.update(|window, cx| { + first.update(cx, |this, cx| { + add_fake_local_terminal(this, TerminalType::Alacritty, window, cx) + }); + save_workspace_with_modified_fake_terminal(&first, cx, |panel| { + panel["panel_name"] = serde_json::json!(crate::panel::SFTP_PANEL_NAME); + panel["info"]["panel"] = serde_json::json!({ "invalid": true }); + }); + }); + + let (restored, restored_cx) = cx.add_window_view(|window, cx| TermuaWindow::new(window, cx)); + restored_cx.update(|_, cx| { + let panel = restored.read(cx).dock_area.read(cx).visible_tab_panels(cx)[0] + .read(cx) + .active_panel(cx) + .expect("restored error panel"); + assert!( + panel + .view() + .downcast::() + .is_ok() + ); + }); + std::fs::remove_dir_all(crate::settings::settings_dir_path()).ok(); +} + +#[gpui::test] +fn footbar_backend_tracks_opened_terminal(cx: &mut gpui::TestAppContext) { + cx.update(|app| { + gpui_component::init(app); + menubar::init(app); + gpui_term::init(app); + gpui_dock::init(app); + app.set_global(TermuaAppState::default()); + app.activate(true); + }); + + let (view, window_cx) = cx.add_window_view(|window, cx| TermuaWindow::new(window, cx)); + let root = view.clone(); + window_cx.draw( + gpui::point(gpui::px(0.), gpui::px(0.)), + gpui::size( + gpui::AvailableSpace::Definite(gpui::px(900.)), + gpui::AvailableSpace::Definite(gpui::px(600.)), + ), + move |_, _| div().size_full().child(root), + ); + window_cx.run_until_parked(); + window_cx.update(|window, cx| { + view.update(cx, |this, cx| { + add_fake_local_terminal(this, TerminalType::Alacritty, window, cx); + }); + }); + window_cx.run_until_parked(); + window_cx.update(|_, app| { + assert_eq!( + app.global::() + .backend(), + Some(TerminalType::Alacritty) + ); + }); + assert!(window_cx.debug_bounds("termua-footbar-backend").is_some()); + assert!( + window_cx + .debug_bounds("termua-footbar-backend-image") + .is_some() + ); + let backend = window_cx + .debug_bounds("termua-footbar-backend") + .expect("backend icon"); + let issues = window_cx + .debug_bounds("termua-footbar-issues") + .expect("issues icon"); + assert!( + backend.origin.x < issues.origin.x, + "backend icon should be before the right-side buttons" + ); + + window_cx.deactivate_window(); + window_cx.run_until_parked(); + window_cx.update(|_, app| { + assert_eq!( + app.global::() + .backend(), + Some(TerminalType::Alacritty), + "app deactivation must not clear the active terminal tab backend" + ); + }); + assert!(window_cx.debug_bounds("termua-footbar-backend").is_some()); + + window_cx.update(|window, cx| { + view.update(cx, |this, cx| { + add_fake_local_terminal(this, TerminalType::WezTerm, window, cx); + }); + }); + window_cx.run_until_parked(); + window_cx.update(|_, app| { + assert_eq!( + app.global::() + .backend(), + Some(TerminalType::WezTerm) + ); + }); + assert!(window_cx.debug_bounds("termua-footbar-backend").is_some()); + assert!( + window_cx + .debug_bounds("termua-footbar-backend-image") + .is_some() + ); +} + +#[gpui::test] +fn main_window_reconnects_saved_ssh_terminal_panel(cx: &mut gpui::TestAppContext) { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let settings_path = unique_workspace_settings_path("restore-ssh-terminal"); + let _settings_guard = crate::settings::override_settings_json_path(settings_path); + let db_path = crate::store::tests::unique_test_db_path("restore-ssh-terminal"); + let _db_guard = crate::store::tests::override_termua_db_path(db_path); + let session_id = crate::store::save_ssh_session_config( + "ssh", + "prod", + crate::settings::TerminalBackend::Wezterm, + "example.com", + 22, + "xterm-256color", + "UTF-8", + ) + .expect("save ssh session"); + + cx.update(|app| { + gpui_component::init(app); + menubar::init(app); + gpui_term::init(app); + gpui_dock::init(app); + app.set_global(TermuaAppState::default()); + }); + + let attempts = Arc::new(AtomicUsize::new(0)); + let builder: SshTerminalBuilderFn = { + let attempts = attempts.clone(); + Arc::new(move |backend, _env, _opts| { + attempts.fetch_add(1, Ordering::SeqCst); + Ok(Box::new(FakeSshTerminalFactory { + backend, + recording_active: Arc::new(AtomicBool::new(false)), + }) as Box) + }) + }; + + let (first, first_cx) = cx.add_window_view(|window, cx| { + TermuaWindow::new_with_ssh_terminal_builder(window, builder.clone(), cx) + }); + first_cx.update(|window, cx| { + first.update(cx, |this, cx| { + this.open_session_by_id(session_id, window, cx); + }); + }); + for _ in 0..20 { + first_cx.run_until_parked(); + if attempts.load(Ordering::SeqCst) >= 1 { + break; + } + } + first_cx.run_until_parked(); + first_cx.update(|_window, cx| { + crate::workspace::save_to_path( + &crate::workspace::state_path(), + first.read(cx).dock_area.read(cx).dump(cx), + ) + .expect("save ssh terminal layout"); + }); + + let (restored, restored_cx) = cx.add_window_view(|window, cx| { + TermuaWindow::new_with_ssh_terminal_builder(window, builder, cx) + }); + for _ in 0..30 { + restored_cx.run_until_parked(); + let restored_ssh = restored_cx.update(|_window, cx| { + restored + .read(cx) + .dock_area + .read(cx) + .visible_tab_panels(cx) + .into_iter() + .flat_map(|tabs| tabs.read(cx).panels().to_vec()) + .filter_map(|panel| panel.view().downcast::().ok()) + .any(|panel| panel.read(cx).kind() == crate::panel::PanelKind::Ssh) + }); + if restored_ssh { + break; + } + } + + assert!(attempts.load(Ordering::SeqCst) >= 2); + let restored_ssh = restored_cx.update(|_window, cx| { + restored + .read(cx) + .dock_area + .read(cx) + .visible_tab_panels(cx) + .into_iter() + .flat_map(|tabs| tabs.read(cx).panels().to_vec()) + .filter_map(|panel| panel.view().downcast::().ok()) + .any(|panel| panel.read(cx).kind() == crate::panel::PanelKind::Ssh) + }); + assert!( + restored_ssh, + "saved SSH tab should reconnect as a terminal panel" + ); + + std::fs::remove_dir_all(crate::settings::settings_dir_path()).ok(); +} + +#[gpui::test] +fn main_window_restores_right_sidebar_stable_state(cx: &mut gpui::TestAppContext) { + let settings_path = unique_workspace_settings_path("restore-right-sidebar"); + let _guard = crate::settings::override_settings_json_path(settings_path); + + cx.update(|app| { + gpui_component::init(app); + menubar::init(app); + gpui_term::init(app); + gpui_dock::init(app); + app.set_global(TermuaAppState::default()); + }); + + let (first, first_cx) = cx.add_window_view(|window, cx| TermuaWindow::new(window, cx)); + first_cx.update(|_window, cx| { + cx.global_mut::() + .active_tab = crate::right_sidebar::RightSidebarTab::Assistant; + cx.global_mut::() + .push(crate::assistant::AssistantRole::User, "remember this"); + crate::workspace::save_to_path( + &crate::workspace::state_path(), + first.read(cx).dock_area.read(cx).dump(cx), + ) + .expect("save right sidebar state"); + + cx.global_mut::() + .active_tab = crate::right_sidebar::RightSidebarTab::Notifications; + cx.global_mut::().clear(); + }); + + let (_restored, restored_cx) = cx.add_window_view(|window, cx| TermuaWindow::new(window, cx)); + restored_cx.update(|_window, cx| { + assert_eq!( + cx.global::() + .active_tab, + crate::right_sidebar::RightSidebarTab::Assistant + ); + let messages = &cx.global::().messages; + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].content.as_ref(), "remember this"); + }); + + std::fs::remove_dir_all(crate::settings::settings_dir_path()).ok(); +} + #[test] fn terminal_context_menu_labels_follow_the_active_locale() { let _guard = crate::locale::lock(); @@ -576,14 +1290,17 @@ fn ssh_connect_clears_sessions_sidebar_connecting_state(cx: &mut gpui::TestAppCo } #[gpui::test] -fn recorder_terminal_view_disables_context_menu(cx: &mut gpui::TestAppContext) { +fn recorder_terminal_view_enables_context_menu(cx: &mut gpui::TestAppContext) { cx.update(|app| { gpui_component::init(app); + gpui_dock::init(app); gpui_term::init(app); + app.set_global(TermuaAppState::default()); }); let cx = cx.add_empty_window(); cx.update(|window, cx| { + let termua = cx.new(|cx| TermuaWindow::new(window, cx)); let active = Arc::new(AtomicBool::new(false)); let term = cx.new(|_| { Terminal::new( @@ -591,12 +1308,103 @@ fn recorder_terminal_view_disables_context_menu(cx: &mut gpui::TestAppContext) { Box::new(FakeBackend::new(Arc::clone(&active))), ) }); - let terminal_view = - cx.new(|cx| TerminalView::new_with_context_menu(term, window, cx, false)); - assert!(!terminal_view.read(cx).context_menu_enabled()); + let terminal_view = termua.update(cx, |this, cx| { + this.create_terminal_view(crate::panel::PanelKind::Recorder, term, window, cx) + }); + assert!(terminal_view.read(cx).context_menu_enabled()); }); } +#[gpui::test] +fn recorder_terminal_view_supports_copy_and_select_all_shortcuts(cx: &mut gpui::TestAppContext) { + use std::{cell::RefCell, rc::Rc}; + + cx.update(|app| { + gpui_component::init(app); + gpui_dock::init(app); + gpui_term::init(app); + crate::menu::bind_menu_shortcuts(app); + app.set_global(TermuaAppState::default()); + }); + + let copy_count = Arc::new(AtomicUsize::new(0)); + let select_all_count = Arc::new(AtomicUsize::new(0)); + let copy_count_for_window = Arc::clone(©_count); + let select_all_count_for_window = Arc::clone(&select_all_count); + let terminal_view_slot = Rc::new(RefCell::new(None)); + let terminal_view_slot_for_window = Rc::clone(&terminal_view_slot); + let (_root, window_cx) = cx.add_window_view(move |window, cx| { + let termua = cx.new(|cx| TermuaWindow::new(window, cx)); + let term = cx.new(|_| { + Terminal::new( + TerminalType::WezTerm, + Box::new(FakeBackend::with_action_counts( + Arc::new(AtomicBool::new(false)), + copy_count_for_window, + select_all_count_for_window, + )), + ) + }); + let terminal_view = termua.update(cx, |this, cx| { + this.create_terminal_view(crate::panel::PanelKind::Recorder, term, window, cx) + }); + *terminal_view_slot_for_window.borrow_mut() = Some(terminal_view.clone()); + gpui_component::Root::new(terminal_view, window, cx) + }); + let terminal_view = terminal_view_slot + .borrow() + .clone() + .expect("expected recorder terminal view"); + + window_cx.update(|window, cx| { + let focus_handle = terminal_view.read(cx).focus_handle.clone(); + window.focus(&focus_handle, cx); + }); + window_cx.run_until_parked(); + window_cx.update(|window, cx| { + let focus_handle = terminal_view.read(cx).focus_handle.clone(); + let copy_binding = window + .highest_precedence_binding_for_action_in(&gpui_term::Copy, &focus_handle) + .expect("copy must have a terminal shortcut"); + let select_all_binding = window + .highest_precedence_binding_for_action_in(&gpui_term::SelectAll, &focus_handle) + .expect("select all must have a terminal shortcut"); + let expected_copy = if cfg!(target_os = "macos") { + "cmd-c" + } else { + "ctrl-shift-c" + }; + let expected_select_all = if cfg!(target_os = "macos") { + "cmd-a" + } else { + "ctrl-shift-a" + }; + + assert_eq!( + copy_binding.keystrokes()[0].as_keystroke().unparse(), + expected_copy + ); + assert_eq!( + select_all_binding.keystrokes()[0].as_keystroke().unparse(), + expected_select_all + ); + assert!( + window + .highest_precedence_binding_for_action_in( + &crate::ToggleAssistantSidebar, + &focus_handle, + ) + .is_none(), + "assistant shortcut must not compete with terminal shortcuts" + ); + }); + window_cx.dispatch_action(gpui_term::Copy); + window_cx.dispatch_action(gpui_term::SelectAll); + + assert_eq!(copy_count.load(Ordering::SeqCst), 1); + assert_eq!(select_all_count.load(Ordering::SeqCst), 1); +} + #[gpui::test] fn main_window_renders_sessions_sidebar_by_default(cx: &mut gpui::TestAppContext) { cx.update(|app| { @@ -812,6 +1620,10 @@ fn close_terminal_event_closes_local_terminal_tab(cx: &mut gpui::TestAppContext) ); window_cx.update(|_window, app| { + app.set_global(crate::footbar::FocusedTerminalBackendState::focused( + 42, + TerminalType::WezTerm, + )); terminal.update(app, |_terminal, cx| { cx.emit(TerminalEvent::CloseTerminal); }); @@ -838,6 +1650,14 @@ fn close_terminal_event_closes_local_terminal_tab(cx: &mut gpui::TestAppContext) terminal_tabs_after, 0, "expected close event on local terminal to remove the terminal tab" ); + window_cx.update(|_, app| { + assert_eq!( + app.global::() + .backend(), + None, + "closing the focused terminal should hide its backend icon" + ); + }); } #[cfg_attr(target_os = "macos", ignore)] @@ -2630,9 +3450,25 @@ fn ssh_sessions_with_missing_password_show_a_notification(cx: &mut gpui::TestApp struct FakeBackend { content: gpui_term::TerminalContent, recording_active: Arc, + copy_count: Arc, + select_all_count: Arc, exited: bool, } +struct FakeSshTerminalFactory { + backend: TerminalType, + recording_active: Arc, +} + +impl SshTerminalFactory for FakeSshTerminalFactory { + fn build(self: Box, _cx: &mut Context) -> Terminal { + Terminal::new( + self.backend, + Box::new(FakeBackend::new(self.recording_active)), + ) + } +} + impl FakeBackend { fn new(recording_active: Arc) -> Self { Self::with_exited(recording_active, false) @@ -2642,9 +3478,25 @@ impl FakeBackend { Self { content: gpui_term::TerminalContent::default(), recording_active, + copy_count: Arc::new(AtomicUsize::new(0)), + select_all_count: Arc::new(AtomicUsize::new(0)), exited, } } + + fn with_action_counts( + recording_active: Arc, + copy_count: Arc, + select_all_count: Arc, + ) -> Self { + Self { + content: gpui_term::TerminalContent::default(), + recording_active, + copy_count, + select_all_count, + exited: false, + } + } } impl TerminalBackend for FakeBackend { @@ -2696,9 +3548,13 @@ impl TerminalBackend for FakeBackend { fn select_matches(&mut self, _matches: &[RangeInclusive]) {} - fn select_all(&mut self) {} + fn select_all(&mut self) { + self.select_all_count.fetch_add(1, Ordering::SeqCst); + } - fn copy(&mut self, _keep_selection: Option, _cx: &mut Context) {} + fn copy(&mut self, _keep_selection: Option, _cx: &mut Context) { + self.copy_count.fetch_add(1, Ordering::SeqCst); + } fn clear(&mut self) {} diff --git a/termua/src/workspace.rs b/termua/src/workspace.rs new file mode 100644 index 0000000..1909e89 --- /dev/null +++ b/termua/src/workspace.rs @@ -0,0 +1,102 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Context as _; +use gpui_dock::DockAreaState; + +pub(crate) const STATE_VERSION: usize = 2; + +pub(crate) fn state_path() -> PathBuf { + #[cfg(test)] + if !crate::settings::settings_json_path_is_overridden() { + let thread = std::thread::current(); + let test_id = thread + .name() + .map(str::to_owned) + .unwrap_or_else(|| format!("{:?}", thread.id())) + .replace(|ch: char| !ch.is_ascii_alphanumeric(), "_"); + return std::env::temp_dir() + .join(format!("termua-workspace-tests-{}", std::process::id())) + .join(test_id) + .join("workspace.json"); + } + + crate::settings::settings_dir_path().join("workspace.json") +} + +pub(crate) fn save_to_path(path: &Path, state: DockAreaState) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create workspace state directory {parent:?}"))?; + } + let json = serde_json::to_string_pretty(&state).context("serialize dock workspace state")?; + crate::atomic_write::write_string(path, &json) +} + +pub(crate) fn load_from_path(path: &Path) -> anyhow::Result { + let json = std::fs::read_to_string(path) + .with_context(|| format!("read dock workspace state {path:?}"))?; + serde_json::from_str(&json).context("deserialize dock workspace state") +} + +#[cfg(test)] +mod tests { + use std::time::{SystemTime, UNIX_EPOCH}; + + use gpui_dock::{DockAreaState, PanelState}; + + fn unique_state_path(label: &str) -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!("termua-workspace-{label}-{nanos}.json")) + } + + #[test] + fn workspace_state_path_is_next_to_settings() { + let settings_path = unique_state_path("settings"); + let _guard = crate::settings::override_settings_json_path(settings_path.clone()); + + assert_eq!( + super::state_path(), + settings_path.parent().unwrap().join("workspace.json") + ); + } + + #[test] + fn default_test_workspace_state_path_is_isolated_from_user_config() { + let path = super::state_path(); + assert!(path.starts_with(std::env::temp_dir())); + assert_ne!( + path, + crate::settings::settings_dir_path().join("workspace.json") + ); + } + + #[test] + fn dock_state_round_trips_through_disk() { + let path = unique_state_path("round-trip"); + let state = DockAreaState { + version: Some(1), + center: PanelState::default(), + left_dock: None, + right_dock: None, + bottom_dock: None, + }; + + super::save_to_path(&path, state.clone()).expect("save dock state"); + let loaded = super::load_from_path(&path).expect("load dock state"); + + assert_eq!(loaded, state); + std::fs::remove_file(path).ok(); + } + + #[test] + fn corrupt_dock_state_is_reported() { + let path = unique_state_path("corrupt"); + std::fs::write(&path, "not json").expect("write corrupt state"); + + assert!(super::load_from_path(&path).is_err()); + std::fs::remove_file(path).ok(); + } +}