mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 16:02:24 +00:00
Merge origin/main into feat/sidebar-custom-groups
main's #806 changed the fold test while this branch changed toggle_sidebar_group to take Option<&GroupKey>; the textual merge left one call site on the old signature. Fixed here. Claude-Session: https://claude.ai/code/session_01MS7VnqvGRtNTrJG9zxtz51
This commit is contained in:
+60
-17
@@ -32,7 +32,7 @@ enum UnderlineKind {
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RenderCell {
|
||||
pub(super) struct RenderCell {
|
||||
c: char,
|
||||
marks: Option<Box<[char]>>,
|
||||
fg: Hsla,
|
||||
@@ -291,7 +291,7 @@ fn match_tint(cx: &gpui::App) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
struct PaintColors {
|
||||
pub(super) struct PaintColors {
|
||||
default_fg: Hsla,
|
||||
default_bg: Hsla,
|
||||
caret: Hsla,
|
||||
@@ -401,7 +401,7 @@ fn blend_toward(c: Hsla, dim: f32, under: Rgba) -> Hsla {
|
||||
}
|
||||
|
||||
impl PaintColors {
|
||||
fn resolve(theme: &gpui_component::Theme, cx: &gpui::App) -> Self {
|
||||
pub(super) fn resolve(theme: &gpui_component::Theme, cx: &gpui::App) -> Self {
|
||||
let default_fg = theme.foreground;
|
||||
let default_bg = theme.background;
|
||||
let caret = theme.caret;
|
||||
@@ -837,8 +837,6 @@ fn segment_row(row: &[RenderCell]) -> Vec<RowSeg> {
|
||||
thread_local! {
|
||||
static CHAR_STRINGS: RefCell<HashMap<char, SharedString>> = RefCell::new(HashMap::new());
|
||||
|
||||
static GRID_BUF: RefCell<Vec<RenderCell>> = const { RefCell::new(Vec::new()) };
|
||||
|
||||
/// Measured ink extents and the font size they were measured at.
|
||||
static INK_EXTENTS: RefCell<(Pixels, HashMap<(gpui::FontId, char), Option<Pixels>>)> =
|
||||
RefCell::new((px(0.), HashMap::new()));
|
||||
@@ -1413,7 +1411,7 @@ fn paint_glyphs(
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct GridCursor {
|
||||
pub(super) struct GridCursor {
|
||||
row: usize,
|
||||
col: usize,
|
||||
// Where the IME candidate window should anchor: the fake caret drawn by
|
||||
@@ -1553,7 +1551,8 @@ fn paint_marked(
|
||||
);
|
||||
}
|
||||
|
||||
struct GridSnapshot {
|
||||
#[derive(Clone)]
|
||||
pub(super) struct GridSnapshot {
|
||||
cursor: Option<GridCursor>,
|
||||
sliver: Option<Vec<RenderCell>>,
|
||||
any_selected: bool,
|
||||
@@ -1567,7 +1566,7 @@ struct GridSnapshot {
|
||||
}
|
||||
|
||||
impl TerminalElement {
|
||||
fn build_grid(
|
||||
pub(super) fn build_grid(
|
||||
&self,
|
||||
colors: &PaintColors,
|
||||
buf: &mut Vec<RenderCell>,
|
||||
@@ -1577,9 +1576,8 @@ impl TerminalElement {
|
||||
cx: &App,
|
||||
dim: f32,
|
||||
under: Rgba,
|
||||
) -> GridSnapshot {
|
||||
buf.clear();
|
||||
buf.resize(rows * cols, RenderCell::default());
|
||||
must_block: bool,
|
||||
) -> Option<GridSnapshot> {
|
||||
let mut cursor: Option<GridCursor> = None;
|
||||
let mut sliver: Option<Vec<RenderCell>> = None;
|
||||
let mut any_selected = false;
|
||||
@@ -1591,7 +1589,32 @@ impl TerminalElement {
|
||||
palette[..16].copy_from_slice(&active.ansi16);
|
||||
}
|
||||
let term = self.view.read(cx).terminal.term.clone();
|
||||
let term = term.lock();
|
||||
// Rendering does not queue for the grid lock. Holding it is the
|
||||
// pane's own reader, part-way through feeding a batch of output
|
||||
// into the emulator — and one UI thread draws every pane in every
|
||||
// window, so waiting here wires one pane's write speed to the frame
|
||||
// rate of the whole window. That is the same illness as #709, which
|
||||
// was this thread parked in `write(2)` for a stalled link; this is
|
||||
// the read side of it. A frame that cannot have the lock paints the
|
||||
// one before it, and nobody can see a frame of lag.
|
||||
//
|
||||
// `try_lock_unfair` rather than a lease: a painter that queued
|
||||
// would make the reader wait for a frame it is not going to get
|
||||
// anyway. Skipping the queue is safe precisely because it never
|
||||
// waits.
|
||||
let term = match term.try_lock_unfair() {
|
||||
Some(term) => term,
|
||||
// The two frames that have to have it: the first one, with no
|
||||
// previous grid to fall back on, and the one after a resize,
|
||||
// where the previous grid is the wrong shape. Both are rare and
|
||||
// neither is in the steady state.
|
||||
None if must_block => term.lock(),
|
||||
None => return None,
|
||||
};
|
||||
// After the lock, not before: an early return must leave the
|
||||
// previous frame's cells intact for the caller to paint again.
|
||||
buf.clear();
|
||||
buf.resize(rows * cols, RenderCell::default());
|
||||
let content = term.renderable_content();
|
||||
display_offset = content.display_offset as i32;
|
||||
history_size = term.grid().history_size();
|
||||
@@ -1683,7 +1706,7 @@ impl TerminalElement {
|
||||
let (any_match, any_current) =
|
||||
self.flag_search_matches(buf, rows, cols, display_offset, cx);
|
||||
self.flag_hovered_link(buf, rows, cols, display_offset, cx);
|
||||
GridSnapshot {
|
||||
Some(GridSnapshot {
|
||||
cursor,
|
||||
sliver,
|
||||
any_selected,
|
||||
@@ -1691,7 +1714,7 @@ impl TerminalElement {
|
||||
any_current,
|
||||
display_offset,
|
||||
history_size,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn flag_hovered_link(
|
||||
@@ -2035,8 +2058,18 @@ impl Element for TerminalElement {
|
||||
(colors, 1., Rgba::default())
|
||||
};
|
||||
|
||||
let mut buf = GRID_BUF.with(|b| std::mem::take(&mut *b.borrow_mut()));
|
||||
let snap = self.build_grid(
|
||||
// This pane's previous frame, borrowed for the duration of this one.
|
||||
// `build_grid` overwrites it when it gets the terminal lock, and leaves
|
||||
// it exactly as it is when it does not.
|
||||
let mut buf = self
|
||||
.view
|
||||
.update(cx, |view, _| std::mem::take(&mut view.grid_buf));
|
||||
let previous = self.view.read(cx).grid_snap.clone();
|
||||
// The two frames with nothing to fall back on: the first one this pane
|
||||
// ever paints, and the one after a resize, whose previous grid is the
|
||||
// wrong shape to paint into these bounds. Those wait for the lock.
|
||||
let must_block = previous.is_none() || buf.len() != geom.rows * geom.cols;
|
||||
let built = self.build_grid(
|
||||
&colors,
|
||||
&mut buf,
|
||||
geom.rows,
|
||||
@@ -2045,7 +2078,17 @@ impl Element for TerminalElement {
|
||||
cx,
|
||||
dim,
|
||||
under,
|
||||
must_block,
|
||||
);
|
||||
if let Some(snap) = &built {
|
||||
let snap = snap.clone();
|
||||
self.view.update(cx, |view, _| view.grid_snap = Some(snap));
|
||||
}
|
||||
// `must_block` above is exactly the condition under which `build_grid`
|
||||
// is not allowed to come back empty, so one of the two is always here.
|
||||
let Some(snap) = built.or(previous) else {
|
||||
return;
|
||||
};
|
||||
let cursor = snap.cursor;
|
||||
let sliver = snap.sliver.as_ref();
|
||||
|
||||
@@ -2241,7 +2284,7 @@ impl Element for TerminalElement {
|
||||
}
|
||||
});
|
||||
|
||||
GRID_BUF.with(|b| *b.borrow_mut() = buf);
|
||||
self.view.update(cx, |view, _| view.grid_buf = buf);
|
||||
|
||||
self.register_mouse_handlers(geom, bounds, prepaint.hitbox.id, window);
|
||||
|
||||
|
||||
+125
-4
@@ -16,7 +16,7 @@ use gpui_component::{ActiveTheme as _, Icon, IconName, WindowExt as _, h_flex};
|
||||
use super::TermSize;
|
||||
use super::cmd_editor::CmdEditor;
|
||||
use super::completion::{self, CandidateKind, CompletionSession};
|
||||
use super::element::TerminalElement;
|
||||
use super::element::{GridSnapshot, RenderCell, TerminalElement};
|
||||
use super::highlight::{self, TokenKind};
|
||||
use super::hold::{GapHold, Verdict};
|
||||
use super::remote::RemoteTerminal;
|
||||
@@ -288,6 +288,22 @@ pub struct TerminalView {
|
||||
pub line_height_mul: f32,
|
||||
pub cell_width: Pixels,
|
||||
pub(super) line_height: Pixels,
|
||||
/// The grid the last frame painted, and the snapshot that went with it.
|
||||
/// A frame that cannot have the terminal lock repaints this rather than
|
||||
/// waiting on the pane's reader — see [`TerminalElement::build_grid`].
|
||||
/// Owned per pane rather than kept in one shared scratch buffer, because
|
||||
/// what makes it reusable is that it is still the *previous frame of this
|
||||
/// pane* when the next one starts.
|
||||
pub(super) grid_buf: Vec<RenderCell>,
|
||||
pub(super) grid_snap: Option<GridSnapshot>,
|
||||
/// Terminal mode and selection as of the last frame that got the lock.
|
||||
/// What the *frame* declares — the keymap context it publishes, whether it
|
||||
/// draws a selection — is read from here, so drawing never queues behind
|
||||
/// the pane's reader for two bits it can be one frame late about.
|
||||
/// Everything with a decision to make (a keystroke asking whether a
|
||||
/// full-screen program owns the screen) still asks the terminal itself.
|
||||
frame_alt_screen: bool,
|
||||
frame_has_selection: bool,
|
||||
selecting: bool,
|
||||
drag_scroll: Option<DragScroll>,
|
||||
drag_scroll_epoch: u64,
|
||||
@@ -1446,6 +1462,10 @@ impl TerminalView {
|
||||
line_height_mul,
|
||||
cell_width: px(8.),
|
||||
line_height: px(17.),
|
||||
grid_buf: Vec::new(),
|
||||
grid_snap: None,
|
||||
frame_alt_screen: false,
|
||||
frame_has_selection: false,
|
||||
selecting: false,
|
||||
drag_scroll: None,
|
||||
drag_scroll_epoch: 0,
|
||||
@@ -2784,8 +2804,12 @@ impl TerminalView {
|
||||
self.terminal.term.lock().selection.is_some()
|
||||
}
|
||||
|
||||
/// Whether *this frame* draws a selection. The grid half comes from
|
||||
/// [`Self::sync_frame_facts`] rather than the terminal, which is what keeps
|
||||
/// the draw off the lock; a selection that appears while the reader holds
|
||||
/// it is drawn one frame later.
|
||||
fn any_selection(&self) -> bool {
|
||||
self.has_selection() || (self.input_active() && self.cmd.selected_text().is_some())
|
||||
self.frame_has_selection || (self.input_active() && self.cmd.selected_text().is_some())
|
||||
}
|
||||
|
||||
/// The keymap context this pane declares each frame.
|
||||
@@ -2797,7 +2821,13 @@ impl TerminalView {
|
||||
pub(super) fn key_context(&self) -> gpui::KeyContext {
|
||||
let mut context = gpui::KeyContext::new_with_defaults();
|
||||
context.add("Terminal");
|
||||
if self.on_alt_screen() {
|
||||
// The frame's own answer, not the terminal's. gpui matches keystrokes
|
||||
// against the context the last painted frame published, so this was
|
||||
// already a frame-old reading of the mode even when it locked; the
|
||||
// chord that must not be a frame late (`AlternatePaste`) asks the
|
||||
// terminal again in `alternate_paste`, which is what that comment
|
||||
// below is about.
|
||||
if self.frame_alt_screen {
|
||||
context.add("alt_screen");
|
||||
}
|
||||
context
|
||||
@@ -5398,7 +5428,12 @@ impl TerminalView {
|
||||
// paint the grid shifted off the row the thumb just picked.
|
||||
self.scroll_frac = 0.;
|
||||
}
|
||||
let term = self.terminal.term.lock();
|
||||
// Not worth a wait: the scrollbar is a picture of where the grid is,
|
||||
// and a frame that cannot have the lock keeps the picture it drew last
|
||||
// time rather than parking the whole window to refresh a thumb.
|
||||
let Some(term) = self.terminal.term.try_lock_unfair() else {
|
||||
return;
|
||||
};
|
||||
let grid = GridScroll {
|
||||
history: term.grid().history_size(),
|
||||
display_offset: term.grid().display_offset(),
|
||||
@@ -5409,6 +5444,23 @@ impl TerminalView {
|
||||
self.scroll_handle.sync(grid);
|
||||
}
|
||||
|
||||
/// Re-read the two things the frame itself declares — the terminal mode its
|
||||
/// keymap context is built from, and whether there is a selection to draw.
|
||||
///
|
||||
/// One `try_lock` at the top of the frame, not one per reader: every
|
||||
/// caller inside `render` would otherwise take the lock separately, and
|
||||
/// each of those is another chance to sit behind the pane's reader with the
|
||||
/// whole window's frame in hand. Failing to get it leaves the previous
|
||||
/// frame's answers in place, which is the same bargain the grid makes in
|
||||
/// [`TerminalElement::build_grid`].
|
||||
fn sync_frame_facts(&mut self) {
|
||||
let Some(term) = self.terminal.term.try_lock_unfair() else {
|
||||
return;
|
||||
};
|
||||
self.frame_alt_screen = term.mode().contains(TermMode::ALT_SCREEN);
|
||||
self.frame_has_selection = term.selection.is_some();
|
||||
}
|
||||
|
||||
/// The scrollback bar, laid down the right edge of the grid.
|
||||
///
|
||||
/// The track is inset to the rows themselves — [`GRID_PAD_Y`] is padding
|
||||
@@ -6537,6 +6589,7 @@ impl Drop for TerminalView {
|
||||
|
||||
impl Render for TerminalView {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
self.sync_frame_facts();
|
||||
self.sync_typeahead_owner();
|
||||
self.sync_scrollbar();
|
||||
if self.shell_owns_prompt() {
|
||||
@@ -13366,6 +13419,74 @@ mod gpui_tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Drawing must never queue for the grid lock.
|
||||
///
|
||||
/// One UI thread paints every pane in every window, and the thread holding
|
||||
/// this lock is the pane's own reader part-way through feeding a batch of
|
||||
/// output into the emulator. A draw that waited for it would wire one
|
||||
/// pane's write speed to the frame rate of the whole window — the read-side
|
||||
/// twin of #709, which was this same thread parked in `write(2)`.
|
||||
///
|
||||
/// No second thread and no timing: `try_lock` fails against a lock this
|
||||
/// thread already holds, so "the reader has it" is reproduced exactly, with
|
||||
/// nothing to race. The cost of that trade is what a regression looks like
|
||||
/// — put `lock()` back and this test hangs on the re-entry rather than
|
||||
/// failing, which reads as a CI timeout on exactly this name.
|
||||
#[gpui::test]
|
||||
fn a_frame_that_cannot_have_the_grid_leaves_the_previous_one_alone(cx: &mut TestAppContext) {
|
||||
use super::super::element::PaintColors;
|
||||
|
||||
let (_window, view, _daemon) = rooted_harness(cx);
|
||||
let element = TerminalElement::new(view.clone());
|
||||
let mut buf = Vec::new();
|
||||
let build = |cx: &mut TestAppContext, buf: &mut Vec<RenderCell>, must_block: bool| {
|
||||
cx.update(|cx| {
|
||||
let colors = PaintColors::resolve(cx.theme(), cx);
|
||||
element.build_grid(
|
||||
&colors,
|
||||
buf,
|
||||
24,
|
||||
80,
|
||||
false,
|
||||
cx,
|
||||
1.,
|
||||
gpui::Rgba::default(),
|
||||
must_block,
|
||||
)
|
||||
})
|
||||
};
|
||||
|
||||
assert!(
|
||||
build(cx, &mut buf, true).is_some(),
|
||||
"the first frame has no previous grid to stand in for it, so it waits and builds"
|
||||
);
|
||||
assert_eq!(buf.len(), 24 * 80);
|
||||
|
||||
// Shortened so the next call cannot touch the buffer without saying so:
|
||||
// building would `clear` and `resize` it back to a full grid.
|
||||
buf.truncate(3);
|
||||
let term = cx.update(|cx| view.read(cx).terminal.term.clone());
|
||||
let held = term.lock();
|
||||
let refused = build(cx, &mut buf, false);
|
||||
drop(held);
|
||||
|
||||
assert!(
|
||||
refused.is_none(),
|
||||
"a frame that cannot have the lock says so instead of waiting for it"
|
||||
);
|
||||
assert_eq!(
|
||||
buf.len(),
|
||||
3,
|
||||
"the previous frame's cells have to survive for that frame to be painted again"
|
||||
);
|
||||
|
||||
assert!(
|
||||
build(cx, &mut buf, false).is_some(),
|
||||
"with the lock free, a frame builds without being told to wait"
|
||||
);
|
||||
assert_eq!(buf.len(), 24 * 80);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_highlight_follows_its_text_as_output_scrolls_under_it(cx: &mut TestAppContext) {
|
||||
let (window, view, _daemon) = rooted_harness(cx);
|
||||
|
||||
+125
-30
@@ -763,6 +763,10 @@ pub(crate) struct LoopbackForwardPanelState {
|
||||
/// Why the last Add or Save did not take, in the far side's own words.
|
||||
/// Cleared the moment the form is closed or the edit is abandoned.
|
||||
pub(crate) mf_error: Option<String>,
|
||||
/// Return, on each of the five boxes. Held here for the same reason the
|
||||
/// sftp form holds its own: a live subscription on a box nothing is
|
||||
/// showing would answer Return for a form that is gone.
|
||||
pub(crate) mf_subs: Vec<Subscription>,
|
||||
}
|
||||
|
||||
pub struct Tty7App {
|
||||
@@ -1422,6 +1426,7 @@ impl Tty7App {
|
||||
mf_description,
|
||||
mf_editing: None,
|
||||
mf_error: None,
|
||||
mf_subs: Vec::new(),
|
||||
},
|
||||
sftp_panel,
|
||||
right_panel: Default::default(),
|
||||
@@ -2991,6 +2996,49 @@ impl Tty7App {
|
||||
self.loopback_panel.form_pane_id = Some(pane_id);
|
||||
self.cancel_managed_forward_edit(window, cx);
|
||||
self.refresh_managed_forwards(pane_id, cx);
|
||||
self.arm_managed_forward_form(pane_id, window, cx);
|
||||
}
|
||||
|
||||
/// Opens the form focused and listening for Return.
|
||||
///
|
||||
/// It had neither. Every other form in the app opens with the caret in the
|
||||
/// first field and answers Return — this one opened cold, so adding a rule
|
||||
/// meant clicking into Bind first, and once you were there the only way to
|
||||
/// commit was the mouse again. Escape did nothing either, which is handled
|
||||
/// on the form itself in `forwards.rs`; a key event only reaches it while
|
||||
/// something inside it holds focus, so the focus below is what makes that
|
||||
/// work too.
|
||||
fn arm_managed_forward_form(
|
||||
&mut self,
|
||||
pane_id: u64,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let inputs = [
|
||||
self.loopback_panel.mf_bind_host.clone(),
|
||||
self.loopback_panel.mf_bind_port.clone(),
|
||||
self.loopback_panel.mf_target_host.clone(),
|
||||
self.loopback_panel.mf_target_port.clone(),
|
||||
self.loopback_panel.mf_description.clone(),
|
||||
];
|
||||
self.loopback_panel.mf_subs = inputs
|
||||
.iter()
|
||||
.map(|input| {
|
||||
cx.subscribe_in(
|
||||
input,
|
||||
window,
|
||||
move |this, _input, ev: &InputEvent, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = ev {
|
||||
// A no-op when the fields do not make a rule yet:
|
||||
// `add_managed_forward` already guards on that and
|
||||
// the form already says what is missing.
|
||||
this.add_managed_forward(pane_id, window, cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
inputs[0].update(cx, |s, cx| s.focus(window, cx));
|
||||
}
|
||||
|
||||
pub(crate) fn close_managed_forward_form(
|
||||
@@ -2998,8 +3046,14 @@ impl Tty7App {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.loopback_panel.form_pane_id = None;
|
||||
let was_open = self.loopback_panel.form_pane_id.take().is_some();
|
||||
self.loopback_panel.mf_subs.clear();
|
||||
self.cancel_managed_forward_edit(window, cx);
|
||||
if was_open {
|
||||
// The form held the focus, so taking it down has to hand it back —
|
||||
// otherwise the next keystroke goes nowhere until the user clicks.
|
||||
self.focus_active(window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn open_typed_ssh_connect(&mut self, input: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -7079,31 +7133,11 @@ impl Tty7App {
|
||||
return None;
|
||||
}
|
||||
let notice = self.remote_status(cx)?.input_notice()?;
|
||||
let theme = cx.theme();
|
||||
// The pill only. `body_area` anchors it, together with whatever else
|
||||
// is floating down there — see `ui::notice`.
|
||||
Some(
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_4()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.child(
|
||||
gpui_component::h_flex()
|
||||
.occlude()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_3()
|
||||
.py_1p5()
|
||||
.rounded_lg()
|
||||
.bg(theme.popover)
|
||||
.border_1()
|
||||
.border_color(theme.warning.opacity(0.4))
|
||||
.shadow_md()
|
||||
.text_xs()
|
||||
.text_color(theme.muted_foreground)
|
||||
.child(notice),
|
||||
)
|
||||
crate::ui::notice::pill(cx.theme().warning, cx)
|
||||
.child(notice)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
@@ -7320,13 +7354,23 @@ impl Render for Tty7App {
|
||||
.child(body)
|
||||
.when_some(self.pane_landing(window, cx), |this, el| this.child(el))
|
||||
.when_some(tab_landing, |this, el| this.child(el))
|
||||
.when_some(ssh_status, |this, el| this.child(el))
|
||||
.when_some(self.render_remote_workspace_strip(cx), |this, el| {
|
||||
this.child(el)
|
||||
})
|
||||
.when_some(self.render_remote_input_notice(cx), |this, el| {
|
||||
this.child(el)
|
||||
});
|
||||
// Both of these used to anchor themselves at `bottom_4` and centre
|
||||
// themselves, as siblings here — so a remote workspace whose ssh
|
||||
// link had also dropped drew them one on top of the other. One
|
||||
// anchor now, and it stacks. The ssh strip goes last because it is
|
||||
// the one carrying buttons.
|
||||
.when_some(
|
||||
crate::ui::notice::anchor(
|
||||
[self.render_remote_input_notice(cx), ssh_status]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
),
|
||||
|this, el| this.child(el),
|
||||
);
|
||||
|
||||
// One decision for the whole document surface. Docked, exactly one of
|
||||
// the two surfaces is drawn — a column has one child, and two `flex_1`
|
||||
@@ -10470,7 +10514,7 @@ mod zoom_gpui_tests {
|
||||
// are left holding when the far side does not answer.
|
||||
#[cfg(test)]
|
||||
mod managed_forward_gpui_tests {
|
||||
use gpui::TestAppContext;
|
||||
use gpui::{Focusable as _, TestAppContext};
|
||||
use gpui_component::input::InputState;
|
||||
|
||||
use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind};
|
||||
@@ -10490,6 +10534,57 @@ mod managed_forward_gpui_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The form had no keyboard contract at all: no Return, no Escape, and it
|
||||
/// opened cold, with the caret still in the terminal behind it. Every
|
||||
/// sibling form in the app has all three.
|
||||
///
|
||||
/// Escape is a `on_key_down` on the form itself and only fires while
|
||||
/// something inside it holds focus, so the focus below is what makes both
|
||||
/// halves work; the subscriptions are what answer Return. Asserting on
|
||||
/// both together is the point — arming one without the other is the state
|
||||
/// this test exists to catch.
|
||||
#[gpui::test]
|
||||
fn opening_the_forward_form_arms_the_keyboard_and_closing_disarms_it(cx: &mut TestAppContext) {
|
||||
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
|
||||
|
||||
app.update_in(&mut vcx, |app, window, cx| {
|
||||
assert!(
|
||||
app.loopback_panel.mf_subs.is_empty(),
|
||||
"nothing is listening before the form is up"
|
||||
);
|
||||
|
||||
app.toggle_managed_forward_form(1, window, cx);
|
||||
|
||||
assert_eq!(
|
||||
app.loopback_panel.form_pane_id,
|
||||
Some(1),
|
||||
"the form is up for the pane that asked"
|
||||
);
|
||||
assert_eq!(
|
||||
app.loopback_panel.mf_subs.len(),
|
||||
5,
|
||||
"Return has to be answered on every box, not just the first"
|
||||
);
|
||||
assert!(
|
||||
app.loopback_panel
|
||||
.mf_bind_host
|
||||
.read(cx)
|
||||
.focus_handle(cx)
|
||||
.is_focused(window),
|
||||
"the form opens with the caret in Bind, so Escape reaches it too"
|
||||
);
|
||||
|
||||
app.close_managed_forward_form(window, cx);
|
||||
|
||||
assert_eq!(app.loopback_panel.form_pane_id, None);
|
||||
assert!(
|
||||
app.loopback_panel.mf_subs.is_empty(),
|
||||
"a live subscription on a box nothing is showing would answer \
|
||||
Return for a form that is gone"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_add_that_never_reaches_the_session_leaves_the_panel_as_it_was(cx: &mut TestAppContext) {
|
||||
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
|
||||
|
||||
@@ -49,6 +49,7 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> {
|
||||
"icons/refresh.svg" => include_bytes!("../../assets/icons/refresh.svg"),
|
||||
"icons/agents/claude.svg" => include_bytes!("../../assets/icons/agents/claude.svg"),
|
||||
"icons/agents/codex.svg" => include_bytes!("../../assets/icons/agents/codex.svg"),
|
||||
"icons/agents/traecli.svg" => include_bytes!("../../assets/icons/agents/traecli.svg"),
|
||||
"icons/agents/gemini.svg" => include_bytes!("../../assets/icons/agents/gemini.svg"),
|
||||
"icons/agents/amp.svg" => include_bytes!("../../assets/icons/agents/amp.svg"),
|
||||
"icons/agents/opencode.svg" => include_bytes!("../../assets/icons/agents/opencode.svg"),
|
||||
|
||||
+19
-30
@@ -148,28 +148,13 @@ impl Tty7App {
|
||||
.and_then(|id| uuid::Uuid::parse_str(&id).ok());
|
||||
|
||||
let theme = cx.theme();
|
||||
let (danger, foreground) = (theme.danger, theme.foreground);
|
||||
|
||||
let bar = h_flex()
|
||||
.occlude()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_3()
|
||||
.py_1p5()
|
||||
.rounded_lg()
|
||||
.bg(theme.popover)
|
||||
.border_1()
|
||||
.border_color(theme.danger.opacity(0.4))
|
||||
.shadow_md()
|
||||
// Off the right panel's ramp on purpose: this bar floats over the
|
||||
// terminal, not inside the panel, and it is sized against the
|
||||
// terminal's own text. `app.rs` draws it, `render_panel_info` does
|
||||
// not.
|
||||
.text_xs()
|
||||
.text_color(theme.muted_foreground)
|
||||
let bar = crate::ui::notice::pill(danger, cx)
|
||||
.child(
|
||||
div()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(theme.foreground)
|
||||
.text_color(foreground)
|
||||
.child(if host.is_empty() {
|
||||
t(L10nKey::ForwardDisconnected).to_string()
|
||||
} else {
|
||||
@@ -186,7 +171,7 @@ impl Tty7App {
|
||||
.id("ssh-strip-reason")
|
||||
.max_w(px(360.))
|
||||
.truncate()
|
||||
.text_color(theme.danger)
|
||||
.text_color(danger)
|
||||
.tooltip(move |window, cx| {
|
||||
gpui_component::tooltip::Tooltip::new(full.clone()).build(window, cx)
|
||||
})
|
||||
@@ -219,17 +204,10 @@ impl Tty7App {
|
||||
this.open_ssh_profile_in_settings(id, window, cx)
|
||||
}))
|
||||
}));
|
||||
Some(
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_4()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.child(bar)
|
||||
.into_any_element(),
|
||||
)
|
||||
// The bar only. `body_area` anchors it, together with whatever else is
|
||||
// floating down there — this used to place itself at `bottom_4` and so
|
||||
// did the remote input notice, on the same container.
|
||||
Some(bar.into_any_element())
|
||||
}
|
||||
|
||||
pub(crate) fn forwards_section(
|
||||
@@ -456,6 +434,17 @@ impl Tty7App {
|
||||
.pt(px(6.))
|
||||
.pb(px(2.))
|
||||
.gap(px(5.))
|
||||
// Escape backs out of the form, the way it backs out of the sftp
|
||||
// edit box and every sheet the app puts up. Return is answered by
|
||||
// the boxes themselves — see `arm_managed_forward_form` — because
|
||||
// an Input takes Return before it can bubble to here.
|
||||
.on_key_down(
|
||||
cx.listener(move |this, ev: &gpui::KeyDownEvent, window, cx| {
|
||||
if ev.keystroke.key == "escape" {
|
||||
this.close_managed_forward_form(window, cx);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(self.segmented_on(
|
||||
sf,
|
||||
"ssh-managed-forward-kind",
|
||||
|
||||
@@ -738,6 +738,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
}
|
||||
L10nKey::SettingsAgentClaudeCode => "Claude Code",
|
||||
L10nKey::SettingsAgentCodex => "Codex",
|
||||
L10nKey::SettingsAgentTraeCode => "TraeCode",
|
||||
L10nKey::SettingsAgentCopilotCli => "Copilot CLI",
|
||||
L10nKey::SettingsAgentOpencode => "OpenCode",
|
||||
L10nKey::SettingsAgentPi => "Pi",
|
||||
@@ -768,6 +769,9 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
"agent integration hooks install uninstall status rich session working waiting tab bar sidebar badge claude"
|
||||
}
|
||||
L10nKey::SettingsSearchCodexKeywords => "agent integration hooks install openai codex",
|
||||
L10nKey::SettingsSearchTraeCodeKeywords => {
|
||||
"agent integration hooks install trae code traecli traex"
|
||||
}
|
||||
L10nKey::SettingsSearchCommandLineToolKeywords => {
|
||||
"command line tool cli tty7 path shell command install symlink terminal iterm agent script"
|
||||
}
|
||||
|
||||
@@ -747,6 +747,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
}
|
||||
L10nKey::SettingsAgentClaudeCode => "Claude Code",
|
||||
L10nKey::SettingsAgentCodex => "Codex",
|
||||
L10nKey::SettingsAgentTraeCode => "TraeCode",
|
||||
L10nKey::SettingsAgentCopilotCli => "Copilot CLI",
|
||||
L10nKey::SettingsAgentOpencode => "OpenCode",
|
||||
L10nKey::SettingsAgentPi => "Pi",
|
||||
@@ -787,6 +788,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::SettingsSearchCodexKeywords => {
|
||||
"エージェント 統合 フック インストール openai codex agent integration hooks install"
|
||||
}
|
||||
L10nKey::SettingsSearchTraeCodeKeywords => {
|
||||
"エージェント 統合 フック インストール trae code traecli traex agent integration hooks install"
|
||||
}
|
||||
L10nKey::SettingsSearchCommandLineToolKeywords => {
|
||||
"cli tty7 パス シェル コマンド インストール シンボリックリンク ターミナル iterm エージェント スクリプト command line tool"
|
||||
}
|
||||
|
||||
@@ -570,6 +570,7 @@ l10n_keys! {
|
||||
SettingsAppHttpProxyInvalid,
|
||||
SettingsAgentClaudeCode,
|
||||
SettingsAgentCodex,
|
||||
SettingsAgentTraeCode,
|
||||
SettingsAgentCopilotCli,
|
||||
SettingsAgentOpencode,
|
||||
SettingsAgentPi,
|
||||
@@ -593,6 +594,7 @@ l10n_keys! {
|
||||
SettingsSearchBoldFontKeywords,
|
||||
SettingsSearchClaudeCodeKeywords,
|
||||
SettingsSearchCodexKeywords,
|
||||
SettingsSearchTraeCodeKeywords,
|
||||
SettingsSearchCommandLineToolKeywords,
|
||||
SettingsSearchCommandLineToolTitle,
|
||||
SettingsSearchCopilotCliKeywords,
|
||||
@@ -1537,6 +1539,7 @@ mod tests {
|
||||
// Product names.
|
||||
L10nKey::SettingsAgentClaudeCode,
|
||||
L10nKey::SettingsAgentCodex,
|
||||
L10nKey::SettingsAgentTraeCode,
|
||||
L10nKey::SettingsAgentCopilotCli,
|
||||
L10nKey::SettingsAgentDroid,
|
||||
L10nKey::SettingsAgentGemini,
|
||||
|
||||
@@ -656,6 +656,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::SettingsAppHttpProxyInvalid => "不是有效的代理地址,该值未保存。",
|
||||
L10nKey::SettingsAgentClaudeCode => "Claude Code",
|
||||
L10nKey::SettingsAgentCodex => "Codex",
|
||||
L10nKey::SettingsAgentTraeCode => "TraeCode",
|
||||
L10nKey::SettingsAgentCopilotCli => "Copilot CLI",
|
||||
L10nKey::SettingsAgentOpencode => "OpenCode",
|
||||
L10nKey::SettingsAgentPi => "Pi",
|
||||
@@ -694,6 +695,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::SettingsSearchCodexKeywords => {
|
||||
"Codex agent 集成 hook 安装 OpenAI codex agent integration hooks install"
|
||||
}
|
||||
L10nKey::SettingsSearchTraeCodeKeywords => {
|
||||
"TraeCode traecli traex agent 集成 hook 安装 agent integration hooks install"
|
||||
}
|
||||
L10nKey::SettingsSearchCommandLineToolKeywords => {
|
||||
"命令行工具 cli tty7 路径 shell 命令 安装 符号链接 terminal command line tool"
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod i18n;
|
||||
pub mod keymap;
|
||||
pub mod local_link;
|
||||
pub mod machine_mirror;
|
||||
pub mod notice;
|
||||
pub mod palette;
|
||||
pub mod pane;
|
||||
pub mod pane_drag;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! The pill the app floats over the terminal when something about the
|
||||
//! connection needs saying.
|
||||
//!
|
||||
//! There were two of these — `render_remote_input_notice` in `app.rs` and
|
||||
//! `render_ssh_status_strip` in `forwards.rs` — written out a builder call at
|
||||
//! a time in two files, identical down to the padding and differing only in
|
||||
//! the border colour. Each also placed itself: both were
|
||||
//! `absolute().left_0().right_0().bottom_4()`, centred, and both were children
|
||||
//! of the same container in `body_area`, with nothing arbitrating between
|
||||
//! them. A remote workspace whose ssh link had also dropped drew them on top
|
||||
//! of each other.
|
||||
//!
|
||||
//! So the shell lives here and the notices no longer place themselves. The
|
||||
//! anchor is a column: a second notice stacks above the first instead of
|
||||
//! landing on it.
|
||||
|
||||
use gpui::{AnyElement, App, Div, Hsla, div, prelude::*};
|
||||
use gpui_component::{ActiveTheme as _, h_flex, v_flex};
|
||||
|
||||
/// Chrome for one floating notice. `accent` is the border, and is the only
|
||||
/// thing that says how bad this one is; the rest of the pill is the same
|
||||
/// whatever went wrong.
|
||||
pub(crate) fn pill(accent: Hsla, cx: &App) -> Div {
|
||||
let theme = cx.theme();
|
||||
h_flex()
|
||||
.occlude()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_3()
|
||||
.py_1p5()
|
||||
.rounded_lg()
|
||||
.bg(theme.popover)
|
||||
.border_1()
|
||||
.border_color(accent.opacity(0.4))
|
||||
.shadow_md()
|
||||
// Off the right panel's ramp on purpose: these float over the
|
||||
// terminal, not inside a panel, and are sized against the terminal's
|
||||
// own text.
|
||||
.text_xs()
|
||||
.text_color(theme.muted_foreground)
|
||||
}
|
||||
|
||||
/// Anchors whatever notices are up as one bottom-centred column, so two of
|
||||
/// them stack rather than collide. `None` when there is nothing to show, which
|
||||
/// is what lets the caller keep using `when_some`.
|
||||
pub(crate) fn anchor(items: Vec<AnyElement>) -> Option<AnyElement> {
|
||||
if items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_4()
|
||||
.child(v_flex().w_full().items_center().gap_2().children(items))
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nothing_up_means_no_anchor() {
|
||||
assert!(anchor(Vec::new()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_notice_still_gets_the_anchor() {
|
||||
assert!(anchor(vec![div().into_any_element()]).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_notices_share_one_anchor() {
|
||||
// The bug this module exists for: two live notices must come back as a
|
||||
// single stacked element, not as two things each claiming `bottom_4`.
|
||||
let stacked = anchor(vec![div().into_any_element(), div().into_any_element()]);
|
||||
assert!(stacked.is_some());
|
||||
}
|
||||
}
|
||||
@@ -601,6 +601,11 @@ fn settings_search_entries() -> &'static [SearchEntry] {
|
||||
title: SettingsAgentCodex,
|
||||
keywords: SettingsSearchCodexKeywords,
|
||||
},
|
||||
SearchEntry {
|
||||
section: Agents,
|
||||
title: SettingsAgentTraeCode,
|
||||
keywords: SettingsSearchTraeCodeKeywords,
|
||||
},
|
||||
SearchEntry {
|
||||
section: Agents,
|
||||
title: SettingsAgentCopilotCli,
|
||||
|
||||
+101
-21
@@ -394,7 +394,10 @@ impl Tty7App {
|
||||
self.sftp_panel.open_pane_id = None;
|
||||
self.sftp_panel.entries.clear();
|
||||
self.sftp_panel.error = None;
|
||||
self.sftp_close_edit();
|
||||
// No `Window` here, and none needed: the browser itself is going away
|
||||
// or being re-pointed at another pane, so focus is settled by whoever
|
||||
// did that, not by the form.
|
||||
let _ = self.sftp_close_edit();
|
||||
self.sftp_panel.editing_path = None;
|
||||
self.sftp_panel.editing_path_sub.clear();
|
||||
self.sftp_panel.jobs.clear();
|
||||
@@ -431,7 +434,10 @@ impl Tty7App {
|
||||
self.sftp_panel.open_workspace = self.pane_workspace(pane_id, window, cx);
|
||||
self.sftp_panel.entries.clear();
|
||||
self.sftp_panel.error = None;
|
||||
self.sftp_close_edit();
|
||||
// No `Window` here, and none needed: the browser itself is going away
|
||||
// or being re-pointed at another pane, so focus is settled by whoever
|
||||
// did that, not by the form.
|
||||
let _ = self.sftp_close_edit();
|
||||
self.sftp_panel.editing_path = None;
|
||||
self.sftp_panel.editing_path_sub.clear();
|
||||
self.sftp_panel.show_history = false;
|
||||
@@ -705,7 +711,7 @@ impl Tty7App {
|
||||
);
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let Ok(0) = answer.await else { return };
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
let _ = this.update_in(cx, |this, window, cx| {
|
||||
if this.sftp_panel.open_pane_id != Some(pane_id) {
|
||||
return;
|
||||
}
|
||||
@@ -713,7 +719,7 @@ impl Tty7App {
|
||||
true => SftpOp::RemoveDir { path },
|
||||
false => SftpOp::RemoveFile { path },
|
||||
};
|
||||
this.sftp_run_op(pane_id, op, cx);
|
||||
this.sftp_run_op(pane_id, op, window, cx);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
@@ -759,11 +765,19 @@ impl Tty7App {
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn sftp_run_op(&mut self, pane_id: u64, op: SftpOp, cx: &mut Context<Self>) {
|
||||
/// Takes a `Window` only so the success arm can hand the focus back: the
|
||||
/// form is still up, still holding the caret, while the far side works.
|
||||
fn sftp_run_op(
|
||||
&mut self,
|
||||
pane_id: u64,
|
||||
op: SftpOp,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let route = self.sftp_route();
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = cx.background_spawn(async move { route.op(op) }).await;
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
let _ = this.update_in(cx, |this, window, cx| {
|
||||
if this.sftp_panel.open_pane_id != Some(pane_id) {
|
||||
return;
|
||||
}
|
||||
@@ -773,7 +787,7 @@ impl Tty7App {
|
||||
cx.notify();
|
||||
}
|
||||
_ => {
|
||||
this.sftp_close_edit();
|
||||
this.sftp_close_edit_in(window, cx);
|
||||
this.sftp_refresh(cx);
|
||||
}
|
||||
}
|
||||
@@ -798,8 +812,8 @@ impl Tty7App {
|
||||
let sub = cx.subscribe_in(
|
||||
&input,
|
||||
window,
|
||||
|this, _input, ev: &InputEvent, _window, cx| match ev {
|
||||
InputEvent::PressEnter { .. } => this.sftp_commit_edit(cx),
|
||||
|this, _input, ev: &InputEvent, window, cx| match ev {
|
||||
InputEvent::PressEnter { .. } => this.sftp_commit_edit(window, cx),
|
||||
// OK is disabled while the box is empty, so the form has to
|
||||
// redraw as the name is typed.
|
||||
InputEvent::Change => cx.notify(),
|
||||
@@ -862,17 +876,36 @@ impl Tty7App {
|
||||
/// Takes the form down and drops the subscription that was listening to
|
||||
/// its box. The two travel together — a live subscription on a box nothing
|
||||
/// is showing would answer Return for a form that is gone.
|
||||
fn sftp_close_edit(&mut self) {
|
||||
///
|
||||
/// Reports whether a form was actually up, because the box owned the focus
|
||||
/// and whoever tore it down has to hand the focus back. It did not, so
|
||||
/// naming a folder and then pressing Escape left the focus on an element
|
||||
/// that no longer existed and the next keystroke went nowhere until you
|
||||
/// clicked. `ssh_prompt` asserts in a comment *and* a test that every
|
||||
/// overlay in the app hands focus back on the way out; these four forms
|
||||
/// were the counterexample.
|
||||
#[must_use]
|
||||
fn sftp_close_edit(&mut self) -> bool {
|
||||
let was_open = self.sftp_panel.editing.is_some();
|
||||
self.sftp_panel.editing = None;
|
||||
self.sftp_panel.editing_sub.clear();
|
||||
was_open
|
||||
}
|
||||
|
||||
pub(crate) fn sftp_cancel_edit(&mut self, cx: &mut Context<Self>) {
|
||||
self.sftp_close_edit();
|
||||
/// `sftp_close_edit` plus the focus hand-back, for the callers that have a
|
||||
/// `Window` to hand it back with.
|
||||
fn sftp_close_edit_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.sftp_close_edit() {
|
||||
self.focus_active(window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sftp_cancel_edit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.sftp_close_edit_in(window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn sftp_commit_edit(&mut self, cx: &mut Context<Self>) {
|
||||
pub(crate) fn sftp_commit_edit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(pane_id) = self.sftp_panel.open_pane_id else {
|
||||
return;
|
||||
};
|
||||
@@ -898,7 +931,7 @@ impl Tty7App {
|
||||
Some(SftpEdit::Rename { original, input }) => {
|
||||
let name = input.read(cx).value().trim().to_string();
|
||||
if name.is_empty() || name == *original {
|
||||
self.sftp_close_edit();
|
||||
self.sftp_close_edit_in(window, cx);
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
@@ -924,7 +957,7 @@ impl Tty7App {
|
||||
None => None,
|
||||
};
|
||||
if let Some(op) = op {
|
||||
self.sftp_run_op(pane_id, op, cx);
|
||||
self.sftp_run_op(pane_id, op, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1386,9 +1419,9 @@ impl Tty7App {
|
||||
.rounded_md()
|
||||
// Escape backs out of the form, the way it backs out of the
|
||||
// path editor above it and every sheet the app puts up.
|
||||
.on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, _window, cx| {
|
||||
.on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| {
|
||||
if ev.keystroke.key == "escape" {
|
||||
this.sftp_cancel_edit(cx);
|
||||
this.sftp_cancel_edit(window, cx);
|
||||
}
|
||||
}))
|
||||
.child(
|
||||
@@ -1408,7 +1441,9 @@ impl Tty7App {
|
||||
.label(t(L10nKey::Cancel))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(|this, _, _w, cx| this.sftp_cancel_edit(cx))),
|
||||
.on_click(
|
||||
cx.listener(|this, _, w, cx| this.sftp_cancel_edit(w, cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("sftp-edit-ok")
|
||||
@@ -1416,7 +1451,9 @@ impl Tty7App {
|
||||
.xsmall()
|
||||
.primary()
|
||||
.disabled(!can_commit)
|
||||
.on_click(cx.listener(|this, _, _w, cx| this.sftp_commit_edit(cx))),
|
||||
.on_click(
|
||||
cx.listener(|this, _, w, cx| this.sftp_commit_edit(w, cx)),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -2198,10 +2235,12 @@ mod tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod gpui_tests {
|
||||
use super::SftpEdit;
|
||||
use crate::core::config::{Config, RightPanelTab};
|
||||
use crate::core::session::Session;
|
||||
use crate::ui::app::Tty7App;
|
||||
use gpui::{AppContext, Entity, TestAppContext, VisualTestContext};
|
||||
use gpui::{AppContext, Entity, Focusable as _, TestAppContext, VisualTestContext};
|
||||
use gpui_component::input::InputState;
|
||||
|
||||
fn harness(cx: &mut TestAppContext) -> (Entity<Tty7App>, VisualTestContext) {
|
||||
cx.executor().allow_parking();
|
||||
@@ -2235,6 +2274,47 @@ mod gpui_tests {
|
||||
})
|
||||
}
|
||||
|
||||
/// The edit box owns the focus while the form is up, so taking the form
|
||||
/// down has to hand the focus back.
|
||||
///
|
||||
/// It did not. Naming a new folder and then pressing Escape left the caret
|
||||
/// on an element that had stopped rendering, and the next keystroke went
|
||||
/// nowhere until you clicked. `ssh_prompt` asserts in a comment *and* a
|
||||
/// test that every overlay in the app hands focus back on the way out;
|
||||
/// these four forms were the counterexample, and `sftp_cancel_edit` could
|
||||
/// not have done it anyway — it took no `Window` at all.
|
||||
#[gpui::test]
|
||||
fn cancelling_the_edit_form_hands_focus_back(cx: &mut TestAppContext) {
|
||||
let (app, mut vcx) = harness(cx);
|
||||
|
||||
let box_focus = app.update_in(&mut vcx, |app, window, cx| {
|
||||
let input = cx.new(|cx| InputState::new(window, cx));
|
||||
input.update(cx, |s, cx| s.focus(window, cx));
|
||||
let handle = input.read(cx).focus_handle(cx);
|
||||
app.sftp_panel.editing = Some(SftpEdit::NewFolder(input));
|
||||
handle
|
||||
});
|
||||
vcx.run_until_parked();
|
||||
|
||||
// Sanity: the box holds focus while the form is up.
|
||||
assert!(
|
||||
app.update_in(&mut vcx, |_, window, _| box_focus.is_focused(window)),
|
||||
"the box should hold focus while the form is up"
|
||||
);
|
||||
|
||||
app.update_in(&mut vcx, |app, window, cx| app.sftp_cancel_edit(window, cx));
|
||||
vcx.run_until_parked();
|
||||
|
||||
assert!(
|
||||
app.update_in(&mut vcx, |app, _, _| app.sftp_panel.editing.is_none()),
|
||||
"the form is down"
|
||||
);
|
||||
assert!(
|
||||
!app.update_in(&mut vcx, |_, window, _| box_focus.is_focused(window)),
|
||||
"the focus the box held must have gone somewhere still on screen"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn toggle_sftp_opens_files_then_closes_the_panel(cx: &mut TestAppContext) {
|
||||
let (app, mut vcx) = harness(cx);
|
||||
|
||||
@@ -968,10 +968,19 @@ impl Tty7App {
|
||||
// Abort stays the emphasized one and now also sits
|
||||
// where the eye lands last: a changed host key is the
|
||||
// one prompt where the safe answer wants both.
|
||||
//
|
||||
// Override is the app's one `danger` button, and it is
|
||||
// the site that earns it: the sheet is what a
|
||||
// man-in-the-middle looks like, and this was the only
|
||||
// control in the product that could act on that with
|
||||
// no colour on it at all. It is disabled until the word
|
||||
// is typed, so it greys until armed and then goes red —
|
||||
// the emphasis arrives exactly when the button does.
|
||||
.child(
|
||||
Button::new("ssh-hkc-override")
|
||||
.label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Override))
|
||||
.small()
|
||||
.danger()
|
||||
.disabled(!can_override)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.submit_ssh_prompt(window, cx)
|
||||
|
||||
+178
-6
@@ -313,6 +313,11 @@ pub(crate) struct Switcher {
|
||||
/// The modifiers held down when Ctrl+Tab opened the panel. Releasing them
|
||||
/// commits the highlighted tab, IDEA-style.
|
||||
hold: Option<gpui::Modifiers>,
|
||||
/// Where the pointer is: inside the card at all, and inside the tab column
|
||||
/// specifically. Both are set by hover listeners, so they only mean
|
||||
/// anything once the mouse has moved since the panel came up.
|
||||
hover_card: bool,
|
||||
hover_tabs: bool,
|
||||
left_scroll: gpui::ScrollHandle,
|
||||
right_scroll: gpui::ScrollHandle,
|
||||
/// Anchors on the two scrolls, worn by whichever row is selected. Both
|
||||
@@ -329,6 +334,14 @@ impl Switcher {
|
||||
fn text(&self, cx: &App) -> String {
|
||||
self.query.read(cx).value().trim().to_lowercase()
|
||||
}
|
||||
|
||||
/// The pointer is parked in the card but off the tab column — on a
|
||||
/// workspace row, the search box, a banner. Letting go of Ctrl there is
|
||||
/// not a commit: the user is reaching for the mouse, and closing the panel
|
||||
/// out from under them makes the workspace list unreachable by hand.
|
||||
fn hover_keeps_open(&self) -> bool {
|
||||
self.hover_card && !self.hover_tabs
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the panel needs for one frame: the groups (one per machine,
|
||||
@@ -437,9 +450,17 @@ impl Tty7App {
|
||||
remote_connect::register(cx);
|
||||
remote_connect::sweep_wsl(cx);
|
||||
let query = cx.new(|cx| {
|
||||
InputState::new(window, cx).placeholder(crate::ui::i18n::t(
|
||||
crate::ui::i18n::L10nKey::SearchWorkspacesAndMachines,
|
||||
))
|
||||
InputState::new(window, cx)
|
||||
.placeholder(crate::ui::i18n::t(
|
||||
crate::ui::i18n::L10nKey::SearchWorkspacesAndMachines,
|
||||
))
|
||||
// On macOS a held Ctrl turns every click into a right click,
|
||||
// and the input answers a right click with Cut/Copy/Paste —
|
||||
// so reaching for this box mid-Ctrl+Tab popped a menu instead
|
||||
// of placing a caret. The rows already dodge this by dropping
|
||||
// their own menus while the gesture is on; this box has no
|
||||
// menu worth keeping either, and Cmd+V still pastes.
|
||||
.context_menu(false)
|
||||
});
|
||||
query.update(cx, |state, cx| state.focus(window, cx));
|
||||
let subs = vec![cx.subscribe_in(
|
||||
@@ -469,6 +490,8 @@ impl Tty7App {
|
||||
right_sel: 0,
|
||||
mru,
|
||||
hold,
|
||||
hover_card: false,
|
||||
hover_tabs: false,
|
||||
left_scroll: left_scroll.clone(),
|
||||
right_scroll: right_scroll.clone(),
|
||||
left_anchor: gpui::ScrollAnchor::for_handle(left_scroll),
|
||||
@@ -601,9 +624,21 @@ impl Tty7App {
|
||||
let Some(hold) = self.switcher.as_ref().and_then(|sw| sw.hold) else {
|
||||
return;
|
||||
};
|
||||
if !now.modified() || !hold.is_subset_of(now) {
|
||||
self.switcher_commit_hold(window, cx);
|
||||
if now.modified() && hold.is_subset_of(now) {
|
||||
return;
|
||||
}
|
||||
// The pointer is already on the workspace list or the search box, so
|
||||
// the release is the user's hand leaving the keyboard, not a pick.
|
||||
// Drop the hold and leave the panel up for the mouse to finish in.
|
||||
if self
|
||||
.switcher
|
||||
.as_ref()
|
||||
.is_some_and(Switcher::hover_keeps_open)
|
||||
{
|
||||
self.switcher_release_hold(cx);
|
||||
return;
|
||||
}
|
||||
self.switcher_commit_hold(window, cx);
|
||||
}
|
||||
|
||||
/// Called when the modifier that raised the panel comes back up.
|
||||
@@ -1655,7 +1690,17 @@ impl Tty7App {
|
||||
this.close_switcher(window, cx)
|
||||
}),
|
||||
)
|
||||
.child(div().occlude().child(card))
|
||||
.child(
|
||||
div()
|
||||
.id("switcher-card")
|
||||
.occlude()
|
||||
.on_hover(cx.listener(|this, hovered: &bool, _window, _cx| {
|
||||
if let Some(sw) = this.switcher.as_mut() {
|
||||
sw.hover_card = *hovered;
|
||||
}
|
||||
}))
|
||||
.child(card),
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
@@ -1735,8 +1780,14 @@ impl Tty7App {
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.id("switcher-tab-column")
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.on_hover(cx.listener(|this, hovered: &bool, _window, _cx| {
|
||||
if let Some(sw) = this.switcher.as_mut() {
|
||||
sw.hover_tabs = *hovered;
|
||||
}
|
||||
}))
|
||||
.child(crate::ui::scrollbar::with_vertical_scrollbar(
|
||||
"switcher-tabs-scrollbar",
|
||||
div()
|
||||
@@ -4046,6 +4097,127 @@ mod gpui_tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// One of the three places on the card a test wants to put the pointer.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Spot {
|
||||
Workspaces,
|
||||
Tabs,
|
||||
Search,
|
||||
}
|
||||
|
||||
/// Where that part of the card lands on screen. The card is centred and
|
||||
/// its columns are laid out from `CARD_W` / `LEFT_W`, so the geometry is
|
||||
/// worth recomputing here rather than hard-coding pixels that move with
|
||||
/// the window size.
|
||||
fn card_point(vcx: &mut gpui::VisualTestContext, spot: Spot) -> gpui::Point<gpui::Pixels> {
|
||||
use gpui::{point, px};
|
||||
|
||||
let viewport = vcx.update(|window, _| window.viewport_size());
|
||||
let card_w = super::CARD_W
|
||||
.min(viewport.width.as_f32() - 2. * super::CARD_MARGIN)
|
||||
.max(320.);
|
||||
let left_w = super::LEFT_W.min(card_w * 0.5);
|
||||
let card_left = (viewport.width.as_f32() - card_w) / 2.;
|
||||
let (dx, dy) = match spot {
|
||||
// The search row is the first thing in the card; both columns
|
||||
// start below it.
|
||||
Spot::Search => (100., 20.),
|
||||
Spot::Workspaces => (20., 60.),
|
||||
// Past the tab column's own header row, onto its first tab.
|
||||
Spot::Tabs => (left_w + 40., 42. + 6. + super::HOST_H + super::ROW_H / 2.),
|
||||
};
|
||||
point(px(card_left + dx), px(super::CARD_TOP + dy))
|
||||
}
|
||||
|
||||
/// Ctrl+Tab, then reach for the mouse: the pointer leaves the tab column
|
||||
/// for the workspace list, and letting go of Ctrl there must not slam the
|
||||
/// panel shut — switching workspaces by hand is exactly what the user is
|
||||
/// in the middle of doing.
|
||||
#[gpui::test]
|
||||
fn releasing_ctrl_over_the_workspace_list_keeps_the_panel_up(cx: &mut TestAppContext) {
|
||||
let (app, mut vcx, _streams) = harness_with_tabs(cx, 3);
|
||||
vcx.simulate_modifiers_change(Modifiers::control());
|
||||
app.update_in(&mut vcx, |app, window, cx| app.tab_switch(true, window, cx));
|
||||
vcx.run_until_parked();
|
||||
|
||||
let at = card_point(&mut vcx, Spot::Workspaces);
|
||||
vcx.simulate_mouse_move(at, None, Modifiers::control());
|
||||
vcx.simulate_modifiers_change(Modifiers::none());
|
||||
|
||||
app.update(cx, |app, _| {
|
||||
let sw = app
|
||||
.switcher
|
||||
.as_ref()
|
||||
.expect("the panel stays up for the mouse to finish in");
|
||||
assert!(sw.hold.is_none(), "the hold is spent, not re-armed");
|
||||
assert_eq!(app.active, 0, "the release picked nothing");
|
||||
});
|
||||
}
|
||||
|
||||
/// The pointer over the tab column is the ordinary gesture: release still
|
||||
/// commits.
|
||||
#[gpui::test]
|
||||
fn releasing_ctrl_over_the_tab_column_still_commits(cx: &mut TestAppContext) {
|
||||
let (app, mut vcx, _streams) = harness_with_tabs(cx, 3);
|
||||
vcx.simulate_modifiers_change(Modifiers::control());
|
||||
app.update_in(&mut vcx, |app, window, cx| app.tab_switch(true, window, cx));
|
||||
vcx.run_until_parked();
|
||||
|
||||
let at = card_point(&mut vcx, Spot::Tabs);
|
||||
vcx.simulate_mouse_move(at, None, Modifiers::control());
|
||||
vcx.simulate_modifiers_change(Modifiers::none());
|
||||
|
||||
app.update(cx, |app, _| {
|
||||
assert!(app.switcher.is_none(), "the panel comes down on release");
|
||||
assert_eq!(app.active, 1, "the highlighted tab is now the active one");
|
||||
});
|
||||
}
|
||||
|
||||
/// macOS reports Ctrl+click as a right click, so a tab row picked with
|
||||
/// the mouse mid-gesture arrives on the right button. The row takes that
|
||||
/// press as the pick; nothing between it and the window may swallow it
|
||||
/// first.
|
||||
#[gpui::test]
|
||||
fn ctrl_clicking_a_tab_row_mid_gesture_picks_it(cx: &mut TestAppContext) {
|
||||
let (app, mut vcx, _streams) = harness_with_tabs(cx, 3);
|
||||
vcx.simulate_modifiers_change(Modifiers::control());
|
||||
app.update_in(&mut vcx, |app, window, cx| {
|
||||
app.tab_switch(true, window, cx);
|
||||
// Two steps down, so the row the pointer lands on below is not
|
||||
// the one the keyboard had already reached.
|
||||
app.tab_switch(true, window, cx);
|
||||
});
|
||||
vcx.run_until_parked();
|
||||
app.update(cx, |app, _| {
|
||||
assert_eq!(app.switcher.as_ref().expect("up").right_sel, 2);
|
||||
});
|
||||
|
||||
let at = card_point(&mut vcx, Spot::Tabs);
|
||||
vcx.simulate_mouse_move(at, None, Modifiers::control());
|
||||
vcx.simulate_mouse_down(at, gpui::MouseButton::Right, Modifiers::control());
|
||||
|
||||
app.update(cx, |app, _| {
|
||||
let sw = app.switcher.as_ref().expect("the panel stays up");
|
||||
assert_eq!(
|
||||
sw.right_sel, 0,
|
||||
"the row under the pointer took the press, not the keyboard's row 2"
|
||||
);
|
||||
assert!(
|
||||
sw.hold.is_some(),
|
||||
"the gesture is still on until Ctrl is up"
|
||||
);
|
||||
});
|
||||
|
||||
vcx.simulate_modifiers_change(Modifiers::none());
|
||||
app.update(cx, |app, _| {
|
||||
assert!(app.switcher.is_none(), "release commits and closes");
|
||||
assert_eq!(
|
||||
app.active, 0,
|
||||
"the first row of a most-recently-used column is this very tab"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn losing_focus_drops_the_hold_so_the_panel_cannot_hang(cx: &mut TestAppContext) {
|
||||
let (app, mut vcx, _streams) = harness_with_tabs(cx, 3);
|
||||
|
||||
+19
-28
@@ -307,19 +307,16 @@ impl Tty7App {
|
||||
// rectangle for them, which is what keeps a pane from being
|
||||
// dropped into a group that is shut.
|
||||
//
|
||||
// The active tab is the one exception: a fold says "I am done
|
||||
// with this repo for now", never "hide the tab I am looking at".
|
||||
// Without it ⌘T inside a folded group — `spawn_group` seeds the
|
||||
// new tab with the group it came from — draws nothing but a
|
||||
// header count going up by one, and with the tab bar docked left
|
||||
// that row is the tab's only representation on screen.
|
||||
// No exception for the active tab. A fold that leaves one row
|
||||
// hanging under a shut chevron, with the header counting rows
|
||||
// that are not there, reads as a list that failed to load. The
|
||||
// cost is that ⌘T inside a folded group — `spawn_group` seeds
|
||||
// the new tab with the group it came from — puts the new tab
|
||||
// behind the chevron: the pane area shows the fresh shell and the
|
||||
// header count goes up, but the row waits for the group to open.
|
||||
let row_count = visible_by_section[group_ix].len();
|
||||
let visible: Vec<usize> = match folded {
|
||||
true => visible_by_section[group_ix]
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&i| i == active)
|
||||
.collect(),
|
||||
true => Vec::new(),
|
||||
false => visible_by_section[group_ix].clone(),
|
||||
};
|
||||
let visible_tabs: Vec<usize> = visible.clone();
|
||||
@@ -2240,8 +2237,6 @@ mod fold_tests {
|
||||
for (i, root) in [(0, &alpha), (1, &alpha), (2, &beta)] {
|
||||
*app.tabs[i].sidebar_group.borrow_mut() = Some(root.clone());
|
||||
}
|
||||
// Active in the group that stays open: the folded group's own
|
||||
// active row has its own test below, and it would mask this one.
|
||||
app.active = 2;
|
||||
cx.notify();
|
||||
});
|
||||
@@ -2298,10 +2293,8 @@ mod fold_tests {
|
||||
app.toggle_sidebar_group(Some(&alpha), cx);
|
||||
});
|
||||
vcx.run_until_parked();
|
||||
// Row 1, not row 0: row 0 is the active tab and a fold never takes
|
||||
// that one off the screen, so it says nothing about the fold.
|
||||
app.update(&mut vcx, |app, _| {
|
||||
assert!(!drawn(app, 1), "folded, so the inactive row is not drawn");
|
||||
assert!(!drawn(app, 1), "folded, so the row is not drawn");
|
||||
});
|
||||
|
||||
// Whatever the row is actually showing — the label is derived from the
|
||||
@@ -2322,11 +2315,13 @@ mod fold_tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// A fold means "I am done with this repo for now", never "hide the tab I
|
||||
/// am looking at". Without this, ⌘T inside a folded group — the new tab
|
||||
/// inherits the group it was spawned from — draws nothing at all.
|
||||
/// A fold hides every row the group has, the active one included. The
|
||||
/// alternative — leaving the active row on screen under a shut chevron,
|
||||
/// with the header counting rows that are not drawn — looks like a list
|
||||
/// that failed to load, which is what folding a group you are working in
|
||||
/// used to produce.
|
||||
#[gpui::test]
|
||||
fn the_active_row_stays_on_screen_inside_a_folded_group(cx: &mut TestAppContext) {
|
||||
fn a_fold_hides_the_active_row_too(cx: &mut TestAppContext) {
|
||||
let (app, mut vcx, _streams) = harness_with_tabs(cx, 2);
|
||||
let alpha = GroupKey::Repo(PathBuf::from("/w/alpha"));
|
||||
|
||||
@@ -2340,21 +2335,17 @@ mod fold_tests {
|
||||
vcx.run_until_parked();
|
||||
|
||||
app.update(&mut vcx, |app, _| {
|
||||
assert!(drawn(app, 0), "the active row survives its group folding");
|
||||
assert!(!drawn(app, 1), "everything else in the group is gone");
|
||||
assert!(!drawn(app, 0), "the active row folds away with the rest");
|
||||
assert!(!drawn(app, 1), "and so does everything else in the group");
|
||||
});
|
||||
|
||||
// And it follows the active tab, rather than being decided once when
|
||||
// the fold happened.
|
||||
app.update(&mut vcx, |app, cx| {
|
||||
app.active = 1;
|
||||
cx.notify();
|
||||
app.toggle_sidebar_group(Some(&alpha), cx)
|
||||
});
|
||||
vcx.run_until_parked();
|
||||
|
||||
app.update(&mut vcx, |app, _| {
|
||||
assert!(drawn(app, 1), "the row that is active now is the one drawn");
|
||||
assert!(!drawn(app, 0), "and the one that no longer is went away");
|
||||
assert!((0..2).all(|i| drawn(app, i)), "unfolding brings both back");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -1302,7 +1302,14 @@ impl Tty7App {
|
||||
gpui::svg()
|
||||
.path(agent.icon_path())
|
||||
.size(px(size * 0.54))
|
||||
.text_color(gpui::white()),
|
||||
// SVG assets are rendered as a single-colour mask.
|
||||
// TraeCode's black field comes from the avatar, and
|
||||
// its brand mark uses the official green.
|
||||
.text_color(if agent == crate::core::cli_agent::CLIAgent::TraeCode {
|
||||
gpui::rgb(0x32F08C)
|
||||
} else {
|
||||
gpui::rgb(0xFFFFFF)
|
||||
}),
|
||||
)
|
||||
.when_some(dot, |b, dot| b.child(dot))
|
||||
.tooltip(move |window, cx| {
|
||||
|
||||
Reference in New Issue
Block a user