mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
Refs #485. Also stops PaneWorkspace::route_header spelling a profile UUID on the restore path.
4162 lines
158 KiB
Rust
4162 lines
158 KiB
Rust
use std::collections::{HashMap, HashSet};
|
||
|
||
use gpui::{
|
||
AnyElement, App, ClickEvent, Context, Entity, MouseButton, MouseDownEvent, Subscription,
|
||
Window, div, prelude::*, px,
|
||
};
|
||
use gpui_component::button::{Button, ButtonVariants as _};
|
||
use gpui_component::input::{Input, InputEvent, InputState};
|
||
use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenuItem};
|
||
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex};
|
||
|
||
use tty7_core::core::machine::TabId;
|
||
use tty7_core::core::session::{RemoteTarget, RouteSnapshot, WorkspaceId};
|
||
|
||
use crate::core::actions::{SwitcherAcross, SwitcherAcrossBack};
|
||
use crate::core::session::WorkspaceStore;
|
||
use crate::daemon::install::InstallPhase;
|
||
use crate::terminal::pane_liveness::Liveness;
|
||
use crate::ui::app::Tty7App;
|
||
use crate::ui::i18n::{L10nKey, t, t_fmt};
|
||
use crate::ui::remote_connect::{self, HostChoice, RemoteWorkspaceRow};
|
||
use crate::ui::remote_workspace::{ConnectFlow, MachineStatus, RemoteLinks};
|
||
|
||
const CARD_W: f32 = 840.0;
|
||
|
||
/// The create form's card. Narrower than the list — it is a form, not a
|
||
/// browser.
|
||
const FORM_W: f32 = 480.0;
|
||
|
||
/// The host dropdown shows about eight rows before it scrolls.
|
||
const FORM_LIST_H: f32 = 8.5 * (ROW_H + 8.0);
|
||
|
||
const LEFT_W: f32 = 340.0;
|
||
|
||
pub(crate) const CARD_TOP: f32 = 120.0;
|
||
|
||
/// Breathing room the card keeps from the window edge, and the height its own
|
||
/// search row and footer take on top of the body.
|
||
const CARD_MARGIN: f32 = 24.0;
|
||
const CARD_CHROME_H: f32 = 84.0;
|
||
|
||
const BODY_H: f32 = 420.0;
|
||
|
||
const ROW_AVATAR: f32 = 20.0;
|
||
|
||
const ROW_H: f32 = 32.0;
|
||
const HOST_H: f32 = 34.0;
|
||
|
||
const GUTTER: f32 = 26.0;
|
||
|
||
const ICON: f32 = 16.0;
|
||
|
||
const ROW_PAD: f32 = 8.0;
|
||
|
||
/// `Failed` stays a unit variant so `Link` can be `Copy` and travel by value in
|
||
/// `GroupRef`; what went wrong rides in `Group::error` instead.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum Link {
|
||
Local,
|
||
Connected,
|
||
Connecting,
|
||
/// The supervisor is retrying this machine on its backoff. Distinct from
|
||
/// `Offline` because the group still has rows worth showing and Disconnect
|
||
/// still has something to call off.
|
||
Reconnecting {
|
||
attempt: u32,
|
||
},
|
||
Failed,
|
||
Offline,
|
||
}
|
||
|
||
/// Whether Disconnect has anything to stop. A machine the supervisor is still
|
||
/// retrying has no live link, but calling the retry off is exactly what the
|
||
/// verb is for, so it has to stay enabled.
|
||
fn link_is_engaged(link: Link) -> bool {
|
||
matches!(link, Link::Connected | Link::Reconnecting { .. })
|
||
}
|
||
|
||
/// How one machine's row is drawn, from the two things that know.
|
||
///
|
||
/// This window's own `connect` still comes first — it is the attempt the user
|
||
/// is watching, and it is the only one that can be Failed with a reason on
|
||
/// screen. Everything after it is the supervisor's, which is the only view that
|
||
/// survives the window that started the connect. Reading the `HostLinks` table
|
||
/// alone, as this used to, cannot tell a machine that is being retried right
|
||
/// now from one nobody has ever connected to: the pump drops the entry the
|
||
/// moment the link dies, and the group then collapses as if it were empty.
|
||
fn link_from(
|
||
connect: Option<&ConnectFlow>,
|
||
target: &RemoteTarget,
|
||
supervised: Option<&MachineStatus>,
|
||
has_link: bool,
|
||
) -> Link {
|
||
match connect {
|
||
Some(ConnectFlow::Connecting { choice }) if &choice.target == target => {
|
||
return Link::Connecting;
|
||
}
|
||
Some(ConnectFlow::Failed { choice, .. }) if &choice.target == target => {
|
||
return Link::Failed;
|
||
}
|
||
_ => {}
|
||
}
|
||
match supervised {
|
||
Some(MachineStatus::Connecting) => Link::Connecting,
|
||
Some(MachineStatus::Attached) => Link::Connected,
|
||
Some(MachineStatus::Reconnecting { attempt, .. }) => {
|
||
Link::Reconnecting { attempt: *attempt }
|
||
}
|
||
Some(MachineStatus::Failed(_)) => Link::Failed,
|
||
None if has_link => Link::Connected,
|
||
None => Link::Offline,
|
||
}
|
||
}
|
||
|
||
struct Group {
|
||
key: String,
|
||
label: String,
|
||
endpoint: String,
|
||
target: Option<RemoteTarget>,
|
||
link: Link,
|
||
error: Option<String>,
|
||
installing: Option<InstallPhase>,
|
||
/// Another client is holding at least one workspace of this machine. The
|
||
/// link itself is fine, so nothing in `link` would ever say so.
|
||
preempted: bool,
|
||
/// The route behind this group no longer resolves — its profile was
|
||
/// deleted or its alias left the ssh config (#485). Parked groups are
|
||
/// not retried; the group shows the snapshot label and offers to forget
|
||
/// its entries instead of a retry button that could never succeed.
|
||
parked: bool,
|
||
rows: Vec<Row>,
|
||
}
|
||
|
||
struct Row {
|
||
id: WorkspaceId,
|
||
name: String,
|
||
path: String,
|
||
when: String,
|
||
/// Raw timestamp behind `when` — what the flat list sorts by.
|
||
last_active: u64,
|
||
live: Liveness,
|
||
open: bool,
|
||
current: bool,
|
||
preempted: bool,
|
||
adopt: Option<Box<RemoteWorkspaceRow>>,
|
||
remote_id: Option<WorkspaceId>,
|
||
tabs: Vec<TabRow>,
|
||
}
|
||
|
||
/// One tab in the right-hand column. Built once per frame for every workspace
|
||
/// on the left, so the search can match tab names and the column can render
|
||
/// without a second pass over the machine tree.
|
||
#[derive(Clone)]
|
||
struct TabRow {
|
||
id: TabId,
|
||
/// Position in the owning workspace's tab order — what `activate` wants.
|
||
index: usize,
|
||
label: String,
|
||
path: String,
|
||
/// Whether `label` is a name someone gave the tab. When it is not, the
|
||
/// label is already derived from the working directory and showing `path`
|
||
/// next to it just prints the same place twice.
|
||
named: bool,
|
||
agent: Option<crate::core::cli_agent::CLIAgent>,
|
||
status: Option<crate::core::cli_agent::AgentStatus>,
|
||
unread: usize,
|
||
ssh: Option<u32>,
|
||
active: bool,
|
||
/// Branch and diff counts, the same line the tab sidebar shows. Only this
|
||
/// window's own tabs have it — the machine tree carries no git state.
|
||
git: Option<tty7_core::core::git::GitStatus>,
|
||
}
|
||
|
||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||
pub(crate) enum Column {
|
||
Left,
|
||
Right,
|
||
}
|
||
|
||
/// A selectable line in the left column: `(group, row)` into `Layout::groups`.
|
||
/// The list is flat — one workspace per line, machines told apart by the badge
|
||
/// on the row itself — and rendering and keyboard navigation walk the same
|
||
/// list so an arrow key can never land somewhere the eye cannot see.
|
||
type Nav = (usize, usize);
|
||
|
||
pub(crate) struct HostSnapshot {
|
||
pub target: RemoteTarget,
|
||
pub rows: Vec<RemoteWorkspaceRow>,
|
||
}
|
||
|
||
/// A pane the daemon runs that no workspace holds — what an interrupted
|
||
/// `tty7 run` leaves behind, and what `tty7 pane ls --all` points the CLI's
|
||
/// reaper at. The switcher is where a GUI user finds and closes one (#596).
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub(crate) struct OrphanPane {
|
||
pub pane_id: u64,
|
||
pub title: String,
|
||
pub cwd: Option<String>,
|
||
pub owner: Option<String>,
|
||
}
|
||
|
||
/// Every pane id the local machine's workspaces hold — what the registry
|
||
/// listing is measured against to find the orphans (#596).
|
||
fn held_local_pane_ids(cx: &App) -> HashSet<u64> {
|
||
crate::ui::machine_mirror::MachineMirrors::machine(cx, crate::core::session::HostId::LOCAL)
|
||
.map(|machine| {
|
||
machine
|
||
.workspaces
|
||
.iter()
|
||
.flat_map(|ws| ws.tabs.iter())
|
||
.flat_map(|tab| tab.root.pane_ids())
|
||
.collect()
|
||
})
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// The registry's live panes minus the ones a workspace holds. Dead entries
|
||
/// drop out too: a corpse the daemon has not reaped yet is not something the
|
||
/// user can act on.
|
||
pub(crate) fn orphan_panes_of(
|
||
listed: Vec<tty7_core::daemon::protocol::PaneInfo>,
|
||
held: &HashSet<u64>,
|
||
) -> Vec<OrphanPane> {
|
||
listed
|
||
.into_iter()
|
||
.filter(|info| info.alive && !held.contains(&info.pane_id))
|
||
.map(|info| OrphanPane {
|
||
pane_id: info.pane_id,
|
||
title: info.title,
|
||
cwd: info.cwd.map(|p| p.display().to_string()),
|
||
owner: info.owner,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The name this machine shows for the workspace an orphan still claims —
|
||
/// `None` once that workspace is gone, which is the ordinary case for a pane
|
||
/// nobody holds.
|
||
fn workspace_name_of(owner: &str, cx: &App) -> Option<String> {
|
||
let id = owner.parse::<WorkspaceId>().ok()?;
|
||
let machine = crate::ui::machine_mirror::MachineMirrors::machine(
|
||
cx,
|
||
crate::core::session::HostId::LOCAL,
|
||
)?;
|
||
let ws = machine.workspaces.iter().find(|ws| ws.id == id)?;
|
||
Some(crate::ui::machine_mirror::display_name_of(
|
||
ws,
|
||
&machine.panes,
|
||
))
|
||
}
|
||
|
||
/// What to print where an orphan names its owner. The owner it carries is a
|
||
/// `WorkspaceId` — 36 characters of UUID that say nothing on screen and, left
|
||
/// whole, push the row's Close button clean out of the card. So: name the
|
||
/// workspace when this machine still has one, and otherwise keep the first 8
|
||
/// characters, which is what `tty7 pane ls --all` prints and enough to line
|
||
/// the two listings up. An owner that is no workspace id at all is an older
|
||
/// client's own label (`tty7-cli`) and already reads fine.
|
||
fn owner_label(owner: &str, workspace_name: Option<String>) -> String {
|
||
workspace_name.unwrap_or_else(|| match owner.parse::<WorkspaceId>() {
|
||
Ok(_) => owner.chars().take(8).collect(),
|
||
Err(_) => owner.to_string(),
|
||
})
|
||
}
|
||
|
||
/// Which face the card is showing: the workspace list, or the create form.
|
||
pub(crate) enum Page {
|
||
List,
|
||
Create(CreateForm),
|
||
}
|
||
|
||
/// The "New Workspace" form: a name prefilled with what the workspace would
|
||
/// have called itself anyway, and a host picked from a combobox that folds
|
||
/// however many machines are configured into one row.
|
||
pub(crate) struct CreateForm {
|
||
name: Entity<InputState>,
|
||
/// The combobox's filter text. Only meaningful while `open`.
|
||
host: Entity<InputState>,
|
||
/// Whether the host dropdown is unfolded.
|
||
open: bool,
|
||
/// Cursor into the dropdown's item list.
|
||
sel: usize,
|
||
/// The picked host. `None` is this computer.
|
||
chosen: Option<HostChoice>,
|
||
/// What the name box was prefilled with. While its value still says this
|
||
/// (or nothing), picking another host refills it; one keystroke of the
|
||
/// user's own and it is theirs.
|
||
prefill: String,
|
||
}
|
||
|
||
/// A create the user asked for on a machine that was not connected yet: the
|
||
/// connect has to land first, because only a live link knows the home
|
||
/// directory a fresh workspace is rooted at. `finish_connect` consumes it.
|
||
pub(crate) struct PendingCreate {
|
||
pub target: RemoteTarget,
|
||
pub name: Option<String>,
|
||
}
|
||
|
||
pub(crate) struct Switcher {
|
||
pub query: Entity<InputState>,
|
||
page: Page,
|
||
renaming: Option<(WorkspaceId, Entity<InputState>)>,
|
||
/// Panes the local daemon runs that no workspace holds (#596). Filled
|
||
/// asynchronously after the panel opens; empty both while the listing is
|
||
/// in flight and when there is nothing to reap.
|
||
orphans: Vec<OrphanPane>,
|
||
column: Column,
|
||
left_sel: usize,
|
||
right_sel: usize,
|
||
/// Order the tab column most-recently-used first. Set when Ctrl+Tab opened
|
||
/// the panel; the plain Cmd+Shift+O panel keeps strip order.
|
||
mru: bool,
|
||
/// The modifiers held down when Ctrl+Tab opened the panel. Releasing them
|
||
/// commits the highlighted tab, IDEA-style.
|
||
hold: Option<gpui::Modifiers>,
|
||
left_scroll: gpui::ScrollHandle,
|
||
right_scroll: gpui::ScrollHandle,
|
||
/// Anchors on the two scrolls, worn by whichever row is selected. Both
|
||
/// columns hold their rows inside one child element, and `scroll_to_item`
|
||
/// indexes a scroll's *direct* children — so it could only ever find item
|
||
/// 0, and walking the list with the arrows quietly left the selection off
|
||
/// the bottom of the column.
|
||
left_anchor: gpui::ScrollAnchor,
|
||
right_anchor: gpui::ScrollAnchor,
|
||
_subs: Vec<Subscription>,
|
||
}
|
||
|
||
impl Switcher {
|
||
fn text(&self, cx: &App) -> String {
|
||
self.query.read(cx).value().trim().to_lowercase()
|
||
}
|
||
}
|
||
|
||
/// Everything the panel needs for one frame: the groups (one per machine,
|
||
/// still the unit that carries link state and errors), and the flat,
|
||
/// most-recently-used-first left column the arrow keys walk.
|
||
struct Layout {
|
||
groups: Vec<Group>,
|
||
nav: Vec<Nav>,
|
||
}
|
||
|
||
impl Layout {
|
||
/// Which workspace row the tab column is showing.
|
||
fn subject(&self, sel: usize) -> Option<(usize, usize)> {
|
||
self.nav.get(sel).copied()
|
||
}
|
||
|
||
fn subject_row(&self, sel: usize) -> Option<&Row> {
|
||
let (g, r) = self.subject(sel)?;
|
||
self.groups[g].rows.get(r)
|
||
}
|
||
}
|
||
|
||
/// The flat left column: every row the query leaves visible, one line per
|
||
/// workspace, most recently used first with this window's own workspace on
|
||
/// top. A query matching a machine's name keeps all of that machine's rows —
|
||
/// searching "devbox" is how the old per-machine grouping is asked for now.
|
||
fn flatten(groups: &[Group], query: &str) -> Vec<Nav> {
|
||
let mut nav: Vec<Nav> = Vec::new();
|
||
for (g, group) in groups.iter().enumerate() {
|
||
let matched_host = group.label.to_lowercase().contains(query);
|
||
for (r, row) in group.rows.iter().enumerate() {
|
||
if query.is_empty() || matched_host || row.matches(query) {
|
||
nav.push((g, r));
|
||
}
|
||
}
|
||
}
|
||
nav.sort_by(|&(ga, ra), &(gb, rb)| {
|
||
let (a, b) = (&groups[ga].rows[ra], &groups[gb].rows[rb]);
|
||
b.current
|
||
.cmp(&a.current)
|
||
.then_with(|| b.last_active.cmp(&a.last_active))
|
||
.then_with(|| a.name.cmp(&b.name))
|
||
});
|
||
nav
|
||
}
|
||
|
||
/// One line of the create form's host dropdown.
|
||
enum HostItem {
|
||
/// This computer — first, while it matches the filter.
|
||
Local,
|
||
Host(HostChoice),
|
||
/// Pinned last, filter or no filter: the way out when the machine wanted
|
||
/// is not configured yet.
|
||
AddHost,
|
||
}
|
||
|
||
/// What the form's name box starts out saying: the same codename a workspace
|
||
/// created without one would have been given anyway (`fresh_workspace_name` —
|
||
/// "quiet-otter"), rolled against the chosen machine so it stays unique
|
||
/// there. Editable before it is spent; clearing the box creates a nameless
|
||
/// workspace that shows its directory, the old fallback.
|
||
fn default_workspace_name(chosen: Option<&HostChoice>, cx: &App) -> String {
|
||
let host = match chosen {
|
||
None => tty7_core::host::HostId::LOCAL,
|
||
Some(choice) => choice.target.host_id(),
|
||
};
|
||
crate::ui::tree_sync::fresh_workspace_name(cx, host)
|
||
}
|
||
|
||
fn host_items(hosts: Vec<HostChoice>, query: &str, local_label: &str) -> Vec<HostItem> {
|
||
let query = query.trim();
|
||
let mut items: Vec<HostItem> = Vec::new();
|
||
if query.is_empty() || crate::ui::palette::fuzzy_score(query, local_label).is_some() {
|
||
items.push(HostItem::Local);
|
||
}
|
||
items.extend(
|
||
remote_connect::filter_hosts(&hosts, query)
|
||
.into_iter()
|
||
.map(HostItem::Host),
|
||
);
|
||
items.push(HostItem::AddHost);
|
||
items
|
||
}
|
||
|
||
impl Tty7App {
|
||
pub(crate) fn toggle_switcher(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||
if self.switcher.is_some() {
|
||
self.close_switcher(window, cx);
|
||
} else {
|
||
self.open_switcher(window, cx);
|
||
}
|
||
}
|
||
|
||
pub(crate) fn open_switcher(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||
self.open_switcher_in(Column::Left, false, None, window, cx);
|
||
}
|
||
|
||
fn open_switcher_in(
|
||
&mut self,
|
||
column: Column,
|
||
mru: bool,
|
||
hold: Option<gpui::Modifiers>,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
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,
|
||
))
|
||
});
|
||
query.update(cx, |state, cx| state.focus(window, cx));
|
||
let subs = vec![cx.subscribe_in(
|
||
&query,
|
||
window,
|
||
|this, _input, ev: &InputEvent, _window, cx| {
|
||
if matches!(ev, InputEvent::Change) {
|
||
// A narrower list can strand the cursor past its end. Land
|
||
// it on the first hit so the tab column shows the hits
|
||
// straight away.
|
||
if let Some(sw) = this.switcher.as_mut() {
|
||
sw.left_sel = 0;
|
||
sw.right_sel = 0;
|
||
}
|
||
cx.notify();
|
||
}
|
||
},
|
||
)];
|
||
let (left_scroll, right_scroll) = (gpui::ScrollHandle::new(), gpui::ScrollHandle::new());
|
||
self.switcher = Some(Switcher {
|
||
query,
|
||
page: Page::List,
|
||
renaming: None,
|
||
orphans: Vec::new(),
|
||
column,
|
||
left_sel: 0,
|
||
right_sel: 0,
|
||
mru,
|
||
hold,
|
||
left_scroll: left_scroll.clone(),
|
||
right_scroll: right_scroll.clone(),
|
||
left_anchor: gpui::ScrollAnchor::for_handle(left_scroll),
|
||
right_anchor: gpui::ScrollAnchor::for_handle(right_scroll),
|
||
_subs: subs,
|
||
});
|
||
// Park the left cursor on this window's own workspace so the tab column
|
||
// opens on something useful.
|
||
let layout = self.switcher_layout(cx);
|
||
let here = self.workspace;
|
||
if let Some(at) = layout
|
||
.nav
|
||
.iter()
|
||
.position(|&(g, r)| layout.groups[g].rows[r].id == here)
|
||
&& let Some(sw) = self.switcher.as_mut()
|
||
{
|
||
sw.left_sel = at;
|
||
}
|
||
self.refresh_orphan_panes(cx);
|
||
cx.notify();
|
||
}
|
||
|
||
/// List the local daemon's panes and keep the ones no workspace holds.
|
||
/// The query is blocking daemon I/O, so it runs off the UI thread; the
|
||
/// filter runs back on it, where the machine mirror lives.
|
||
///
|
||
/// Local on purpose: a remote machine's orphans belong to its own daemon,
|
||
/// and routing a listing per host is what the CLI's reaper already does.
|
||
fn refresh_orphan_panes(&mut self, cx: &mut Context<Self>) {
|
||
cx.spawn(async move |this, cx| {
|
||
let listed = cx
|
||
.background_spawn(async move { tty7_core::client::PaneClient::local().list() })
|
||
.await;
|
||
let listed = match listed {
|
||
Ok(listed) => listed,
|
||
Err(e) => {
|
||
log::warn!(target: "tty7::switcher", "orphan pane listing failed: {e}");
|
||
return;
|
||
}
|
||
};
|
||
let _ = this.update(cx, |this, cx| {
|
||
let held = held_local_pane_ids(cx);
|
||
if let Some(sw) = this.switcher.as_mut() {
|
||
sw.orphans = orphan_panes_of(listed, &held);
|
||
}
|
||
cx.notify();
|
||
});
|
||
})
|
||
.detach();
|
||
}
|
||
|
||
/// Hang up one orphan pane and show what is left. The kill is
|
||
/// fire-and-forget, so the refresh that follows is also the confirmation:
|
||
/// a pane that survived it simply stays on the list.
|
||
fn close_orphan_pane(&mut self, pane_id: u64, cx: &mut Context<Self>) {
|
||
cx.spawn(async move |this, cx| {
|
||
let listed = cx
|
||
.background_spawn(async move {
|
||
let client = tty7_core::client::PaneClient::local();
|
||
client.kill(pane_id)?;
|
||
client.list()
|
||
})
|
||
.await;
|
||
let listed = match listed {
|
||
Ok(listed) => listed,
|
||
Err(e) => {
|
||
log::warn!(target: "tty7::switcher", "closing orphan %{pane_id} failed: {e}");
|
||
return;
|
||
}
|
||
};
|
||
let _ = this.update(cx, |this, cx| {
|
||
let held = held_local_pane_ids(cx);
|
||
if let Some(sw) = this.switcher.as_mut() {
|
||
sw.orphans = orphan_panes_of(listed, &held);
|
||
}
|
||
cx.notify();
|
||
});
|
||
})
|
||
.detach();
|
||
}
|
||
|
||
/// Ctrl+Tab. The first press raises the panel on the tab column with the
|
||
/// previously used tab already highlighted; further presses walk it. Holding
|
||
/// the modifier keeps the panel up, releasing it commits — IDEA's gesture.
|
||
///
|
||
/// With fewer than two tabs there is nothing to cycle, so the panel opens on
|
||
/// the workspace column and *stays* — no hold, no commit-on-release. The
|
||
/// gesture degrades into "open the switcher" rather than doing nothing.
|
||
pub(crate) fn tab_switch(
|
||
&mut self,
|
||
forward: bool,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
if self.switcher.is_some() {
|
||
let layout = self.switcher_layout(cx);
|
||
self.switcher_step_right(&layout, forward, window, cx);
|
||
return;
|
||
}
|
||
let n = self.tabs.len();
|
||
let cycling = n >= 2;
|
||
let held = window.modifiers();
|
||
self.open_switcher_in(
|
||
match cycling {
|
||
true => Column::Right,
|
||
false => Column::Left,
|
||
},
|
||
cycling,
|
||
(cycling && held.modified()).then_some(held),
|
||
window,
|
||
cx,
|
||
);
|
||
if cycling && let Some(sw) = self.switcher.as_mut() {
|
||
sw.right_sel = match forward {
|
||
true => 1,
|
||
false => n - 1,
|
||
};
|
||
}
|
||
cx.notify();
|
||
}
|
||
|
||
/// Watches the modifiers while a Ctrl+Tab panel is up. Letting go of any
|
||
/// part of the combination that raised it is the commit gesture.
|
||
pub(crate) fn switcher_hold_changed(
|
||
&mut self,
|
||
now: &gpui::Modifiers,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
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);
|
||
}
|
||
}
|
||
|
||
/// Called when the modifier that raised the panel comes back up.
|
||
pub(crate) fn switcher_commit_hold(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||
if self.switcher.as_ref().and_then(|sw| sw.hold).is_none() {
|
||
return;
|
||
}
|
||
let layout = self.switcher_layout(cx);
|
||
self.switcher_confirm(&layout, false, window, cx);
|
||
// Confirming a tab already closed the panel; anything else (an empty
|
||
// column, a workspace with nothing in it) still has to come down.
|
||
if self.switcher.is_some() {
|
||
self.close_switcher(window, cx);
|
||
}
|
||
}
|
||
|
||
/// Drops the hold without acting on it. The modifier release will never
|
||
/// arrive at a window that is no longer focused, so the panel would
|
||
/// otherwise sit there waiting forever.
|
||
pub(crate) fn switcher_release_hold(&mut self, cx: &mut Context<Self>) {
|
||
if let Some(sw) = self.switcher.as_mut()
|
||
&& sw.hold.take().is_some()
|
||
{
|
||
cx.notify();
|
||
}
|
||
}
|
||
|
||
pub(crate) fn close_switcher(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||
if self.switcher.take().is_some() {
|
||
if matches!(self.connect, Some(ConnectFlow::Failed { .. })) {
|
||
self.connect = None;
|
||
}
|
||
self.focus_active(window, cx);
|
||
cx.notify();
|
||
}
|
||
}
|
||
|
||
fn switcher_groups(&self, cx: &mut Context<Self>) -> Vec<Group> {
|
||
let now = crate::ui::home::now_secs();
|
||
let current = self.workspace;
|
||
crate::terminal::pane_liveness::sweep(cx);
|
||
|
||
let mut groups: Vec<Group> = Vec::new();
|
||
let mut index: HashMap<String, usize> = HashMap::new();
|
||
{
|
||
let app: &App = cx;
|
||
let store = WorkspaceStore::all(app);
|
||
for w in &store.views {
|
||
let (key, label, endpoint, target) = match w.host.as_ref() {
|
||
None => (
|
||
String::new(),
|
||
t(L10nKey::SwitcherThisComputer).to_string(),
|
||
String::new(),
|
||
None,
|
||
),
|
||
Some(r) => {
|
||
let key = r.target.to_string();
|
||
// The live profile's own name overwrites this below
|
||
// while the route still resolves; when it does not,
|
||
// the snapshot — or, for entries saved before
|
||
// snapshots existed, a placeholder — is all the entry
|
||
// has. Never the bare profile UUID (#485).
|
||
let label = r.route_label(t(L10nKey::RemoteProfileGone));
|
||
let endpoint = r
|
||
.via
|
||
.as_ref()
|
||
.map(RouteSnapshot::endpoint)
|
||
.filter(|e| e != &label)
|
||
.unwrap_or_default();
|
||
(key, label, endpoint, Some(r.target.clone()))
|
||
}
|
||
};
|
||
let slot = *index.entry(key.clone()).or_insert_with(|| {
|
||
groups.push(Group {
|
||
key,
|
||
label,
|
||
endpoint,
|
||
target,
|
||
link: Link::Offline,
|
||
error: None,
|
||
installing: None,
|
||
preempted: false,
|
||
parked: false,
|
||
rows: Vec::new(),
|
||
});
|
||
groups.len() - 1
|
||
});
|
||
// A row's path is on the workspace's own machine, so that is
|
||
// the machine whose home may shorten it (#580).
|
||
let home = crate::ui::path_display::home_for_host(app, w.host_id());
|
||
groups[slot].rows.push(Row {
|
||
id: w.id,
|
||
name: crate::ui::machine_mirror::display_name(app, w)
|
||
.unwrap_or_else(|| t(L10nKey::WindowUntitled).to_string()),
|
||
path: crate::ui::machine_mirror::subject_path(app, w)
|
||
.map(|p| {
|
||
crate::ui::home::display_path(std::path::Path::new(&p), home.as_deref())
|
||
})
|
||
.unwrap_or_default(),
|
||
when: crate::ui::home::relative_time(now, w.last_active),
|
||
last_active: w.last_active,
|
||
live: crate::terminal::pane_liveness::liveness_of(app, w),
|
||
open: w.open,
|
||
current: w.id == current,
|
||
preempted: false,
|
||
adopt: None,
|
||
remote_id: w.host.as_ref().map(|r| r.workspace),
|
||
tabs: self.tab_rows_for(w.id, app),
|
||
});
|
||
}
|
||
}
|
||
|
||
// Listed once for the whole frame: the pending groups below name
|
||
// themselves from it, and so does the pass that settles every group's
|
||
// link state further down.
|
||
let configured = remote_connect::available_hosts(cx);
|
||
|
||
for target in self.pending_machines() {
|
||
let key = target.to_string();
|
||
if index.contains_key(&key) {
|
||
continue;
|
||
}
|
||
index.insert(key.clone(), groups.len());
|
||
groups.push(Group {
|
||
// Not `key`: a `Profile` target spells itself as its config
|
||
// UUID, so a machine whose profile has been deleted would
|
||
// announce itself to the banners below by a raw UUID (#485).
|
||
// The pass below overwrites this while the profile is still
|
||
// configured; this is what is left when it is not.
|
||
label: remote_connect::label_from_hosts(&configured, &target),
|
||
key,
|
||
endpoint: String::new(),
|
||
target: Some(target),
|
||
link: Link::Offline,
|
||
error: None,
|
||
installing: None,
|
||
preempted: false,
|
||
parked: false,
|
||
rows: Vec::new(),
|
||
});
|
||
}
|
||
|
||
if !index.contains_key("") {
|
||
groups.insert(
|
||
0,
|
||
Group {
|
||
key: String::new(),
|
||
label: t(L10nKey::SwitcherThisComputer).to_string(),
|
||
endpoint: String::new(),
|
||
target: None,
|
||
link: Link::Offline,
|
||
error: None,
|
||
installing: None,
|
||
preempted: false,
|
||
parked: false,
|
||
rows: Vec::new(),
|
||
},
|
||
);
|
||
}
|
||
|
||
// This window's own workspace has to be in the list even when the store
|
||
// has not caught up with it: Ctrl+Tab reaches its tabs through the same
|
||
// left column, and a missing row would leave the panel with nothing to
|
||
// switch between.
|
||
if !groups
|
||
.iter()
|
||
.any(|g| g.rows.iter().any(|r| r.id == current))
|
||
&& let Some(slot) = groups.iter().position(|g| g.key.is_empty())
|
||
{
|
||
let app: &App = cx;
|
||
groups[slot].rows.insert(
|
||
0,
|
||
Row {
|
||
id: current,
|
||
name: crate::ui::machine_mirror::display_name_for(app, current)
|
||
.unwrap_or_else(|| t(L10nKey::WindowUntitled).to_string()),
|
||
path: String::new(),
|
||
when: crate::ui::home::relative_time(now, now),
|
||
last_active: now,
|
||
live: Liveness::Alive,
|
||
open: true,
|
||
current: true,
|
||
preempted: false,
|
||
adopt: None,
|
||
remote_id: None,
|
||
tabs: self.tab_rows_for(current, app),
|
||
},
|
||
);
|
||
}
|
||
|
||
// Workspaces this machine holds that the store has never heard of: the
|
||
// CLI makes them too, and one that never appears here looks to the
|
||
// person who ran `tty7 new` like nothing happened at all. They open
|
||
// like any other row — the id in the tree is the id a window claims.
|
||
if let Some(slot) = groups.iter().position(|g| g.key.is_empty()) {
|
||
// Measured against the rows already listed rather than against the
|
||
// store, which is what put them there: the block above lists this
|
||
// window's own workspace before the store has caught up with it,
|
||
// and two rows under one id would be two ways into one window.
|
||
let listed: Vec<WorkspaceId> = groups
|
||
.iter()
|
||
.flat_map(|g| g.rows.iter().map(|r| r.id))
|
||
.collect();
|
||
let app: &App = cx;
|
||
// Unclaimed *local* workspaces: their paths are on this machine,
|
||
// so this machine's home is the right one to measure them by.
|
||
let local_home = crate::ui::path_display::local_home();
|
||
let rows: Vec<Row> = crate::ui::machine_mirror::unclaimed_local_workspaces(app)
|
||
.into_iter()
|
||
.filter(|ws| !listed.contains(&ws.id))
|
||
.map(|ws| Row {
|
||
id: ws.id,
|
||
name: ws.name,
|
||
path: ws
|
||
.path
|
||
.map(|p| {
|
||
crate::ui::home::display_path(
|
||
std::path::Path::new(&p),
|
||
local_home.as_deref(),
|
||
)
|
||
})
|
||
.unwrap_or_default(),
|
||
when: crate::ui::home::relative_time(now, ws.last_active),
|
||
last_active: ws.last_active,
|
||
live: match ws.live {
|
||
true => Liveness::Alive,
|
||
false => Liveness::Stopped,
|
||
},
|
||
open: false,
|
||
current: false,
|
||
preempted: false,
|
||
adopt: None,
|
||
remote_id: None,
|
||
tabs: self.tab_rows_for(ws.id, app),
|
||
})
|
||
.collect();
|
||
groups[slot].rows.extend(rows);
|
||
}
|
||
|
||
// Row order is `flatten`'s business now — the left column is one flat
|
||
// most-recently-used list. Groups keep local-first order only so the
|
||
// trouble banners under the list come out in a stable order.
|
||
groups.sort_by(|a, b| a.key.is_empty().cmp(&b.key.is_empty()).reverse());
|
||
|
||
for group in &mut groups {
|
||
let Some(target) = group.target.clone() else {
|
||
group.link = Link::Local;
|
||
continue;
|
||
};
|
||
if let Some(known) = configured.iter().find(|h| h.target == target) {
|
||
group.label = known.label.clone();
|
||
if known.detail != known.label {
|
||
group.endpoint = known.detail.clone();
|
||
}
|
||
}
|
||
let id = target.host_id();
|
||
let supervised = RemoteLinks::machine_status(cx, id);
|
||
group.link = self.link_state(&target, supervised.as_ref(), cx);
|
||
// A route that no longer resolves parks the whole group (#485):
|
||
// not retried, labeled from the snapshot, forget-not-retry for
|
||
// actions. A live link is exempt — its panes keep working
|
||
// regardless of what happened to the profile that made them.
|
||
group.parked =
|
||
group.link != Link::Connected && !remote_connect::route_resolvable(cx, &target);
|
||
if let Some(ConnectFlow::Failed { choice, error }) = &self.connect
|
||
&& choice.target == target
|
||
{
|
||
group.error = Some(error.clone());
|
||
}
|
||
if group.error.is_none() {
|
||
if let Some(error) = self.remote_host_errors.get(&target.to_string()) {
|
||
group.error = Some(error.clone());
|
||
}
|
||
}
|
||
// The supervisor's own attempts never touch this window's `connect`,
|
||
// so without this last fallback the route it could not build, and
|
||
// the reconnect that has been failing for an hour, have nowhere at
|
||
// all to be said.
|
||
if group.error.is_none() {
|
||
group.error = match supervised {
|
||
Some(MachineStatus::Failed(e)) => Some(e),
|
||
Some(MachineStatus::Reconnecting { last_error, .. }) => last_error,
|
||
_ => None,
|
||
};
|
||
}
|
||
let taken: HashSet<WorkspaceId> =
|
||
RemoteLinks::preempted_on(cx, id).into_iter().collect();
|
||
group.preempted = !taken.is_empty();
|
||
for row in &mut group.rows {
|
||
row.preempted = taken.contains(&row.id);
|
||
}
|
||
let reported = remote_connect::install_progress_for(id);
|
||
if group.link == Link::Connecting
|
||
|| matches!(group.link, Link::Reconnecting { .. })
|
||
|| group.error.is_some()
|
||
|| matches!(reported, Some(InstallPhase::Restarting))
|
||
{
|
||
group.installing = reported;
|
||
}
|
||
if let Some(snapshot) = self.host_snapshots.get(&id) {
|
||
group.merge(&snapshot.rows, now);
|
||
}
|
||
}
|
||
groups
|
||
}
|
||
|
||
/// The tab column's rows for one workspace. This window's own workspace has
|
||
/// live in-memory tabs (agent status, unread counts, MRU order); every other
|
||
/// workspace comes out of the machine mirror, which is the only view this
|
||
/// process has of windows it does not own.
|
||
fn tab_rows_for(&self, id: WorkspaceId, cx: &App) -> Vec<TabRow> {
|
||
if id == self.workspace {
|
||
let order = match self.switcher.as_ref().is_some_and(|sw| sw.mru) {
|
||
true => self.tabs_by_mru(),
|
||
false => (0..self.tabs.len()).collect(),
|
||
};
|
||
return order
|
||
.into_iter()
|
||
.map(|i| {
|
||
let tab = &self.tabs[i];
|
||
TabRow {
|
||
id: tab.tree_id.get(),
|
||
index: i,
|
||
label: self.tab_label(tab, i, None, cx),
|
||
named: tab.name.as_deref().is_some_and(|n| !n.trim().is_empty())
|
||
|| tab.agent(cx).is_some(),
|
||
path: tab
|
||
.pane
|
||
.terminals()
|
||
.first()
|
||
.and_then(|leaf| {
|
||
let leaf = leaf.read(cx);
|
||
Some((leaf.cwd()?, leaf.display_home(cx)))
|
||
})
|
||
.map(|(p, home)| crate::ui::home::display_path(&p, home.as_deref()))
|
||
.unwrap_or_default(),
|
||
agent: tab.agent(cx),
|
||
status: tab.agent_status(cx),
|
||
unread: tab.agent_unread_count(cx),
|
||
ssh: self.tab_ssh_dot(tab, cx),
|
||
active: i == self.active,
|
||
git: tab.git_status(None, cx),
|
||
}
|
||
})
|
||
.collect();
|
||
}
|
||
|
||
let Some((views, active)) = crate::ui::machine_mirror::tab_views_for(cx, id) else {
|
||
return Vec::new();
|
||
};
|
||
// Git state is cached globally per (host, cwd) and outlives the panes
|
||
// that filled it, so a workspace this window recently left still has
|
||
// its branches on hand. Read-only on purpose: probing every cwd of
|
||
// every workspace to populate a panel that closes in a second would
|
||
// cost a git invocation each, and a round trip each when the host is
|
||
// remote.
|
||
let host = WorkspaceStore::all(cx).get(id).map(|w| w.host_id());
|
||
let git = |cwd: Option<&str>| -> Option<tty7_core::core::git::GitStatus> {
|
||
let (host, cwd) = (host?, cwd?);
|
||
cx.try_global::<crate::terminal::git_status::GitStatusCache>()?
|
||
.status_for(host, std::path::Path::new(cwd))
|
||
};
|
||
// These rows describe a workspace on `host`, and the cwds they carry
|
||
// are that machine's. Only its home may shorten them (#580).
|
||
let home = host.and_then(|host| crate::ui::path_display::home_for_host(cx, host));
|
||
views
|
||
.into_iter()
|
||
.enumerate()
|
||
.map(|(i, v)| TabRow {
|
||
label: tab_view_label(&v, i, home.as_deref()),
|
||
// The label only stands in for the path when it came *from* the
|
||
// path; a name or an agent leaves the location still worth
|
||
// printing.
|
||
named: v.name.as_deref().is_some_and(|n| !n.trim().is_empty()) || v.agent.is_some(),
|
||
path: v
|
||
.cwd
|
||
.as_deref()
|
||
.map(|p| {
|
||
crate::ui::home::display_path(std::path::Path::new(p), home.as_deref())
|
||
})
|
||
.unwrap_or_default(),
|
||
agent: v.agent,
|
||
status: v.status,
|
||
unread: 0,
|
||
ssh: None,
|
||
active: Some(v.id) == active,
|
||
git: git(v.cwd.as_deref()),
|
||
index: i,
|
||
id: v.id,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Builds one frame's worth of panel: the groups, and the flat left
|
||
/// column the arrow keys walk.
|
||
fn switcher_layout(&self, cx: &mut Context<Self>) -> Layout {
|
||
let groups = self.switcher_groups(cx);
|
||
let query = self
|
||
.switcher
|
||
.as_ref()
|
||
.map(|sw| sw.text(cx))
|
||
.unwrap_or_default();
|
||
let nav = flatten(&groups, &query);
|
||
Layout { groups, nav }
|
||
}
|
||
|
||
fn pending_machines(&self) -> Vec<RemoteTarget> {
|
||
let mut out: Vec<RemoteTarget> = self
|
||
.host_snapshots
|
||
.values()
|
||
.map(|s| s.target.clone())
|
||
.collect();
|
||
if let Some(choice) = self.connect.as_ref().and_then(ConnectFlow::choice) {
|
||
out.push(choice.target.clone());
|
||
}
|
||
out
|
||
}
|
||
|
||
fn link_state(
|
||
&self,
|
||
target: &RemoteTarget,
|
||
supervised: Option<&MachineStatus>,
|
||
cx: &mut Context<Self>,
|
||
) -> Link {
|
||
let has_link = remote_connect::HostLinks::get(cx, target.host_id()).is_some();
|
||
link_from(self.connect.as_ref(), target, supervised, has_link)
|
||
}
|
||
|
||
fn switcher_open(
|
||
&mut self,
|
||
row: RowRef,
|
||
new_window: bool,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
self.close_switcher(window, cx);
|
||
match row.adopt {
|
||
Some((target, remote)) => self.open_remote_workspace(target, *remote, window, cx),
|
||
None if new_window => crate::ui::windows::open(cx, Some(row.id)),
|
||
None => self.reveal_workspace(row.id, window, cx),
|
||
}
|
||
}
|
||
|
||
fn switcher_rename(&mut self, id: WorkspaceId, window: &mut Window, cx: &mut Context<Self>) {
|
||
let current = crate::ui::machine_mirror::display_name_for(cx, id).unwrap_or_default();
|
||
let input = Self::rename_box(current, window, cx);
|
||
let sub = cx.subscribe_in(
|
||
&input,
|
||
window,
|
||
move |this, _input, ev: &InputEvent, window, cx| match ev {
|
||
InputEvent::PressEnter { .. } | InputEvent::Blur => {
|
||
this.switcher_commit_rename(window, cx)
|
||
}
|
||
_ => {}
|
||
},
|
||
);
|
||
if let Some(sw) = self.switcher.as_mut() {
|
||
sw.renaming = Some((id, input));
|
||
sw._subs.push(sub);
|
||
}
|
||
cx.notify();
|
||
}
|
||
|
||
fn switcher_commit_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||
let Some((id, input)) = self.switcher.as_mut().and_then(|sw| sw.renaming.take()) else {
|
||
return;
|
||
};
|
||
let value = input.read(cx).value().trim().to_string();
|
||
crate::ui::tree_sync::rename_workspace(cx, id, (!value.is_empty()).then_some(value));
|
||
crate::ui::windows::refresh_menu(cx);
|
||
if id == self.workspace {
|
||
self.sync_window_title(window, cx);
|
||
}
|
||
if let Some(sw) = self.switcher.as_ref() {
|
||
sw.query.update(cx, |state, cx| state.focus(window, cx));
|
||
}
|
||
cx.notify();
|
||
}
|
||
|
||
fn switcher_disconnect(&mut self, target: &RemoteTarget, cx: &mut Context<Self>) {
|
||
crate::ui::remote_workspace::RemoteLinks::disconnect(cx, target.host_id());
|
||
if self
|
||
.connect
|
||
.as_ref()
|
||
.and_then(ConnectFlow::choice)
|
||
.is_some_and(|c| &c.target == target)
|
||
{
|
||
self.connect = None;
|
||
}
|
||
// A create still waiting on this machine's link dies with the link:
|
||
// calling the connect off is calling the create off, or the next
|
||
// successful connect would grow a workspace nobody asked it for.
|
||
// One parked on a dialect refusal is waiting all the same, so it
|
||
// dies here too.
|
||
if self
|
||
.pending_create
|
||
.as_ref()
|
||
.is_some_and(|p| &p.target == target)
|
||
{
|
||
self.pending_create = None;
|
||
}
|
||
if self
|
||
.parked_create
|
||
.as_ref()
|
||
.is_some_and(|p| &p.target == target)
|
||
{
|
||
self.parked_create = None;
|
||
}
|
||
cx.notify();
|
||
}
|
||
|
||
/// `⌘⇧N` and the footer button. Raises the switcher if it is down and
|
||
/// flips the card to the create form, host defaulting to this computer —
|
||
/// so `⌘⇧N` then Enter is still "a fresh local workspace", two keys.
|
||
pub(crate) fn open_workspace_form(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||
if self.switcher.is_none() {
|
||
self.open_switcher(window, cx);
|
||
}
|
||
self.switcher_to_form(None, window, cx);
|
||
}
|
||
|
||
fn switcher_to_form(
|
||
&mut self,
|
||
chosen: Option<HostChoice>,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
let prefill = default_workspace_name(chosen.as_ref(), cx);
|
||
let name = cx.new(|cx| {
|
||
InputState::new(window, cx)
|
||
.placeholder(t(L10nKey::SwitcherFormNamePlaceholder))
|
||
.default_value(prefill.clone())
|
||
});
|
||
let host = cx.new(|cx| InputState::new(window, cx).placeholder(t(L10nKey::FilterHosts)));
|
||
// Retyping the filter moves the dropdown cursor back onto the first hit.
|
||
let sub = cx.subscribe_in(
|
||
&host,
|
||
window,
|
||
|this, _input, ev: &InputEvent, _window, cx| {
|
||
if matches!(ev, InputEvent::Change)
|
||
&& let Some(sw) = this.switcher.as_mut()
|
||
&& let Page::Create(form) = &mut sw.page
|
||
{
|
||
form.sel = 0;
|
||
cx.notify();
|
||
}
|
||
},
|
||
);
|
||
name.update(cx, |state, cx| state.focus(window, cx));
|
||
if let Some(sw) = self.switcher.as_mut() {
|
||
sw.page = Page::Create(CreateForm {
|
||
name,
|
||
host,
|
||
open: false,
|
||
sel: 0,
|
||
chosen,
|
||
prefill,
|
||
});
|
||
sw._subs.push(sub);
|
||
}
|
||
cx.notify();
|
||
}
|
||
|
||
fn switcher_back_to_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||
if let Some(sw) = self.switcher.as_mut() {
|
||
sw.page = Page::List;
|
||
let query = sw.query.clone();
|
||
query.update(cx, |state, cx| state.focus(window, cx));
|
||
}
|
||
cx.notify();
|
||
}
|
||
|
||
/// The host dropdown's lines for the filter text currently in the box.
|
||
fn form_items(&self, form: &CreateForm, cx: &App) -> Vec<HostItem> {
|
||
let query = form.host.read(cx).value().trim().to_string();
|
||
host_items(
|
||
remote_connect::available_hosts(cx),
|
||
&query,
|
||
t(L10nKey::SwitcherThisComputer),
|
||
)
|
||
}
|
||
|
||
fn switcher_form_open_hosts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||
if let Some(sw) = self.switcher.as_mut()
|
||
&& let Page::Create(form) = &mut sw.page
|
||
{
|
||
form.open = true;
|
||
form.sel = 0;
|
||
let host = form.host.clone();
|
||
host.update(cx, |state, cx| {
|
||
state.set_value("", window, cx);
|
||
state.focus(window, cx);
|
||
});
|
||
}
|
||
cx.notify();
|
||
}
|
||
|
||
fn switcher_form_close_hosts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||
if let Some(sw) = self.switcher.as_mut()
|
||
&& let Page::Create(form) = &mut sw.page
|
||
{
|
||
form.open = false;
|
||
let name = form.name.clone();
|
||
name.update(cx, |state, cx| state.focus(window, cx));
|
||
}
|
||
cx.notify();
|
||
}
|
||
|
||
fn switcher_form_pick(&mut self, at: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||
let item = {
|
||
let Some(sw) = self.switcher.as_ref() else {
|
||
return;
|
||
};
|
||
let Page::Create(form) = &sw.page else {
|
||
return;
|
||
};
|
||
let mut items = self.form_items(form, cx);
|
||
if at >= items.len() {
|
||
return;
|
||
}
|
||
items.swap_remove(at)
|
||
};
|
||
match item {
|
||
HostItem::Local => self.switcher_form_choose(None, window, cx),
|
||
HostItem::Host(choice) => self.switcher_form_choose(Some(choice), window, cx),
|
||
HostItem::AddHost => {
|
||
self.close_switcher(window, cx);
|
||
self.open_new_ssh_host(window, cx);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn switcher_form_choose(
|
||
&mut self,
|
||
chosen: Option<HostChoice>,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
let prefill = default_workspace_name(chosen.as_ref(), cx);
|
||
if let Some(sw) = self.switcher.as_mut()
|
||
&& let Page::Create(form) = &mut sw.page
|
||
{
|
||
form.chosen = chosen;
|
||
form.open = false;
|
||
form.sel = 0;
|
||
let (host, name) = (form.host.clone(), form.name.clone());
|
||
// A name the user has not touched follows the host; one they have
|
||
// is theirs and stays.
|
||
let untouched = {
|
||
let value = name.read(cx).value().trim().to_string();
|
||
value.is_empty() || value == form.prefill
|
||
};
|
||
if untouched {
|
||
form.prefill = prefill.clone();
|
||
name.update(cx, |state, cx| state.set_value(&prefill, window, cx));
|
||
}
|
||
host.update(cx, |state, cx| state.set_value("", window, cx));
|
||
name.update(cx, |state, cx| state.focus(window, cx));
|
||
}
|
||
cx.notify();
|
||
}
|
||
|
||
/// Enter on the form. Local and already-connected machines create on the
|
||
/// spot; a machine with no live link has to connect first — the create is
|
||
/// parked on the app and `finish_connect` completes it, because only a
|
||
/// live link knows the home directory a fresh workspace is rooted at.
|
||
fn switcher_form_create(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||
let (name, chosen) = {
|
||
let Some(sw) = self.switcher.as_ref() else {
|
||
return;
|
||
};
|
||
let Page::Create(form) = &sw.page else {
|
||
return;
|
||
};
|
||
let name = form.name.read(cx).value().trim().to_string();
|
||
((!name.is_empty()).then_some(name), form.chosen.clone())
|
||
};
|
||
match chosen {
|
||
None => {
|
||
self.close_switcher(window, cx);
|
||
self.switch_workspace(None, window, cx);
|
||
self.name_fresh_workspace(name, window, cx);
|
||
}
|
||
Some(choice) => match remote_connect::HostLinks::home(cx, choice.target.host_id()) {
|
||
Some(home) => {
|
||
self.close_switcher(window, cx);
|
||
self.create_remote_workspace(choice.target.clone(), home, window, cx);
|
||
self.name_fresh_workspace(name, window, cx);
|
||
}
|
||
None => {
|
||
self.pending_create = Some(PendingCreate {
|
||
target: choice.target.clone(),
|
||
name,
|
||
});
|
||
// Back to the list, where the connect has somewhere to be
|
||
// seen while it works.
|
||
self.switcher_back_to_list(window, cx);
|
||
self.connect_to_host(choice, cx);
|
||
}
|
||
},
|
||
}
|
||
}
|
||
|
||
/// Names the workspace this window just created and switched to.
|
||
pub(crate) fn name_fresh_workspace(
|
||
&mut self,
|
||
name: Option<String>,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
let Some(name) = name else {
|
||
return;
|
||
};
|
||
crate::ui::tree_sync::name_new_workspace(cx, self.workspace, name);
|
||
crate::ui::windows::refresh_menu(cx);
|
||
self.sync_window_title(window, cx);
|
||
}
|
||
|
||
/// Arrow keys and Enter for the panel. These run ahead of the text input's
|
||
/// own `MoveUp`/`MoveDown` bindings, so anything handled here must stop
|
||
/// propagating or the cursor jumps inside the search box instead.
|
||
fn on_switcher_key(
|
||
&mut self,
|
||
ev: &gpui::KeyDownEvent,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
let Some(sw) = self.switcher.as_ref() else {
|
||
return;
|
||
};
|
||
if matches!(sw.page, Page::Create(_)) {
|
||
self.on_form_key(ev, window, cx);
|
||
return;
|
||
}
|
||
let (key, mods) = (ev.keystroke.key.as_str(), ev.keystroke.modifiers);
|
||
if key == "escape" {
|
||
cx.stop_propagation();
|
||
// A rename in progress is the smaller thing to back out of; the
|
||
// panel itself only closes on a second press.
|
||
if let Some(sw) = self.switcher.as_mut()
|
||
&& sw.renaming.take().is_some()
|
||
{
|
||
let query = sw.query.clone();
|
||
query.update(cx, |state, cx| state.focus(window, cx));
|
||
cx.notify();
|
||
return;
|
||
}
|
||
self.close_switcher(window, cx);
|
||
return;
|
||
}
|
||
// A rename box owns every key while it is up.
|
||
if sw.renaming.is_some() {
|
||
return;
|
||
}
|
||
let column = sw.column;
|
||
let layout = self.switcher_layout(cx);
|
||
match key_intent(key, mods) {
|
||
// Escape already returned above; closing again is harmless and
|
||
// beats a panic in the render path if that ever stops being true.
|
||
Key::Close => self.close_switcher(window, cx),
|
||
Key::Pass => {}
|
||
Key::Step(forward) => {
|
||
cx.stop_propagation();
|
||
match column {
|
||
Column::Left => self.switcher_step_left(&layout, forward, window, cx),
|
||
Column::Right => self.switcher_step_right(&layout, forward, window, cx),
|
||
}
|
||
}
|
||
// Once there is a query, left and right belong to the caret in the
|
||
// search box; Tab is then the way across.
|
||
Key::ToColumn(Column::Left) if column == Column::Right && sw.text(cx).is_empty() => {
|
||
cx.stop_propagation();
|
||
self.switcher_focus(Column::Left, cx);
|
||
}
|
||
Key::ToColumn(Column::Right) if column == Column::Left && sw.text(cx).is_empty() => {
|
||
let has_tabs = layout
|
||
.subject_row(sw.left_sel)
|
||
.is_some_and(|r| !r.tabs.is_empty());
|
||
if has_tabs {
|
||
cx.stop_propagation();
|
||
self.switcher_focus(Column::Right, cx);
|
||
}
|
||
}
|
||
Key::ToColumn(_) => {}
|
||
Key::Tab(forward) => {
|
||
cx.stop_propagation();
|
||
self.switcher_step_right(&layout, forward, window, cx);
|
||
}
|
||
Key::Confirm(new_window) => {
|
||
cx.stop_propagation();
|
||
self.switcher_confirm(&layout, new_window, window, cx);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The create form's keys. Characters fall through to whichever input is
|
||
/// focused; everything structural is decided here, ahead of the inputs'
|
||
/// own bindings, so it has to stop propagating whatever it handles.
|
||
fn on_form_key(
|
||
&mut self,
|
||
ev: &gpui::KeyDownEvent,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
let (open, sel, n) = {
|
||
let Some(sw) = self.switcher.as_ref() else {
|
||
return;
|
||
};
|
||
let Page::Create(form) = &sw.page else {
|
||
return;
|
||
};
|
||
(form.open, form.sel, self.form_items(form, cx).len())
|
||
};
|
||
let (key, mods) = (ev.keystroke.key.as_str(), ev.keystroke.modifiers);
|
||
let bare = !mods.alt && !mods.function && !mods.control && !mods.secondary();
|
||
match key {
|
||
// The dropdown is the smaller thing to back out of; the form only
|
||
// folds back into the list on a second press.
|
||
"escape" => {
|
||
cx.stop_propagation();
|
||
match open {
|
||
true => self.switcher_form_close_hosts(window, cx),
|
||
false => self.switcher_back_to_list(window, cx),
|
||
}
|
||
}
|
||
"tab" if bare || mods.shift => {
|
||
cx.stop_propagation();
|
||
match open {
|
||
true => self.switcher_form_close_hosts(window, cx),
|
||
false => self.switcher_form_open_hosts(window, cx),
|
||
}
|
||
}
|
||
"up" | "down" if bare => {
|
||
cx.stop_propagation();
|
||
match open {
|
||
true if n > 0 => {
|
||
if let Some(sw) = self.switcher.as_mut()
|
||
&& let Page::Create(form) = &mut sw.page
|
||
{
|
||
form.sel = step(sel.min(n - 1), n, key == "down");
|
||
}
|
||
cx.notify();
|
||
}
|
||
false if key == "down" => self.switcher_form_open_hosts(window, cx),
|
||
_ => {}
|
||
}
|
||
}
|
||
"enter" if bare => {
|
||
cx.stop_propagation();
|
||
match open {
|
||
true => self.switcher_form_pick(sel, window, cx),
|
||
false => self.switcher_form_create(window, cx),
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
/// The anchor a row wears while it is the selected one, so stepping the
|
||
/// cursor with the keyboard carries the column to it.
|
||
fn switcher_anchor(&self, column: Column, picked: bool) -> Option<gpui::ScrollAnchor> {
|
||
let sw = self.switcher.as_ref()?;
|
||
picked.then(|| match column {
|
||
Column::Left => sw.left_anchor.clone(),
|
||
Column::Right => sw.right_anchor.clone(),
|
||
})
|
||
}
|
||
|
||
/// Moves the left cursor to a clicked row so the tab column follows it.
|
||
/// Deliberately not wired to hover — the tab column swapping out from under
|
||
/// the pointer on the way to somewhere else is noise, not a preview.
|
||
fn switcher_point_at(&mut self, at: usize, cx: &mut Context<Self>) {
|
||
let Some(sw) = self.switcher.as_mut() else {
|
||
return;
|
||
};
|
||
if sw.left_sel == at && sw.column == Column::Left {
|
||
return;
|
||
}
|
||
sw.left_sel = at;
|
||
sw.right_sel = 0;
|
||
sw.column = Column::Left;
|
||
cx.notify();
|
||
}
|
||
|
||
/// Aims the tab cursor at one row, without acting on it.
|
||
fn switcher_point_tab(&mut self, nth: usize, cx: &mut Context<Self>) {
|
||
if let Some(sw) = self.switcher.as_mut() {
|
||
sw.column = Column::Right;
|
||
sw.right_sel = nth;
|
||
cx.notify();
|
||
}
|
||
}
|
||
|
||
fn switcher_focus(&mut self, column: Column, cx: &mut Context<Self>) {
|
||
if let Some(sw) = self.switcher.as_mut() {
|
||
sw.column = column;
|
||
}
|
||
cx.notify();
|
||
}
|
||
|
||
fn switcher_step_left(
|
||
&mut self,
|
||
layout: &Layout,
|
||
forward: bool,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
let n = layout.nav.len();
|
||
let Some(sw) = self.switcher.as_mut() else {
|
||
return;
|
||
};
|
||
if n == 0 {
|
||
return;
|
||
}
|
||
sw.column = Column::Left;
|
||
sw.left_sel = step(sw.left_sel.min(n - 1), n, forward);
|
||
// A different workspace means a different tab column.
|
||
sw.right_sel = 0;
|
||
let anchor = sw.left_anchor.clone();
|
||
anchor.scroll_to(window, cx);
|
||
cx.notify();
|
||
}
|
||
|
||
fn switcher_step_right(
|
||
&mut self,
|
||
layout: &Layout,
|
||
forward: bool,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
let sel = self.switcher.as_ref().map(|sw| sw.left_sel).unwrap_or(0);
|
||
let query = self
|
||
.switcher
|
||
.as_ref()
|
||
.map(|sw| sw.text(cx))
|
||
.unwrap_or_default();
|
||
let n = layout
|
||
.subject_row(sel)
|
||
.map(|row| visible_tabs(row, &query).len())
|
||
.unwrap_or(0);
|
||
let Some(sw) = self.switcher.as_mut() else {
|
||
return;
|
||
};
|
||
if n == 0 {
|
||
return;
|
||
}
|
||
sw.column = Column::Right;
|
||
sw.right_sel = step(sw.right_sel.min(n - 1), n, forward);
|
||
let anchor = sw.right_anchor.clone();
|
||
anchor.scroll_to(window, cx);
|
||
cx.notify();
|
||
}
|
||
|
||
fn switcher_confirm(
|
||
&mut self,
|
||
layout: &Layout,
|
||
new_window: bool,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
let Some(sw) = self.switcher.as_ref() else {
|
||
return;
|
||
};
|
||
let (sel, column, right_sel) = (sw.left_sel, sw.column, sw.right_sel);
|
||
if column == Column::Right {
|
||
let query = sw.text(cx);
|
||
let Some(row) = layout.subject_row(sel) else {
|
||
return;
|
||
};
|
||
let Some(tab) = visible_tabs(row, &query)
|
||
.get(right_sel)
|
||
.and_then(|i| row.tabs.get(*i))
|
||
else {
|
||
return;
|
||
};
|
||
let (ws, id, index) = (row.id, tab.id, tab.index);
|
||
self.switcher_open_tab(ws, id, index, new_window, window, cx);
|
||
return;
|
||
}
|
||
if let Some(&(g, r)) = layout.nav.get(sel) {
|
||
let group = &layout.groups[g];
|
||
let row = RowRef::of(group, &group.rows[r]);
|
||
self.switcher_open(row, new_window, window, cx);
|
||
}
|
||
}
|
||
|
||
/// Activates one tab of `ws`, wherever that workspace happens to live.
|
||
fn switcher_open_tab(
|
||
&mut self,
|
||
ws: WorkspaceId,
|
||
tab: TabId,
|
||
index: usize,
|
||
new_window: bool,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
self.close_switcher(window, cx);
|
||
if new_window {
|
||
crate::ui::windows::open_at_tab(cx, ws, tab);
|
||
return;
|
||
}
|
||
if ws == self.workspace {
|
||
self.activate(index, window, cx);
|
||
return;
|
||
}
|
||
// A workspace that already has a window belongs to that window; anything
|
||
// else comes here, the same as picking the workspace itself.
|
||
if crate::ui::windows::WindowRegistry::window_for(cx, ws).is_some() {
|
||
crate::ui::windows::open_at_tab(cx, ws, tab);
|
||
return;
|
||
}
|
||
self.switch_workspace(Some(ws), window, cx);
|
||
self.activate_tree_tab(tab, window, cx);
|
||
}
|
||
|
||
pub(crate) fn render_switcher(
|
||
&self,
|
||
window: &Window,
|
||
cx: &mut Context<Self>,
|
||
) -> Option<AnyElement> {
|
||
let sw = self.switcher.as_ref()?;
|
||
let scrim = crate::ui::presets::scrim_fill(cx);
|
||
let card = match &sw.page {
|
||
Page::Create(form) => self.render_create_card(form, window, cx),
|
||
Page::List => self.render_list_card(window, cx),
|
||
};
|
||
Some(
|
||
div()
|
||
.absolute()
|
||
.inset_0()
|
||
.flex()
|
||
.items_start()
|
||
.justify_center()
|
||
.pt(px(CARD_TOP))
|
||
.bg(scrim)
|
||
.key_context("Switcher")
|
||
.on_action(cx.listener(|this, _: &SwitcherAcross, window, cx| {
|
||
let layout = this.switcher_layout(cx);
|
||
this.switcher_step_right(&layout, true, window, cx);
|
||
}))
|
||
.on_action(cx.listener(|this, _: &SwitcherAcrossBack, window, cx| {
|
||
let layout = this.switcher_layout(cx);
|
||
this.switcher_step_right(&layout, false, window, cx);
|
||
}))
|
||
.on_key_down(cx.listener(Self::on_switcher_key))
|
||
.on_mouse_down(
|
||
MouseButton::Left,
|
||
cx.listener(|this, _: &MouseDownEvent, window, cx| {
|
||
this.close_switcher(window, cx)
|
||
}),
|
||
)
|
||
.child(div().occlude().child(card))
|
||
.into_any_element(),
|
||
)
|
||
}
|
||
|
||
fn render_list_card(&self, window: &Window, cx: &mut Context<Self>) -> AnyElement {
|
||
let sw = self.switcher.as_ref().expect("only rendered while up");
|
||
let (sel, column) = (sw.left_sel, sw.column);
|
||
let (left_scroll, right_scroll) = (sw.left_scroll.clone(), sw.right_scroll.clone());
|
||
let layout = self.switcher_layout(cx);
|
||
|
||
let theme = cx.theme();
|
||
let (border, card_bg) = (theme.border, theme.popover);
|
||
|
||
let mut list = v_flex().gap(px(1.));
|
||
for (at, &(g, r)) in layout.nav.iter().enumerate() {
|
||
let picked = sel == at && column == Column::Left;
|
||
let group = &layout.groups[g];
|
||
list = list.child(self.render_row(group, &group.rows[r], picked, Some(at), cx));
|
||
}
|
||
if layout.nav.is_empty() {
|
||
list = list.child(
|
||
div()
|
||
.px(px(ROW_PAD))
|
||
.py(px(14.))
|
||
.text_sm()
|
||
.text_color(cx.theme().muted_foreground)
|
||
.child(t(L10nKey::SwitcherNoMatch)),
|
||
);
|
||
}
|
||
// Orphan panes belong to the machine, not to a workspace, so they are
|
||
// not rows and join no navigation — the bottom of the workspace list
|
||
// is simply where a user looking for them finds them (#596). A search
|
||
// narrows the panel to workspaces, and the box steps out of the way
|
||
// for one.
|
||
if !sw.orphans.is_empty() && sw.text(cx).is_empty() {
|
||
list = list.child(self.render_orphan_panes(cx));
|
||
}
|
||
|
||
// Fixed height, not fit-to-content: the tab column changes length every
|
||
// time the left cursor moves, and a card that resizes under the pointer
|
||
// is unusable.
|
||
// The card is fixed-size by design — a panel that resizes under the
|
||
// pointer is unusable — but it still has to fit the window it floats
|
||
// over. Below its natural size it takes what there is.
|
||
let viewport = window.viewport_size();
|
||
let card_w = CARD_W
|
||
.min(viewport.width.as_f32() - 2. * CARD_MARGIN)
|
||
.max(320.);
|
||
let body_h = BODY_H
|
||
.min(viewport.height.as_f32() - CARD_TOP - CARD_CHROME_H - CARD_MARGIN)
|
||
.max(120.);
|
||
let body = div()
|
||
.flex()
|
||
.flex_row()
|
||
.items_stretch()
|
||
.h(px(body_h))
|
||
// Both columns scroll once the lists outrun the card, and neither
|
||
// said so. The border moves out to the column so it stays put
|
||
// while the rows underneath it move.
|
||
.child(
|
||
v_flex()
|
||
.w(px(LEFT_W.min(card_w * 0.5)))
|
||
.flex_shrink_0()
|
||
.border_r_1()
|
||
.border_color(border)
|
||
.child(crate::ui::scrollbar::with_vertical_scrollbar(
|
||
"switcher-workspaces-scrollbar",
|
||
div()
|
||
.id("switcher-workspaces")
|
||
.track_scroll(&left_scroll)
|
||
.size_full()
|
||
.overflow_y_scroll()
|
||
.p(px(6.))
|
||
.child(list),
|
||
&left_scroll,
|
||
)),
|
||
)
|
||
.child(
|
||
v_flex()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.child(crate::ui::scrollbar::with_vertical_scrollbar(
|
||
"switcher-tabs-scrollbar",
|
||
div()
|
||
.id("switcher-tabs")
|
||
.track_scroll(&right_scroll)
|
||
.size_full()
|
||
.overflow_y_scroll()
|
||
.p(px(6.))
|
||
.child(self.render_tabs(&layout, sel, column, cx)),
|
||
&right_scroll,
|
||
)),
|
||
);
|
||
|
||
v_flex()
|
||
.w(px(card_w))
|
||
.bg(card_bg)
|
||
.border_1()
|
||
.border_color(border)
|
||
.rounded(px(10.))
|
||
.shadow_xl()
|
||
.overflow_hidden()
|
||
.child(self.render_search(cx))
|
||
.child(body)
|
||
.children(self.render_banners(&layout, cx))
|
||
.child(self.render_footer(cx))
|
||
.into_any_element()
|
||
}
|
||
|
||
fn render_search(&self, cx: &mut Context<Self>) -> impl IntoElement + use<> {
|
||
let theme = cx.theme();
|
||
let (muted, border) = (theme.muted_foreground, theme.border);
|
||
h_flex()
|
||
.items_center()
|
||
.gap(px(8.))
|
||
.pl(px(6. + ROW_PAD))
|
||
.pr(px(12.))
|
||
.h(px(42.))
|
||
.border_b_1()
|
||
.border_color(border)
|
||
.child(glyph_col(
|
||
GUTTER,
|
||
Icon::new(IconName::Search).size(px(ICON)).text_color(muted),
|
||
))
|
||
.children(
|
||
self.switcher
|
||
.as_ref()
|
||
.map(|sw| Input::new(&sw.query).appearance(false).small().pl_0()),
|
||
)
|
||
}
|
||
|
||
fn render_footer(&self, cx: &mut Context<Self>) -> impl IntoElement + use<> {
|
||
let theme = cx.theme();
|
||
let (muted, dim, border) = (
|
||
theme.muted_foreground,
|
||
theme.muted_foreground.opacity(0.7),
|
||
theme.border,
|
||
);
|
||
let hover = hover_fill(cx);
|
||
let holding = self.switcher.as_ref().is_some_and(|sw| sw.hold.is_some());
|
||
// With a query in the box, ← and → belong to the caret and Tab becomes
|
||
// the way across. That remap is deliberate, and until now it was also
|
||
// silent: arrows simply stopped working with nothing said.
|
||
let filtering = self
|
||
.switcher
|
||
.as_ref()
|
||
.is_some_and(|sw| !sw.text(cx).trim().is_empty());
|
||
h_flex()
|
||
.items_center()
|
||
.justify_between()
|
||
.border_t_1()
|
||
.border_color(border)
|
||
.p(px(6.))
|
||
.child(
|
||
h_flex()
|
||
.id("switcher-new-workspace")
|
||
.items_center()
|
||
.gap(px(8.))
|
||
.h(px(ROW_H))
|
||
.px(px(ROW_PAD))
|
||
.rounded(px(6.))
|
||
.cursor_pointer()
|
||
.hover(move |r| r.bg(hover))
|
||
.text_sm()
|
||
.text_color(muted)
|
||
.child(glyph_col(
|
||
GUTTER,
|
||
Icon::new(IconName::Plus).size(px(ICON)).text_color(dim),
|
||
))
|
||
.child(t(L10nKey::AppMenuNewWorkspace))
|
||
.on_click(cx.listener(|this, _, window, cx| {
|
||
this.switcher_to_form(None, window, cx);
|
||
})),
|
||
)
|
||
.child(
|
||
h_flex()
|
||
.items_center()
|
||
.gap(px(6.))
|
||
.pr(px(ROW_PAD))
|
||
.text_xs()
|
||
.text_color(dim)
|
||
.when(!holding && filtering, |hint| {
|
||
hint.child(t(L10nKey::SwitcherTabToCrossColumns))
|
||
})
|
||
.when(!holding && !filtering, |hint| {
|
||
hint.child(
|
||
div()
|
||
.px(px(5.))
|
||
.py(px(1.))
|
||
.rounded(px(4.))
|
||
.border_1()
|
||
.border_color(border)
|
||
.child(crate::ui::keymap::secondary_glyph()),
|
||
)
|
||
.child(t(L10nKey::ClickForNewWindow))
|
||
})
|
||
.when(holding, |hint| hint.child(t(L10nKey::SwitcherHoldToSwitch))),
|
||
)
|
||
}
|
||
|
||
/// The machine-trouble bands between the list and the footer: install
|
||
/// progress, a failed connect with its retry, a parked route (#485), or a
|
||
/// connect still in flight. With the per-machine headers gone this is
|
||
/// where a machine's state gets to speak; a healthy machine says nothing
|
||
/// here — its state dot rides on its workspace rows.
|
||
fn render_banners(&self, layout: &Layout, cx: &mut Context<Self>) -> Vec<AnyElement> {
|
||
let mut out: Vec<AnyElement> = Vec::new();
|
||
for group in &layout.groups {
|
||
if group.target.is_none() {
|
||
continue;
|
||
}
|
||
if let Some(phase) = group.installing {
|
||
out.push(
|
||
self.render_install_progress(&group.label, phase, cx)
|
||
.into_any_element(),
|
||
);
|
||
} else if group.parked
|
||
&& !self.parked_dismissed.contains(&group.key)
|
||
&& !group.rows.is_empty()
|
||
{
|
||
out.push(self.render_parked_notice(group, cx).into_any_element());
|
||
} else if let Some(error) = group.error.clone() {
|
||
out.push(self.render_error_band(group, &error, cx));
|
||
} else if matches!(group.link, Link::Connecting | Link::Reconnecting { .. }) {
|
||
let theme = cx.theme();
|
||
out.push(
|
||
h_flex()
|
||
.items_center()
|
||
.gap(px(6.))
|
||
.px(px(12.))
|
||
.py(px(6.))
|
||
.border_t_1()
|
||
.border_color(theme.border)
|
||
.child(
|
||
div()
|
||
.flex_shrink_0()
|
||
.size(px(6.))
|
||
.rounded_full()
|
||
.bg(theme.warning),
|
||
)
|
||
.child(
|
||
div()
|
||
.text_xs()
|
||
.text_color(theme.muted_foreground)
|
||
.truncate()
|
||
.child(t_fmt(
|
||
L10nKey::SwitcherConnectingTo,
|
||
&[("machine", &group.label)],
|
||
)),
|
||
)
|
||
.into_any_element(),
|
||
);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn render_error_band(&self, group: &Group, error: &str, cx: &mut Context<Self>) -> AnyElement {
|
||
let retry = GroupRef::of(group);
|
||
let replace = retry.clone();
|
||
let retry_key = group.key.clone();
|
||
let replace_key = group.key.clone();
|
||
let dismiss_key = group.key.clone();
|
||
let dismiss_target = group.target.clone();
|
||
// The band no longer sits under a machine header, so plain errors
|
||
// carry the machine's name themselves; the dialect restatement
|
||
// already names it.
|
||
let shown = remote_connect::dialect_complaint(error, &group.label)
|
||
.unwrap_or_else(|| format!("{}: {error}", group.label));
|
||
let replace_action = crate::ui::remote_workspace::mismatch_action_key(error);
|
||
let theme = cx.theme();
|
||
v_flex()
|
||
.gap(px(4.))
|
||
.px(px(12.))
|
||
.py(px(8.))
|
||
.border_t_1()
|
||
.border_color(theme.danger.opacity(0.4))
|
||
.child(
|
||
div()
|
||
.text_xs()
|
||
.text_color(theme.muted_foreground)
|
||
.child(shown),
|
||
)
|
||
.child(
|
||
h_flex()
|
||
.gap(px(4.))
|
||
.child(
|
||
Button::new(gpui::SharedString::from(format!(
|
||
"switcher-retry:{}",
|
||
group.key
|
||
)))
|
||
.label(t(L10nKey::TryAgain))
|
||
.ghost()
|
||
.xsmall()
|
||
.on_click(cx.listener(
|
||
move |this, _, _window, cx| {
|
||
this.remote_host_errors.remove(&retry_key);
|
||
if let Some(target) = retry.target.clone() {
|
||
this.connect_to_host(
|
||
HostChoice {
|
||
target,
|
||
label: retry.label.clone(),
|
||
detail: String::new(),
|
||
},
|
||
cx,
|
||
);
|
||
}
|
||
},
|
||
)),
|
||
)
|
||
.when(
|
||
crate::daemon::control::is_dialect_refusal(error)
|
||
// Same gate as the workspace strip's: a machine
|
||
// whose server is not ours to install cannot be
|
||
// helped by this button, and a click that can only
|
||
// fail is worse than no button.
|
||
&& replace
|
||
.target
|
||
.as_ref()
|
||
.is_some_and(|t| t.hosts_our_server()),
|
||
|row| {
|
||
row.child(
|
||
Button::new(gpui::SharedString::from(format!(
|
||
"switcher-replace:{}",
|
||
group.key
|
||
)))
|
||
.label(t(replace_action))
|
||
.ghost()
|
||
.xsmall()
|
||
.on_click(cx.listener(
|
||
move |this, _, window, cx| {
|
||
this.remote_host_errors.remove(&replace_key);
|
||
if let Some(target) = replace.target.clone() {
|
||
this.confirm_replace_remote_server(
|
||
target,
|
||
replace.label.clone(),
|
||
replace_action,
|
||
window,
|
||
cx,
|
||
);
|
||
}
|
||
},
|
||
)),
|
||
)
|
||
},
|
||
)
|
||
.child(
|
||
Button::new(gpui::SharedString::from(format!(
|
||
"switcher-dismiss:{}",
|
||
group.key
|
||
)))
|
||
.label(t(L10nKey::Dismiss))
|
||
.ghost()
|
||
.xsmall()
|
||
.on_click(cx.listener(
|
||
move |this, _, _window, cx| {
|
||
this.remote_host_errors.remove(&dismiss_key);
|
||
// The other half of this block can come from a
|
||
// failed connect. Retire that too, but only when
|
||
// it is this host's failure — a connect to
|
||
// anywhere else is still in flight.
|
||
if let Some(ConnectFlow::Failed { choice, .. }) = &this.connect
|
||
&& Some(&choice.target) == dismiss_target.as_ref()
|
||
{
|
||
this.connect = None;
|
||
}
|
||
// Dismissing the refusal is giving up on the
|
||
// machine, and the create parked on it goes
|
||
// with it — an update run weeks later must
|
||
// not revive a workspace nobody remembers
|
||
// asking for.
|
||
if this
|
||
.parked_create
|
||
.as_ref()
|
||
.is_some_and(|p| Some(&p.target) == dismiss_target.as_ref())
|
||
{
|
||
this.parked_create = None;
|
||
}
|
||
cx.notify();
|
||
},
|
||
)),
|
||
),
|
||
)
|
||
.into_any_element()
|
||
}
|
||
|
||
/// The orphan block under the local group (#596): one line per pane no
|
||
/// window holds — id, owner, where it runs — and the way to stop it.
|
||
fn render_orphan_panes(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||
let theme = cx.theme();
|
||
let Some(switcher) = self.switcher.as_ref() else {
|
||
return v_flex();
|
||
};
|
||
// Resolved up front, while `cx` can still be borrowed as an `App`: the
|
||
// render loop below needs it mutably for the Close listener.
|
||
let lines: Vec<(u64, String)> = switcher
|
||
.orphans
|
||
.iter()
|
||
.map(|orphan| {
|
||
let mut bits = vec![format!("%{}", orphan.pane_id)];
|
||
if let Some(owner) = &orphan.owner {
|
||
bits.push(owner_label(owner, workspace_name_of(owner, cx)));
|
||
}
|
||
if let Some(cwd) = &orphan.cwd {
|
||
bits.push(cwd.clone());
|
||
} else if !orphan.title.is_empty() {
|
||
bits.push(orphan.title.clone());
|
||
}
|
||
(orphan.pane_id, bits.join(" · "))
|
||
})
|
||
.collect();
|
||
let mut list = v_flex().gap(px(2.));
|
||
for (pane_id, line) in lines {
|
||
list = list.child(
|
||
h_flex()
|
||
.items_center()
|
||
.justify_between()
|
||
.gap(px(6.))
|
||
// The text takes the slack and gives it back: without
|
||
// `min_w_0` a flex child refuses to shrink below its
|
||
// content, and one long cwd pushed the Close button past
|
||
// the edge of the card — the row named a pane the user
|
||
// then had no way to stop.
|
||
.child(
|
||
div()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.truncate()
|
||
.text_xs()
|
||
.text_color(theme.foreground)
|
||
.child(line),
|
||
)
|
||
.child(
|
||
div().flex_shrink_0().child(
|
||
Button::new(gpui::SharedString::from(format!(
|
||
"switcher-close-orphan:{pane_id}"
|
||
)))
|
||
.label(t(L10nKey::Close))
|
||
.ghost()
|
||
.xsmall()
|
||
.on_click(cx.listener(
|
||
move |this, _, _window, cx| {
|
||
this.close_orphan_pane(pane_id, cx);
|
||
},
|
||
)),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
v_flex()
|
||
.gap(px(4.))
|
||
.mx(px(4.))
|
||
.mt(px(6.))
|
||
.mb(px(2.))
|
||
.px(px(10.))
|
||
.py(px(8.))
|
||
.rounded(px(6.))
|
||
.border_1()
|
||
.border_color(theme.border)
|
||
.child(
|
||
div()
|
||
.text_xs()
|
||
.text_color(theme.muted_foreground)
|
||
.child(t(L10nKey::SwitcherOrphanPanes)),
|
||
)
|
||
.child(list)
|
||
}
|
||
|
||
/// The parked group's notice (#485): no retry button — nothing it could
|
||
/// try would succeed — just the way back (a fresh profile rediscovers
|
||
/// the session) and the two honest actions.
|
||
fn render_parked_notice(&self, group: &Group, cx: &mut Context<Self>) -> impl IntoElement {
|
||
let theme = cx.theme();
|
||
let forget_key = group.key.clone();
|
||
let dismiss_key = group.key.clone();
|
||
let dismiss_target = group.target.clone();
|
||
let entries: Vec<WorkspaceId> = group.rows.iter().map(|r| r.id).collect();
|
||
v_flex()
|
||
.gap(px(4.))
|
||
.px(px(12.))
|
||
.py(px(8.))
|
||
.border_t_1()
|
||
.border_color(theme.border)
|
||
.child(
|
||
div()
|
||
.text_xs()
|
||
.text_color(theme.muted_foreground)
|
||
.child(format!(
|
||
"{} — {}",
|
||
group.label,
|
||
t(L10nKey::RemoteRouteParkedHint)
|
||
)),
|
||
)
|
||
.child(
|
||
h_flex()
|
||
.gap(px(4.))
|
||
.child(
|
||
Button::new(gpui::SharedString::from(format!(
|
||
"switcher-forget:{}",
|
||
group.key
|
||
)))
|
||
.label(t(L10nKey::RemoteActionRemoveEntry))
|
||
.ghost()
|
||
.xsmall()
|
||
.on_click(cx.listener(
|
||
move |this, _, _window, cx| {
|
||
// Forget, never delete: nothing is sent to the
|
||
// machine, so the remote sessions keep running
|
||
// (#485). Adopt rows name no local entry and are
|
||
// skipped by the store.
|
||
for id in &entries {
|
||
crate::ui::windows::forget_workspace(cx, *id);
|
||
}
|
||
this.remote_host_errors.remove(&forget_key);
|
||
this.parked_dismissed.remove(&forget_key);
|
||
cx.notify();
|
||
},
|
||
)),
|
||
)
|
||
.child(
|
||
Button::new(gpui::SharedString::from(format!(
|
||
"switcher-parked-dismiss:{}",
|
||
group.key
|
||
)))
|
||
.label(t(L10nKey::Dismiss))
|
||
.ghost()
|
||
.xsmall()
|
||
.on_click(cx.listener(
|
||
move |this, _, _window, cx| {
|
||
this.parked_dismissed.insert(dismiss_key.clone());
|
||
// A parked group can still carry the deterministic
|
||
// failure that predated the parking — retire it
|
||
// too, or it pops right back as the error block.
|
||
this.remote_host_errors.remove(&dismiss_key);
|
||
if let Some(ConnectFlow::Failed { choice, .. }) = &this.connect
|
||
&& Some(&choice.target) == dismiss_target.as_ref()
|
||
{
|
||
this.connect = None;
|
||
}
|
||
cx.notify();
|
||
},
|
||
)),
|
||
),
|
||
)
|
||
}
|
||
|
||
fn render_install_progress(
|
||
&self,
|
||
label: &str,
|
||
phase: InstallPhase,
|
||
cx: &mut Context<Self>,
|
||
) -> impl IntoElement + use<> {
|
||
let theme = cx.theme();
|
||
let caption = crate::ui::remote_workspace::install_phase_caption(phase);
|
||
|
||
v_flex()
|
||
.gap(px(6.))
|
||
.px(px(12.))
|
||
.py(px(8.))
|
||
.border_t_1()
|
||
.border_color(theme.border)
|
||
.child(
|
||
div()
|
||
.text_xs()
|
||
.text_color(theme.muted_foreground)
|
||
.child(format!("{label} — {caption}")),
|
||
)
|
||
.child(crate::ui::remote_workspace::install_progress_bar(phase, cx))
|
||
}
|
||
fn render_row(
|
||
&self,
|
||
group: &Group,
|
||
row: &Row,
|
||
picked: bool,
|
||
nav_at: Option<usize>,
|
||
cx: &mut Context<Self>,
|
||
) -> AnyElement {
|
||
if let Some(sw) = self.switcher.as_ref()
|
||
&& let Some((id, input)) = sw.renaming.as_ref()
|
||
&& *id == row.id
|
||
{
|
||
return h_flex()
|
||
.id(("switcher-rename", row.id.element_key() as usize))
|
||
.items_center()
|
||
.h(px(ROW_H))
|
||
.px(px(ROW_PAD))
|
||
.rounded(px(6.))
|
||
.bg(hover_fill(cx))
|
||
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
||
.child(Input::new(input).appearance(false).xsmall())
|
||
.into_any_element();
|
||
}
|
||
|
||
let theme = cx.theme();
|
||
let (fg, muted, dim, warn) = (
|
||
theme.foreground,
|
||
theme.muted_foreground,
|
||
theme.muted_foreground.opacity(0.7),
|
||
theme.warning,
|
||
);
|
||
let sf = rungs(cx);
|
||
let hover = gpui::rgb(sf.hover);
|
||
let rref = RowRef::of(group, row);
|
||
let click_ref = rref.clone();
|
||
let menu_ref = rref.clone();
|
||
let ctx_ref = rref.clone();
|
||
let gref = GroupRef::of(group);
|
||
let menu_host = gref.clone();
|
||
let ctx_host = gref;
|
||
let app = cx.entity().downgrade();
|
||
let app2 = app.clone();
|
||
let key = row.id.element_key() as usize;
|
||
let holding = self.switcher.as_ref().is_some_and(|sw| sw.hold.is_some());
|
||
// A machine with no link cannot show this row's panes right now; the
|
||
// row stays, muted, and opening it is what asks for the connection.
|
||
let unlit = group.target.is_some() && matches!(group.link, Link::Offline | Link::Failed);
|
||
|
||
// "Open" is what this workspace would say either way, and it is the
|
||
// wrong word for one another client is driving.
|
||
let badge = if row.preempted {
|
||
Some((t(L10nKey::SwitcherStatusTakenOver), false))
|
||
} else if row.current {
|
||
Some((t(L10nKey::SwitcherThisWindow), true))
|
||
} else if row.open {
|
||
Some((t(L10nKey::SwitcherOpen), false))
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// Two lines rather than one: the left column is only LEFT_W wide, and a
|
||
// workspace name plus path plus badge plus timestamp on one row pushes
|
||
// the trailing pieces straight out over the divider. The second line
|
||
// leads with the machine the workspace lives on — the flat list's only
|
||
// grouping — with its link state as the dot's color.
|
||
let when_path = match row.path.is_empty() {
|
||
true => row.when.clone(),
|
||
false => format!("{} · {}", row.path, row.when),
|
||
};
|
||
let host_dot: Option<gpui::Hsla> = match group.link {
|
||
Link::Local => None,
|
||
Link::Connected if group.preempted => Some(warn),
|
||
Link::Connected => Some(gpui::rgb(crate::ui::tab_strip::LIVE_DOT).into()),
|
||
Link::Connecting | Link::Reconnecting { .. } => Some(warn),
|
||
Link::Failed => Some(theme.danger),
|
||
Link::Offline => Some(gpui::rgb(crate::ui::tab_strip::UNKNOWN_DOT).into()),
|
||
};
|
||
let host_label = match group.target.is_some() {
|
||
true => group.label.clone(),
|
||
false => t(L10nKey::SwitcherLocalHost).to_string(),
|
||
};
|
||
|
||
let line = h_flex()
|
||
.id(("switcher-row", key))
|
||
.group("switcher-row")
|
||
.items_center()
|
||
.gap(px(8.))
|
||
.min_h(px(ROW_H))
|
||
.py(px(4.))
|
||
.px(px(ROW_PAD))
|
||
.rounded(px(6.))
|
||
.overflow_hidden()
|
||
.cursor_pointer()
|
||
.when(picked, |r| r.bg(gpui::rgb(sf.pressed)))
|
||
.anchor_scroll(self.switcher_anchor(Column::Left, picked))
|
||
.hover(move |r| r.bg(hover))
|
||
.child(crate::ui::tab_strip::workspace_avatar(
|
||
&row.name, row.live, ROW_AVATAR, cx,
|
||
))
|
||
.child(
|
||
v_flex()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.gap(px(1.))
|
||
.child(
|
||
div()
|
||
.truncate()
|
||
.text_sm()
|
||
.when(row.current, |d| d.font_weight(gpui::FontWeight::MEDIUM))
|
||
.text_color(match unlit {
|
||
true => muted,
|
||
false => fg,
|
||
})
|
||
.child(row.name.clone()),
|
||
)
|
||
.child(
|
||
h_flex()
|
||
.items_center()
|
||
.gap(px(5.))
|
||
.min_w_0()
|
||
.text_xs()
|
||
.text_color(dim)
|
||
.child(match host_dot {
|
||
Some(color) => div()
|
||
.flex_shrink_0()
|
||
.size(px(6.))
|
||
.rounded_full()
|
||
.bg(color)
|
||
.into_any_element(),
|
||
None => gpui::svg()
|
||
.path("icons/machine-local.svg")
|
||
.flex_shrink_0()
|
||
.size(px(10.))
|
||
.text_color(dim)
|
||
.into_any_element(),
|
||
})
|
||
.child(
|
||
div()
|
||
.flex_shrink_0()
|
||
.max_w(px(120.))
|
||
.truncate()
|
||
.text_color(muted)
|
||
.child(host_label),
|
||
)
|
||
.when(!when_path.is_empty(), |line| {
|
||
line.child(div().flex_shrink_0().child("·"))
|
||
.child(div().min_w_0().truncate().child(when_path))
|
||
}),
|
||
),
|
||
)
|
||
.children(badge.map(|(label, here)| {
|
||
div()
|
||
.flex_shrink_0()
|
||
.px(px(6.))
|
||
.py(px(1.))
|
||
.rounded(px(4.))
|
||
.text_xs()
|
||
.bg(gpui::rgb(sf.selected))
|
||
.text_color(match (row.preempted, here) {
|
||
(true, _) => warn,
|
||
(_, true) => fg.opacity(0.85),
|
||
_ => muted,
|
||
})
|
||
.child(label)
|
||
}))
|
||
.child(
|
||
div()
|
||
.invisible()
|
||
.flex_shrink_0()
|
||
.group_hover("switcher-row", |x| x.visible())
|
||
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
||
.child(
|
||
crate::ui::tab_strip::hit_target(
|
||
Button::new(("switcher-row-more", key))
|
||
.icon(IconName::Ellipsis)
|
||
.ghost()
|
||
.xsmall(),
|
||
)
|
||
.tooltip(t(L10nKey::TabTooltipMore))
|
||
.dropdown_menu(move |menu, _window, _cx| {
|
||
row_menu(menu, &menu_ref, &menu_host, app.clone())
|
||
}),
|
||
),
|
||
)
|
||
.on_click(cx.listener(move |this, ev: &ClickEvent, window, cx| {
|
||
// One click aims the tab column at this workspace; opening it
|
||
// takes a second click, Enter, or the platform modifier.
|
||
if let Some(at) = nav_at {
|
||
this.switcher_point_at(at, cx);
|
||
}
|
||
let modified = ev.modifiers().secondary();
|
||
if ev.click_count() >= 2 || modified {
|
||
this.switcher_open(click_ref.clone(), modified, window, cx);
|
||
}
|
||
}));
|
||
|
||
// A held Ctrl makes every click a right click on macOS. Drop the menu
|
||
// and take the right-button press as the pick instead, or clicking
|
||
// during the gesture does nothing at all.
|
||
match holding {
|
||
true => line
|
||
.on_mouse_down(
|
||
MouseButton::Right,
|
||
cx.listener(move |this, _: &MouseDownEvent, _window, cx| {
|
||
cx.stop_propagation();
|
||
if let Some(at) = nav_at {
|
||
this.switcher_point_at(at, cx);
|
||
}
|
||
}),
|
||
)
|
||
.into_any_element(),
|
||
false => line
|
||
.context_menu(move |menu, _window, _cx| {
|
||
row_menu(menu, &ctx_ref, &ctx_host, app2.clone())
|
||
})
|
||
.into_any_element(),
|
||
}
|
||
}
|
||
|
||
/// The "New Workspace" form: a name, and one combobox row that folds
|
||
/// however many machines are configured into a searchable dropdown.
|
||
fn render_create_card(
|
||
&self,
|
||
form: &CreateForm,
|
||
window: &Window,
|
||
cx: &mut Context<Self>,
|
||
) -> AnyElement {
|
||
let theme = cx.theme();
|
||
let (border, card_bg) = (theme.border, theme.popover);
|
||
let (fg, muted, dim) = (
|
||
theme.foreground,
|
||
theme.muted_foreground,
|
||
theme.muted_foreground.opacity(0.7),
|
||
);
|
||
let sf = rungs(cx);
|
||
let (hover, picked_bg) = (gpui::rgb(sf.hover), gpui::rgb(sf.pressed));
|
||
let viewport = window.viewport_size();
|
||
let card_w = FORM_W
|
||
.min(viewport.width.as_f32() - 2. * CARD_MARGIN)
|
||
.max(320.);
|
||
|
||
let chosen_label = form
|
||
.chosen
|
||
.as_ref()
|
||
.map(|h| h.label.clone())
|
||
.unwrap_or_else(|| t(L10nKey::SwitcherThisComputer).to_string());
|
||
let chosen_local = form.chosen.is_none();
|
||
let chosen_dot: Option<gpui::Hsla> = form.chosen.as_ref().map(|h| {
|
||
match remote_connect::HostLinks::get(cx, h.target.host_id()).is_some() {
|
||
true => gpui::rgb(crate::ui::tab_strip::LIVE_DOT).into(),
|
||
false => gpui::rgb(crate::ui::tab_strip::UNKNOWN_DOT).into(),
|
||
}
|
||
});
|
||
|
||
let header = h_flex()
|
||
.items_center()
|
||
.gap(px(6.))
|
||
.h(px(42.))
|
||
.px(px(8.))
|
||
.border_b_1()
|
||
.border_color(border)
|
||
.child(
|
||
crate::ui::tab_strip::hit_target(
|
||
Button::new("switcher-form-back")
|
||
.icon(IconName::ChevronLeft)
|
||
.ghost()
|
||
.xsmall()
|
||
.on_click(cx.listener(|this, _, window, cx| {
|
||
this.switcher_back_to_list(window, cx);
|
||
})),
|
||
)
|
||
.tooltip(t(L10nKey::SwitcherFormBack)),
|
||
)
|
||
.child(
|
||
div()
|
||
.text_sm()
|
||
.font_weight(gpui::FontWeight::MEDIUM)
|
||
.text_color(fg)
|
||
.child(t(L10nKey::AppMenuNewWorkspace)),
|
||
);
|
||
|
||
let label_col = |text: &'static str| {
|
||
div()
|
||
.w(px(52.))
|
||
.flex_shrink_0()
|
||
.text_sm()
|
||
.text_color(muted)
|
||
.child(text)
|
||
};
|
||
let field = |inner: gpui::Div| {
|
||
inner
|
||
.flex_1()
|
||
.min_w_0()
|
||
.items_center()
|
||
.gap(px(6.))
|
||
.px(px(8.))
|
||
.h(px(30.))
|
||
.rounded(px(6.))
|
||
.border_1()
|
||
.border_color(border)
|
||
};
|
||
|
||
let name_row = h_flex()
|
||
.items_center()
|
||
.gap(px(8.))
|
||
.child(label_col(t(L10nKey::SwitcherFormName)))
|
||
.child(field(h_flex()).child(Input::new(&form.name).appearance(false).small()));
|
||
|
||
// The trigger: the picked host while folded, the filter box while
|
||
// open. One row either way, so the form does not jump.
|
||
let host_glyph = |local: bool, dot: Option<gpui::Hsla>| match dot {
|
||
Some(color) => div()
|
||
.flex_shrink_0()
|
||
.size(px(6.))
|
||
.rounded_full()
|
||
.bg(color)
|
||
.into_any_element(),
|
||
None if local => gpui::svg()
|
||
.path("icons/machine-local.svg")
|
||
.flex_shrink_0()
|
||
.size(px(12.))
|
||
.text_color(muted)
|
||
.into_any_element(),
|
||
None => gpui::svg()
|
||
.path("icons/machine-remote.svg")
|
||
.flex_shrink_0()
|
||
.size(px(12.))
|
||
.text_color(muted)
|
||
.into_any_element(),
|
||
};
|
||
let trigger: AnyElement = match form.open {
|
||
false => field(h_flex())
|
||
.id("switcher-form-host")
|
||
.cursor_pointer()
|
||
.hover(move |r| r.bg(hover))
|
||
.child(host_glyph(chosen_local, chosen_dot))
|
||
.child(
|
||
div()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.truncate()
|
||
.text_sm()
|
||
.text_color(fg)
|
||
.child(chosen_label),
|
||
)
|
||
.child(
|
||
Icon::new(IconName::ChevronDown)
|
||
.size(px(ICON))
|
||
.text_color(dim),
|
||
)
|
||
.on_click(cx.listener(|this, _, window, cx| {
|
||
this.switcher_form_open_hosts(window, cx);
|
||
}))
|
||
.into_any_element(),
|
||
true => field(h_flex())
|
||
.child(Icon::new(IconName::Search).size(px(14.)).text_color(muted))
|
||
.child(Input::new(&form.host).appearance(false).small())
|
||
.into_any_element(),
|
||
};
|
||
|
||
let mut host_block = v_flex().flex_1().min_w_0().gap(px(4.)).child(trigger);
|
||
if form.open {
|
||
let items = self.form_items(form, cx);
|
||
let mut list = v_flex().gap(px(1.)).p(px(4.));
|
||
for (i, item) in items.iter().enumerate() {
|
||
let picked = i == form.sel;
|
||
let base = h_flex()
|
||
.id(("switcher-form-item", i))
|
||
.items_center()
|
||
.gap(px(8.))
|
||
.min_h(px(ROW_H))
|
||
.py(px(4.))
|
||
.px(px(ROW_PAD))
|
||
.rounded(px(6.))
|
||
.overflow_hidden()
|
||
.cursor_pointer()
|
||
.when(picked, |r| r.bg(picked_bg))
|
||
.hover(move |r| r.bg(hover))
|
||
.on_click(cx.listener(move |this, _, window, cx| {
|
||
this.switcher_form_pick(i, window, cx);
|
||
}));
|
||
let line = match item {
|
||
HostItem::Local => base
|
||
.child(host_glyph(true, None))
|
||
.child(
|
||
div()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.truncate()
|
||
.text_sm()
|
||
.text_color(fg)
|
||
.child(t(L10nKey::SwitcherThisComputer)),
|
||
)
|
||
.into_any_element(),
|
||
HostItem::Host(host) => {
|
||
let lit =
|
||
remote_connect::HostLinks::get(cx, host.target.host_id()).is_some();
|
||
base.child(host_glyph(
|
||
false,
|
||
Some(match lit {
|
||
true => gpui::rgb(crate::ui::tab_strip::LIVE_DOT).into(),
|
||
false => gpui::rgb(crate::ui::tab_strip::UNKNOWN_DOT).into(),
|
||
}),
|
||
))
|
||
.child(
|
||
div()
|
||
.flex_shrink_0()
|
||
.max_w(px(180.))
|
||
.truncate()
|
||
.text_sm()
|
||
.text_color(fg)
|
||
.child(host.label.clone()),
|
||
)
|
||
.child(
|
||
div()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.truncate()
|
||
.text_xs()
|
||
.text_color(dim)
|
||
.child(host.detail.clone()),
|
||
)
|
||
.into_any_element()
|
||
}
|
||
HostItem::AddHost => base
|
||
.border_t_1()
|
||
.border_color(border)
|
||
.rounded_none()
|
||
.child(Icon::new(IconName::Plus).size(px(14.)).text_color(dim))
|
||
.child(
|
||
div()
|
||
.text_sm()
|
||
.text_color(muted)
|
||
.child(t(L10nKey::AddSshHost)),
|
||
)
|
||
.into_any_element(),
|
||
};
|
||
list = list.child(line);
|
||
}
|
||
host_block = host_block.child(
|
||
div().rounded(px(6.)).border_1().border_color(border).child(
|
||
div()
|
||
.id("switcher-form-hosts")
|
||
.max_h(px(FORM_LIST_H))
|
||
.overflow_y_scroll()
|
||
.child(list),
|
||
),
|
||
);
|
||
}
|
||
|
||
let host_row = h_flex()
|
||
.items_start()
|
||
.gap(px(8.))
|
||
.child(
|
||
label_col(t(L10nKey::SwitcherFormHost))
|
||
.h(px(30.))
|
||
.flex()
|
||
.items_center(),
|
||
)
|
||
.child(host_block);
|
||
|
||
let footer = h_flex()
|
||
.items_center()
|
||
.px(px(12.))
|
||
.py(px(8.))
|
||
.border_t_1()
|
||
.border_color(border)
|
||
.text_xs()
|
||
.text_color(dim)
|
||
.child(match form.open {
|
||
true => t(L10nKey::SwitcherFormPickHint),
|
||
false => t(L10nKey::SwitcherFormCreateHint),
|
||
});
|
||
|
||
v_flex()
|
||
.w(px(card_w))
|
||
.bg(card_bg)
|
||
.border_1()
|
||
.border_color(border)
|
||
.rounded(px(10.))
|
||
.shadow_xl()
|
||
.overflow_hidden()
|
||
.child(header)
|
||
.child(
|
||
v_flex()
|
||
.p(px(12.))
|
||
.gap(px(10.))
|
||
.child(name_row)
|
||
.child(host_row),
|
||
)
|
||
.child(footer)
|
||
.into_any_element()
|
||
}
|
||
|
||
/// The right-hand column: the tabs of whichever workspace the left column
|
||
/// is sitting on.
|
||
fn render_tabs(
|
||
&self,
|
||
layout: &Layout,
|
||
sel: usize,
|
||
column: Column,
|
||
cx: &mut Context<Self>,
|
||
) -> AnyElement {
|
||
let theme = cx.theme();
|
||
let (fg, muted, dim) = (
|
||
theme.foreground,
|
||
theme.muted_foreground,
|
||
theme.muted_foreground.opacity(0.7),
|
||
);
|
||
let note = |text: String| {
|
||
div()
|
||
.px(px(ROW_PAD))
|
||
.py(px(14.))
|
||
.text_sm()
|
||
.text_color(muted)
|
||
.child(text)
|
||
.into_any_element()
|
||
};
|
||
|
||
let Some(row) = layout.subject_row(sel) else {
|
||
return note(t(L10nKey::SwitcherPickAWorkspace).to_string());
|
||
};
|
||
if row.tabs.is_empty() {
|
||
// A remote workspace this client has never opened has no tab
|
||
// mirror to read — "no tabs" would be a claim nobody checked.
|
||
let unseen = row.adopt.is_some() || (row.remote_id.is_some() && !row.open);
|
||
return note(match unseen {
|
||
true => t(L10nKey::SwitcherTabsAfterOpening).to_string(),
|
||
false => t(L10nKey::SwitcherNoTabs).to_string(),
|
||
});
|
||
}
|
||
|
||
let query = self
|
||
.switcher
|
||
.as_ref()
|
||
.map(|sw| sw.text(cx))
|
||
.unwrap_or_default();
|
||
let hits = visible_tabs(row, &query);
|
||
if hits.is_empty() {
|
||
return note(t(L10nKey::SwitcherNoTabMatch).to_string());
|
||
}
|
||
|
||
let sf = rungs(cx);
|
||
let (hover, picked_bg) = (gpui::rgb(sf.hover), gpui::rgb(sf.pressed));
|
||
let right_sel = self.switcher.as_ref().map(|sw| sw.right_sel).unwrap_or(0);
|
||
let holding = self.switcher.as_ref().is_some_and(|sw| sw.hold.is_some());
|
||
let ws = row.id;
|
||
|
||
let mut list = v_flex().gap(px(1.)).child(
|
||
h_flex()
|
||
.items_center()
|
||
.gap(px(6.))
|
||
.h(px(HOST_H))
|
||
.px(px(ROW_PAD))
|
||
.child(
|
||
div()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.truncate()
|
||
.text_xs()
|
||
.font_weight(gpui::FontWeight::MEDIUM)
|
||
.text_color(muted)
|
||
.child(row.name.clone()),
|
||
)
|
||
.child(div().text_xs().text_color(dim).child(match row.tabs.len() {
|
||
1 => t(L10nKey::SwitcherTabCountOne).to_string(),
|
||
n => t_fmt(L10nKey::SwitcherTabCount, &[("n", &n.to_string())]),
|
||
})),
|
||
);
|
||
|
||
for (nth, i) in hits.iter().enumerate() {
|
||
let tab = &row.tabs[*i];
|
||
let picked = nth == right_sel && column == Column::Right;
|
||
let (id, index) = (tab.id, tab.index);
|
||
// The second line is what tells two tabs on the same repo apart —
|
||
// the branch, then the diff counts, mirroring the tab sidebar.
|
||
let under = tab.git.as_ref().map(|g| {
|
||
h_flex()
|
||
.items_center()
|
||
.gap(px(5.))
|
||
.text_xs()
|
||
.text_color(dim)
|
||
.child(
|
||
gpui::svg()
|
||
.path("icons/git-branch.svg")
|
||
.flex_shrink_0()
|
||
.size(px(11.))
|
||
.text_color(dim),
|
||
)
|
||
.child(div().min_w_0().truncate().child(g.branch.clone()))
|
||
.when(g.added > 0, |c| {
|
||
c.child(
|
||
div()
|
||
.flex_shrink_0()
|
||
.text_color(theme.success)
|
||
.child(format!("+{}", g.added)),
|
||
)
|
||
})
|
||
.when(g.removed > 0, |c| {
|
||
c.child(
|
||
div()
|
||
.flex_shrink_0()
|
||
.text_color(theme.danger)
|
||
.child(format!("−{}", g.removed)),
|
||
)
|
||
})
|
||
});
|
||
let subtitle = match under {
|
||
Some(line) => Some(line.into_any_element()),
|
||
None if tab.named && !tab.path.is_empty() => Some(
|
||
div()
|
||
.text_xs()
|
||
.truncate()
|
||
.text_color(dim)
|
||
.child(tab.path.clone())
|
||
.into_any_element(),
|
||
),
|
||
None => None,
|
||
};
|
||
|
||
list = list.child(
|
||
h_flex()
|
||
.id(("switcher-tab", index))
|
||
.items_center()
|
||
.gap(px(8.))
|
||
.min_h(px(ROW_H))
|
||
.py(px(4.))
|
||
.px(px(ROW_PAD))
|
||
.rounded(px(6.))
|
||
.overflow_hidden()
|
||
.cursor_pointer()
|
||
.when(picked, |r| r.bg(picked_bg))
|
||
.anchor_scroll(self.switcher_anchor(Column::Right, picked))
|
||
.hover(move |r| r.bg(hover))
|
||
.child(self.tab_avatar(
|
||
("switcher-avatar", index),
|
||
tab.agent,
|
||
tab.status,
|
||
tab.unread,
|
||
tab.ssh,
|
||
ROW_AVATAR,
|
||
cx,
|
||
))
|
||
.child(
|
||
v_flex()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.gap(px(1.))
|
||
.child(
|
||
div()
|
||
.truncate()
|
||
.text_sm()
|
||
.when(tab.active, |d| d.font_weight(gpui::FontWeight::MEDIUM))
|
||
.text_color(fg)
|
||
.child(tab.label.clone()),
|
||
)
|
||
.children(subtitle),
|
||
)
|
||
.when(tab.active, |r| {
|
||
r.child(
|
||
div()
|
||
.flex_shrink_0()
|
||
.px(px(6.))
|
||
.py(px(1.))
|
||
.rounded(px(4.))
|
||
.text_xs()
|
||
.bg(gpui::rgb(sf.selected))
|
||
.text_color(muted)
|
||
.child(t(L10nKey::SwitcherActiveTab)),
|
||
)
|
||
})
|
||
.on_click(cx.listener(move |this, ev: &ClickEvent, window, cx| {
|
||
this.switcher_open_tab(
|
||
ws,
|
||
id,
|
||
index,
|
||
ev.modifiers().secondary(),
|
||
window,
|
||
cx,
|
||
)
|
||
}))
|
||
// Mid-gesture a click arrives as a right press on macOS. It
|
||
// aims the cursor; releasing Ctrl is what commits.
|
||
.when(holding, |line| {
|
||
line.on_mouse_down(
|
||
MouseButton::Right,
|
||
cx.listener(move |this, _: &MouseDownEvent, _window, cx| {
|
||
cx.stop_propagation();
|
||
this.switcher_point_tab(nth, cx);
|
||
}),
|
||
)
|
||
}),
|
||
);
|
||
}
|
||
list.into_any_element()
|
||
}
|
||
}
|
||
|
||
/// Tab indices of `row` the search leaves visible. A workspace matched by its
|
||
/// own name keeps all of them — the search told you which workspace, not which
|
||
/// tab.
|
||
fn visible_tabs(row: &Row, query: &str) -> Vec<usize> {
|
||
if query.is_empty()
|
||
|| row.name.to_lowercase().contains(query)
|
||
|| row.path.to_lowercase().contains(query)
|
||
{
|
||
return (0..row.tabs.len()).collect();
|
||
}
|
||
let hits: Vec<usize> = row
|
||
.tabs
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, t)| t.matches(query))
|
||
.map(|(i, _)| i)
|
||
.collect();
|
||
match hits.is_empty() {
|
||
// The host name matched, so the workspace is on screen with nothing of
|
||
// its own to narrow by. Show the lot rather than an empty column.
|
||
true => (0..row.tabs.len()).collect(),
|
||
false => hits,
|
||
}
|
||
}
|
||
|
||
impl Row {
|
||
/// A workspace stays in the list when its own name or path matches, and
|
||
/// also when any of its tabs does — searching "claude" should surface the
|
||
/// workspaces running one.
|
||
fn matches(&self, query: &str) -> bool {
|
||
self.name.to_lowercase().contains(query)
|
||
|| self.path.to_lowercase().contains(query)
|
||
|| self.tabs.iter().any(|t| t.matches(query))
|
||
}
|
||
}
|
||
|
||
impl TabRow {
|
||
fn matches(&self, query: &str) -> bool {
|
||
self.label.to_lowercase().contains(query) || self.path.to_lowercase().contains(query)
|
||
}
|
||
}
|
||
|
||
/// Names a tab of a workspace this window does not own, matching what
|
||
/// `Tty7App::tab_label` shows for local ones.
|
||
///
|
||
/// The two read different sources and have to be talked into agreeing. A local
|
||
/// tab is named by its live terminal's OSC title, which shells set to the
|
||
/// working directory and agents overwrite with what they are doing. The tree
|
||
/// carries a copy of that title (`PaneRecord::osc_title`), which is what makes
|
||
/// the two columns agree; `PaneRecord::title` is the *foreground process name*
|
||
/// ("zsh") and only stands in when there is no title at all.
|
||
fn tab_view_label(
|
||
view: &crate::ui::machine_mirror::TabView,
|
||
index: usize,
|
||
home: Option<&std::path::Path>,
|
||
) -> String {
|
||
let unnamed = || {
|
||
t_fmt(
|
||
L10nKey::TabUnnamedShell,
|
||
&[("n", &((index + 1).to_string()))],
|
||
)
|
||
};
|
||
// A path can shorten away to nothing (a bare "user@host:"), and the process
|
||
// name is still worth more than a number.
|
||
let shortened = |raw: &str| match crate::ui::tab_strip::short_title(raw, home) {
|
||
shortened if !shortened.trim().is_empty() => shortened,
|
||
_ => match view.title.trim() {
|
||
"" => unnamed(),
|
||
title => title.to_string(),
|
||
},
|
||
};
|
||
match view.label() {
|
||
crate::ui::machine_mirror::TabLabel::Named(name) => name.to_string(),
|
||
// Through `short_title` because the local strip puts its own titles
|
||
// through it too: the shell integration writes `user@host:~/dir`, and a
|
||
// tab that spelled that out in full where the strip says "…/dir" would
|
||
// be the same disagreement in a new place.
|
||
crate::ui::machine_mirror::TabLabel::Osc(title) => shortened(title),
|
||
crate::ui::machine_mirror::TabLabel::Agent(agent) => agent.display_name().to_string(),
|
||
crate::ui::machine_mirror::TabLabel::Cwd(cwd) => shortened(cwd),
|
||
crate::ui::machine_mirror::TabLabel::Process(title) => title.to_string(),
|
||
crate::ui::machine_mirror::TabLabel::Unknown => unnamed(),
|
||
}
|
||
}
|
||
|
||
impl Group {
|
||
fn merge(&mut self, remote: &[RemoteWorkspaceRow], now: u64) {
|
||
if self.target.is_none() {
|
||
return;
|
||
}
|
||
let known: HashSet<WorkspaceId> = self.rows.iter().filter_map(|r| r.remote_id).collect();
|
||
for r in remote {
|
||
if known.contains(&r.id) {
|
||
continue;
|
||
}
|
||
self.rows.push(Row {
|
||
id: r.id,
|
||
name: r.name.clone(),
|
||
path: String::new(),
|
||
when: crate::ui::home::relative_time(now, r.last_active),
|
||
last_active: r.last_active,
|
||
live: Liveness::Stopped,
|
||
open: false,
|
||
current: false,
|
||
preempted: false,
|
||
adopt: Some(Box::new(r.clone())),
|
||
remote_id: Some(r.id),
|
||
// A workspace this client has never adopted has no local id to
|
||
// hang a machine-tree lookup on. The tab column says so.
|
||
tabs: Vec::new(),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
struct GroupRef {
|
||
label: String,
|
||
target: Option<RemoteTarget>,
|
||
link: Link,
|
||
}
|
||
|
||
impl GroupRef {
|
||
fn of(g: &Group) -> Self {
|
||
Self {
|
||
label: g.label.clone(),
|
||
target: g.target.clone(),
|
||
link: g.link,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
struct RowRef {
|
||
id: WorkspaceId,
|
||
live: bool,
|
||
adopt: Option<(RemoteTarget, Box<RemoteWorkspaceRow>)>,
|
||
}
|
||
|
||
impl RowRef {
|
||
fn of(group: &Group, row: &Row) -> Self {
|
||
Self {
|
||
id: row.id,
|
||
live: row.live == Liveness::Alive,
|
||
adopt: match (&group.target, &row.adopt) {
|
||
(Some(t), Some(r)) => Some((t.clone(), r.clone())),
|
||
_ => None,
|
||
},
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What a host row offering the form says, or `None` for a machine that has no
|
||
/// SSH host behind it at all — WSL and the local stdio server are configured
|
||
/// nowhere this form could edit.
|
||
///
|
||
/// Shared with the tab menu, which offers the same row for the connection a tab
|
||
/// is on (#438), so the two surfaces cannot drift on which machines are
|
||
/// editable or on what the row is called.
|
||
pub(crate) fn host_form_label(target: &RemoteTarget) -> Option<&'static str> {
|
||
match target {
|
||
RemoteTarget::Profile { .. } => Some(t(L10nKey::SwitcherEditHost)),
|
||
RemoteTarget::Alias { .. } | RemoteTarget::Direct { .. } => {
|
||
Some(t(L10nKey::SwitcherSaveAsHost))
|
||
}
|
||
RemoteTarget::Wsl { .. } | RemoteTarget::LocalStdio { .. } => None,
|
||
}
|
||
}
|
||
|
||
fn row_menu(
|
||
menu: gpui_component::menu::PopupMenu,
|
||
row: &RowRef,
|
||
host: &GroupRef,
|
||
app: gpui::WeakEntity<Tty7App>,
|
||
) -> gpui_component::menu::PopupMenu {
|
||
let (a1, a2, a3, a4) = (app.clone(), app.clone(), app.clone(), app.clone());
|
||
let (id, adopt) = (row.id, row.adopt.is_some());
|
||
let stoppable = row.live;
|
||
// Every workspace verb addresses the row by its local id, and a remote
|
||
// this client has never adopted has none yet. That greyed them all out at
|
||
// once, which reads as broken rather than as not-yet. Say what would make
|
||
// them work, the way the tab pane already says it for the same rows.
|
||
let menu = match adopt {
|
||
true => menu.item(PopupMenuItem::label(t(L10nKey::SwitcherOpenToManage))),
|
||
false => menu
|
||
.item(
|
||
PopupMenuItem::new(t(L10nKey::SwitcherRename)).on_click(move |_, window, cx| {
|
||
let _ = a1.update(cx, |this, cx| this.switcher_rename(id, window, cx));
|
||
}),
|
||
)
|
||
.item(
|
||
PopupMenuItem::new(t(L10nKey::SwitcherOpenInNewWindow)).on_click(
|
||
move |_, window, cx| {
|
||
let _ = a2.update(cx, |this, cx| {
|
||
this.close_switcher(window, cx);
|
||
crate::ui::windows::open(cx, Some(id));
|
||
});
|
||
},
|
||
),
|
||
)
|
||
.separator()
|
||
.item(
|
||
PopupMenuItem::new(t(L10nKey::AppMenuStopWorkspace))
|
||
.disabled(!stoppable)
|
||
.on_click(move |_, window, cx| {
|
||
let _ = a3.update(cx, |this, cx| {
|
||
this.close_switcher(window, cx);
|
||
this.stop_workspace(id, window, cx);
|
||
});
|
||
}),
|
||
)
|
||
.item(
|
||
PopupMenuItem::new(t(L10nKey::AppMenuDeleteWorkspace)).on_click(
|
||
move |_, window, cx| {
|
||
let _ = a4.update(cx, |this, cx| {
|
||
this.close_switcher(window, cx);
|
||
this.delete_workspace(id, window, cx);
|
||
});
|
||
},
|
||
),
|
||
),
|
||
};
|
||
host_menu(menu, host, app)
|
||
}
|
||
|
||
/// The machine verbs that used to live on the group headers, now appended
|
||
/// under the machine's own name to every one of its rows. Local rows carry
|
||
/// none — the footer's New Workspace covers this computer.
|
||
fn host_menu(
|
||
menu: gpui_component::menu::PopupMenu,
|
||
host: &GroupRef,
|
||
app: gpui::WeakEntity<Tty7App>,
|
||
) -> gpui_component::menu::PopupMenu {
|
||
let Some(target) = host.target.clone() else {
|
||
return menu;
|
||
};
|
||
let (a1, a2, a3, a4) = (app.clone(), app.clone(), app.clone(), app);
|
||
let menu = menu
|
||
.separator()
|
||
.item(PopupMenuItem::label(host.label.clone()));
|
||
let engaged = link_is_engaged(host.link);
|
||
let create_choice = HostChoice {
|
||
target: target.clone(),
|
||
label: host.label.clone(),
|
||
detail: String::new(),
|
||
};
|
||
let menu = menu.item(
|
||
PopupMenuItem::new(t(L10nKey::AppMenuNewWorkspace)).on_click(move |_, window, cx| {
|
||
let _ = a1.update(cx, |this, cx| {
|
||
this.switcher_to_form(Some(create_choice.clone()), window, cx);
|
||
});
|
||
}),
|
||
);
|
||
// The host as it is configured, reachable from the one place it is on
|
||
// screen. A machine dialled by address has no profile yet, so the same
|
||
// row offers to make one instead (#438).
|
||
let menu = match host_form_label(&target) {
|
||
Some(label) => {
|
||
let for_edit = target.clone();
|
||
menu.item(PopupMenuItem::new(label).on_click(move |_, window, cx| {
|
||
let for_edit = for_edit.clone();
|
||
let _ = a4.update(cx, |this, cx| {
|
||
this.close_switcher(window, cx);
|
||
this.edit_ssh_host_of_target(&for_edit, window, cx);
|
||
});
|
||
}))
|
||
}
|
||
None => menu,
|
||
};
|
||
let menu = match engaged {
|
||
true => {
|
||
let for_disconnect = target.clone();
|
||
menu.item(PopupMenuItem::new(t(L10nKey::SwitcherDisconnect)).on_click(
|
||
move |_, _window, cx| {
|
||
let _ = a2.update(cx, |this, cx| this.switcher_disconnect(&for_disconnect, cx));
|
||
},
|
||
))
|
||
}
|
||
false => {
|
||
let connect_choice = HostChoice {
|
||
target: target.clone(),
|
||
label: host.label.clone(),
|
||
detail: String::new(),
|
||
};
|
||
menu.item(
|
||
PopupMenuItem::new(t(L10nKey::Connect))
|
||
.disabled(matches!(host.link, Link::Connecting))
|
||
.on_click(move |_, _window, cx| {
|
||
let _ = a2.update(cx, |this, cx| {
|
||
this.connect_to_host(connect_choice.clone(), cx)
|
||
});
|
||
}),
|
||
)
|
||
}
|
||
};
|
||
if !target.hosts_our_server() {
|
||
return menu;
|
||
}
|
||
let (label, for_restart) = (host.label.clone(), target);
|
||
menu.item(
|
||
PopupMenuItem::new(t(L10nKey::AppMenuRestartServer)).on_click(move |_, window, cx| {
|
||
let _ = a3.update(cx, |this, cx| {
|
||
this.confirm_restart_remote_server(for_restart.clone(), label.clone(), window, cx);
|
||
});
|
||
}),
|
||
)
|
||
}
|
||
|
||
fn rungs(cx: &App) -> crate::ui::presets::Surface {
|
||
cx.global::<crate::ui::presets::Surfaces>().popover
|
||
}
|
||
|
||
fn hover_fill(cx: &App) -> gpui::Rgba {
|
||
gpui::rgb(rungs(cx).hover)
|
||
}
|
||
|
||
/// What the panel wants to do with a keystroke, before any of the state that
|
||
/// only the panel knows (which column has the cursor, whether the search box
|
||
/// has text) gets a say.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum Key {
|
||
Close,
|
||
Step(bool),
|
||
ToColumn(Column),
|
||
Tab(bool),
|
||
Confirm(bool),
|
||
Pass,
|
||
}
|
||
|
||
fn key_intent(key: &str, mods: gpui::Modifiers) -> Key {
|
||
if key == "escape" {
|
||
return Key::Close;
|
||
}
|
||
// Alt and Fn belong to whoever else wants them, whatever the key.
|
||
if mods.alt || mods.function {
|
||
return Key::Pass;
|
||
}
|
||
// Careful: `control` cannot be lumped in with "some modifier is down", as
|
||
// it *is* the secondary modifier off macOS — Ctrl+Enter has to reach the
|
||
// new-window branch there, not fall through as a stray chord.
|
||
let bare = !mods.control && !mods.secondary();
|
||
match key {
|
||
"up" | "down" if bare => Key::Step(key == "down"),
|
||
"left" if bare => Key::ToColumn(Column::Left),
|
||
"right" if bare => Key::ToColumn(Column::Right),
|
||
// Tab keeps working with Ctrl held — that is the Ctrl+Tab gesture still
|
||
// in progress. Off macOS that chord arrives as the NextTab action
|
||
// instead, which lands in the same place.
|
||
"tab" if !mods.secondary() => Key::Tab(!mods.shift),
|
||
"enter" if bare || mods.secondary() => Key::Confirm(mods.secondary()),
|
||
_ => Key::Pass,
|
||
}
|
||
}
|
||
|
||
/// Wraps a cursor around a list of `n` items.
|
||
fn step(at: usize, n: usize, forward: bool) -> usize {
|
||
match forward {
|
||
true => (at + 1) % n,
|
||
false => (at + n - 1) % n,
|
||
}
|
||
}
|
||
|
||
fn glyph_col(w: f32, child: impl IntoElement) -> impl IntoElement {
|
||
div()
|
||
.w(px(w))
|
||
.flex_shrink_0()
|
||
.flex()
|
||
.items_center()
|
||
.justify_center()
|
||
.child(child)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// #485 on the path #645 did not cover. A machine the switcher knows only
|
||
/// from a listing snapshot has no store entry to name it, so its group
|
||
/// used to be labelled by the target's own spelling — and a `Profile`
|
||
/// target spells itself as its config UUID. Delete the profile and every
|
||
/// banner under the list announced a raw UUID.
|
||
#[gpui::test]
|
||
fn a_pending_machine_whose_profile_is_gone_is_not_named_by_its_uuid(
|
||
cx: &mut gpui::TestAppContext,
|
||
) {
|
||
use crate::core::session::RemoteTarget;
|
||
|
||
let (app, _vcx) = crate::ui::app::test_window::harness(cx);
|
||
|
||
// A profile id that is in no config: the state left behind when the
|
||
// profile a machine was reached through is deleted.
|
||
let id = uuid::Uuid::new_v4();
|
||
let target = RemoteTarget::Profile { id };
|
||
|
||
app.update(cx, |app, _| {
|
||
app.host_snapshots.insert(
|
||
target.host_id(),
|
||
super::HostSnapshot {
|
||
target: target.clone(),
|
||
rows: Vec::new(),
|
||
},
|
||
);
|
||
});
|
||
|
||
app.update(cx, |app, cx| {
|
||
let groups = app.switcher_groups(cx);
|
||
let group = groups
|
||
.iter()
|
||
.find(|g| g.target.as_ref() == Some(&target))
|
||
.expect("the snapshot puts its machine in the list");
|
||
assert!(
|
||
!group.label.contains(&id.to_string()),
|
||
"the switcher named a machine by its raw profile UUID: {}",
|
||
group.label
|
||
);
|
||
assert_eq!(
|
||
group.label,
|
||
t(L10nKey::RemoteProfileGone),
|
||
"a gone profile is named here the way a pane's route names it"
|
||
);
|
||
});
|
||
}
|
||
|
||
/// A wrong hostname or a stale password used to be fixable only by
|
||
/// finding the same machine again in Settings (#438). The machine is on
|
||
/// screen here, so its host row is too — worded for what the row can
|
||
/// actually do, since a machine reached by address has no profile to open.
|
||
#[test]
|
||
fn every_ssh_machine_offers_its_host_form_and_nothing_else_does() {
|
||
crate::ui::i18n::set_locale("en");
|
||
assert_eq!(
|
||
host_form_label(&RemoteTarget::Profile {
|
||
id: uuid::Uuid::new_v4()
|
||
}),
|
||
Some("Edit Host…")
|
||
);
|
||
assert_eq!(
|
||
host_form_label(&RemoteTarget::Alias {
|
||
alias: "prod".into()
|
||
}),
|
||
Some("Save as SSH Host…"),
|
||
"an alias lives in ~/.ssh/config, which this form does not write"
|
||
);
|
||
assert_eq!(
|
||
host_form_label(&RemoteTarget::direct("me", "10.0.0.5", 22)),
|
||
Some("Save as SSH Host…")
|
||
);
|
||
assert_eq!(
|
||
host_form_label(&RemoteTarget::Wsl {
|
||
distro: "Ubuntu".into()
|
||
}),
|
||
None,
|
||
"a WSL distro is configured nowhere this form could reach"
|
||
);
|
||
assert_eq!(
|
||
host_form_label(&RemoteTarget::LocalStdio {
|
||
program: "tty7-server".into(),
|
||
args: Vec::new()
|
||
}),
|
||
None
|
||
);
|
||
}
|
||
|
||
/// The switcher used to read the `HostLinks` table and nothing else, which
|
||
/// cannot tell "being retried right now" from "never heard of". The group
|
||
/// then collapsed mid-reconnect as if the machine had no workspaces.
|
||
#[test]
|
||
fn a_machine_being_retried_does_not_read_as_one_nobody_ever_connected_to() {
|
||
let target = RemoteTarget::direct("me", "build-box", 22);
|
||
let retrying = MachineStatus::Reconnecting {
|
||
attempt: 2,
|
||
last_error: Some("connection refused".into()),
|
||
};
|
||
|
||
assert_eq!(
|
||
link_from(None, &target, Some(&retrying), false),
|
||
Link::Reconnecting { attempt: 2 }
|
||
);
|
||
assert_eq!(link_from(None, &target, None, false), Link::Offline);
|
||
assert_eq!(
|
||
link_from(None, &target, Some(&MachineStatus::Attached), false),
|
||
Link::Connected
|
||
);
|
||
assert_eq!(
|
||
link_from(
|
||
None,
|
||
&target,
|
||
Some(&MachineStatus::Failed("no route to host".into())),
|
||
false
|
||
),
|
||
Link::Failed,
|
||
"a route the supervisor could not build has to reach the panel too"
|
||
);
|
||
// A link this window knows nothing about is still a link.
|
||
assert_eq!(link_from(None, &target, None, true), Link::Connected);
|
||
}
|
||
|
||
#[test]
|
||
fn this_windows_own_attempt_is_only_ever_about_its_own_machine() {
|
||
let build = RemoteTarget::direct("me", "build-box", 22);
|
||
let gpu = RemoteTarget::direct("me", "gpu-lab", 22);
|
||
let flow = ConnectFlow::Connecting {
|
||
choice: HostChoice {
|
||
target: gpu,
|
||
label: "gpu-lab".into(),
|
||
detail: String::new(),
|
||
},
|
||
};
|
||
|
||
assert_eq!(
|
||
link_from(Some(&flow), &build, Some(&MachineStatus::Attached), true),
|
||
Link::Connected,
|
||
"a connect to the GPU box says nothing about the build box"
|
||
);
|
||
}
|
||
|
||
fn tab(label: &str, path: &str) -> TabRow {
|
||
TabRow {
|
||
id: TabId::new(),
|
||
index: 0,
|
||
label: label.to_string(),
|
||
path: path.to_string(),
|
||
named: false,
|
||
agent: None,
|
||
status: None,
|
||
unread: 0,
|
||
ssh: None,
|
||
active: false,
|
||
git: None,
|
||
}
|
||
}
|
||
|
||
fn row(name: &str, tabs: Vec<TabRow>) -> Row {
|
||
Row {
|
||
id: WorkspaceId::new(),
|
||
name: name.to_string(),
|
||
path: "~/code".to_string(),
|
||
when: String::new(),
|
||
last_active: 0,
|
||
live: Liveness::Alive,
|
||
open: true,
|
||
current: false,
|
||
preempted: false,
|
||
adopt: None,
|
||
remote_id: None,
|
||
tabs,
|
||
}
|
||
}
|
||
|
||
fn aged(mut r: Row, last_active: u64) -> Row {
|
||
r.last_active = last_active;
|
||
r
|
||
}
|
||
|
||
fn group(rows: Vec<Row>) -> Group {
|
||
Group {
|
||
key: String::new(),
|
||
label: "This Computer".to_string(),
|
||
endpoint: String::new(),
|
||
target: None,
|
||
link: Link::Local,
|
||
error: None,
|
||
installing: None,
|
||
preempted: false,
|
||
parked: false,
|
||
rows,
|
||
}
|
||
}
|
||
|
||
fn named_group(label: &str, rows: Vec<Row>) -> Group {
|
||
Group {
|
||
key: label.to_lowercase(),
|
||
label: label.to_string(),
|
||
target: Some(RemoteTarget::direct("me", label, 22)),
|
||
link: Link::Offline,
|
||
..group(rows)
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn orphan_panes_of_keeps_only_live_panes_no_workspace_holds() {
|
||
use tty7_core::daemon::protocol::PaneInfo;
|
||
|
||
fn info(pane_id: u64, alive: bool) -> PaneInfo {
|
||
PaneInfo {
|
||
pane_id,
|
||
cwd: Some(std::path::PathBuf::from("/tmp/x")),
|
||
title: "zsh".to_string(),
|
||
osc_title: None,
|
||
alive,
|
||
owner: Some("tty7-cli".to_string()),
|
||
}
|
||
}
|
||
|
||
let held: HashSet<u64> = [2].into_iter().collect();
|
||
let orphans = orphan_panes_of(vec![info(1, true), info(2, true), info(3, false)], &held);
|
||
assert_eq!(
|
||
orphans,
|
||
vec![OrphanPane {
|
||
pane_id: 1,
|
||
title: "zsh".to_string(),
|
||
cwd: Some("/tmp/x".to_string()),
|
||
owner: Some("tty7-cli".to_string()),
|
||
}],
|
||
"%2 is held by a workspace and %3 is dead — neither is a leak to reap (#596)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn an_orphans_owner_reads_as_a_workspace_never_as_a_whole_uuid() {
|
||
let uuid = "e0d7bebd-2e46-4a8e-abbb-f2f109a9b61d";
|
||
assert_eq!(
|
||
owner_label(uuid, Some("seeg".to_string())),
|
||
"seeg",
|
||
"the workspace is still here, so say which one it is"
|
||
);
|
||
assert_eq!(
|
||
owner_label(uuid, None),
|
||
"e0d7bebd",
|
||
"no workspace left to name: the short id `pane ls` prints, not all 36 \
|
||
characters — the full one shoved the Close button off the card"
|
||
);
|
||
assert_eq!(
|
||
owner_label("tty7-cli", None),
|
||
"tty7-cli",
|
||
"an older client's own label is not an id and reads fine whole"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_workspace_stays_in_the_list_when_only_one_of_its_tabs_matches() {
|
||
let ws = row(
|
||
"notes",
|
||
vec![tab("zsh", "~/notes"), tab("claude", "~/notes")],
|
||
);
|
||
assert!(ws.matches("claude"));
|
||
assert!(!ws.matches("codex"));
|
||
}
|
||
|
||
#[test]
|
||
fn searching_a_tab_name_narrows_the_tab_column_to_the_hits() {
|
||
let ws = row("notes", vec![tab("zsh", "~/a"), tab("claude", "~/b")]);
|
||
assert_eq!(visible_tabs(&ws, "claude"), vec![1]);
|
||
}
|
||
|
||
#[test]
|
||
fn searching_the_workspace_name_keeps_all_of_its_tabs() {
|
||
let ws = row("notes", vec![tab("zsh", "~/a"), tab("claude", "~/b")]);
|
||
assert_eq!(visible_tabs(&ws, "notes"), vec![0, 1]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_workspace_shown_only_because_its_host_matched_keeps_all_its_tabs() {
|
||
// Nothing about the workspace or its tabs matched — the group header
|
||
// did. An empty column here would read as "this workspace is empty".
|
||
let ws = row("notes", vec![tab("zsh", "~/a")]);
|
||
assert_eq!(visible_tabs(&ws, "dev-box"), vec![0]);
|
||
}
|
||
|
||
#[test]
|
||
fn the_flat_list_is_most_recently_used_first_across_machines() {
|
||
// Two machines, interleaved activity: the list must interleave too —
|
||
// grouping by machine is exactly what this design retired.
|
||
let groups = vec![
|
||
group(vec![
|
||
aged(row("old-local", vec![]), 10),
|
||
aged(row("fresh-local", vec![]), 40),
|
||
]),
|
||
named_group("devbox", vec![aged(row("remote", vec![]), 30)]),
|
||
];
|
||
let names: Vec<&str> = flatten(&groups, "")
|
||
.into_iter()
|
||
.map(|(g, r)| groups[g].rows[r].name.as_str())
|
||
.collect();
|
||
assert_eq!(names, vec!["fresh-local", "remote", "old-local"]);
|
||
}
|
||
|
||
#[test]
|
||
fn this_windows_workspace_outranks_everything_however_stale() {
|
||
let mut here = aged(row("here", vec![]), 1);
|
||
here.current = true;
|
||
let groups = vec![group(vec![aged(row("busy", vec![]), 99), here])];
|
||
let first = flatten(&groups, "")[0];
|
||
assert_eq!(groups[first.0].rows[first.1].name, "here");
|
||
}
|
||
|
||
#[test]
|
||
fn a_query_matching_a_machines_name_keeps_all_of_its_rows() {
|
||
// Searching "devbox" is how the retired per-machine grouping is asked
|
||
// for now, so a host-name hit must surface every row of that machine
|
||
// and nothing of the others.
|
||
let groups = vec![
|
||
group(vec![row("local-notes", vec![])]),
|
||
named_group("devbox", vec![row("api", vec![]), row("web", vec![])]),
|
||
];
|
||
let hits = flatten(&groups, "devbox");
|
||
assert_eq!(hits.len(), 2);
|
||
assert!(hits.iter().all(|&(g, _)| g == 1));
|
||
}
|
||
|
||
#[test]
|
||
fn the_tab_column_follows_the_flat_cursor() {
|
||
let layout = Layout {
|
||
groups: vec![group(vec![row("a", vec![]), row("b", vec![])])],
|
||
nav: vec![(0, 1), (0, 0)],
|
||
};
|
||
assert_eq!(layout.subject(0), Some((0, 1)));
|
||
assert_eq!(layout.subject(1), Some((0, 0)));
|
||
assert_eq!(layout.subject(2), None, "past the end is nothing");
|
||
}
|
||
|
||
fn host(label: &str) -> HostChoice {
|
||
HostChoice {
|
||
target: RemoteTarget::direct("me", label, 22),
|
||
label: label.to_string(),
|
||
detail: format!("me@{label}:22"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_host_dropdown_leads_local_and_pins_add_host_last() {
|
||
let items = host_items(vec![host("devbox")], "", "This Computer");
|
||
assert!(matches!(items.first(), Some(HostItem::Local)));
|
||
assert!(matches!(items.last(), Some(HostItem::AddHost)));
|
||
assert_eq!(items.len(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn filtering_the_dropdown_narrows_hosts_but_never_loses_the_way_out() {
|
||
let items = host_items(
|
||
vec![host("devbox"), host("staging")],
|
||
"stag",
|
||
"This Computer",
|
||
);
|
||
// "stag" matches neither the local label nor devbox.
|
||
assert_eq!(items.len(), 2);
|
||
assert!(
|
||
matches!(&items[0], HostItem::Host(h) if h.label == "staging"),
|
||
"the one matching machine survives"
|
||
);
|
||
assert!(
|
||
matches!(items.last(), Some(HostItem::AddHost)),
|
||
"Add SSH Host… is pinned, filter or no filter"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_local_row_answers_to_its_own_name() {
|
||
let items = host_items(vec![host("devbox")], "this comp", "This Computer");
|
||
assert!(matches!(items.first(), Some(HostItem::Local)));
|
||
}
|
||
|
||
#[test]
|
||
fn the_cursor_wraps_at_both_ends() {
|
||
assert_eq!(step(0, 3, true), 1);
|
||
assert_eq!(step(2, 3, true), 0);
|
||
assert_eq!(step(0, 3, false), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn a_tab_of_another_window_is_named_the_way_a_local_one_would_be() {
|
||
// `title` here is the foreground process name the machine tree carries,
|
||
// not a terminal title — it must not outrank the working directory the
|
||
// local tab strip would be showing.
|
||
let mut view = crate::ui::machine_mirror::TabView {
|
||
id: TabId::new(),
|
||
name: Some(" build ".to_string()),
|
||
title: "zsh".to_string(),
|
||
osc_title: Some("✳ 修复 workspace switcher".to_string()),
|
||
cwd: Some("/Users/x/repo/tty7".to_string()),
|
||
agent: Some(crate::core::cli_agent::CLIAgent::Claude),
|
||
status: None,
|
||
live: true,
|
||
panes: 1,
|
||
};
|
||
assert_eq!(tab_view_label(&view, 0, None), "build", "a given name wins");
|
||
|
||
view.name = None;
|
||
assert_eq!(
|
||
tab_view_label(&view, 0, None),
|
||
"✳ 修复 workspace switcher",
|
||
"then the title the local strip would be showing, verbatim"
|
||
);
|
||
|
||
view.osc_title = Some("user@host:~/repo/025/tty7".to_string());
|
||
assert_eq!(
|
||
tab_view_label(&view, 0, None),
|
||
crate::ui::tab_strip::short_title("user@host:~/repo/025/tty7", None),
|
||
"a shell's title goes through the shortener the strip uses"
|
||
);
|
||
|
||
view.osc_title = Some("user@host:".to_string());
|
||
assert_eq!(
|
||
tab_view_label(&view, 0, None),
|
||
"zsh",
|
||
"a title that shortens away to nothing falls through"
|
||
);
|
||
|
||
view.osc_title = None;
|
||
assert_eq!(
|
||
tab_view_label(&view, 0, None),
|
||
"Claude Code",
|
||
"an agent names a tab that has told us nothing else"
|
||
);
|
||
|
||
view.agent = None;
|
||
assert_eq!(
|
||
tab_view_label(&view, 0, None),
|
||
crate::ui::tab_strip::short_title("/Users/x/repo/tty7", None),
|
||
"otherwise the directory, put through the same shortener as the strip"
|
||
);
|
||
|
||
view.cwd = None;
|
||
assert_eq!(
|
||
tab_view_label(&view, 0, None),
|
||
"zsh",
|
||
"process name is last"
|
||
);
|
||
|
||
view.title = String::new();
|
||
assert!(tab_view_label(&view, 2, None).contains('3'));
|
||
}
|
||
}
|
||
|
||
#[cfg(all(test, unix))]
|
||
mod gpui_tests {
|
||
use gpui::{Modifiers, TestAppContext};
|
||
|
||
use super::Column;
|
||
use crate::ui::app::test_window::harness_with_tabs;
|
||
|
||
/// Deleting a remote workspace has to take the listing snapshot's row with
|
||
/// it. The snapshot every window keeps for the switcher is merged into the
|
||
/// panel every frame, and with the store entry gone nothing dedups that
|
||
/// row any more — the workspace the user just deleted popped straight back
|
||
/// as an adoptable machine row until the next reconnect replaced the
|
||
/// snapshot.
|
||
#[gpui::test]
|
||
fn a_deleted_remote_workspace_leaves_the_switcher(cx: &mut TestAppContext) {
|
||
use gpui::VisualContext as _;
|
||
|
||
use crate::core::session::{RemoteRef, RemoteTarget, WindowView, WorkspaceId};
|
||
|
||
let (app, vcx) = crate::ui::app::test_window::harness(cx);
|
||
let handle = vcx.window_handle();
|
||
let weak = app.downgrade();
|
||
|
||
let target = RemoteTarget::Alias {
|
||
alias: "build-box".into(),
|
||
};
|
||
let machine_ws = WorkspaceId::new();
|
||
let kept_ws = WorkspaceId::new();
|
||
let view = WindowView::on_remote(RemoteRef::new(target.clone(), machine_ws));
|
||
let doomed = view.id;
|
||
|
||
app.update(cx, |app, cx| {
|
||
crate::core::session::WorkspaceStore::install_for_test(
|
||
cx,
|
||
crate::core::session::WindowViews {
|
||
views: vec![view],
|
||
active: None,
|
||
},
|
||
);
|
||
crate::ui::windows::WindowRegistry::init(cx);
|
||
crate::ui::windows::WindowRegistry::register(cx, app.workspace, handle, weak);
|
||
app.host_snapshots.insert(
|
||
target.host_id(),
|
||
super::HostSnapshot {
|
||
target: target.clone(),
|
||
rows: vec![
|
||
crate::ui::remote_connect::RemoteWorkspaceRow {
|
||
id: machine_ws,
|
||
name: "doomed".into(),
|
||
panes: 0,
|
||
last_active: 0,
|
||
},
|
||
crate::ui::remote_connect::RemoteWorkspaceRow {
|
||
id: kept_ws,
|
||
name: "kept".into(),
|
||
panes: 0,
|
||
last_active: 0,
|
||
},
|
||
],
|
||
},
|
||
);
|
||
});
|
||
|
||
// From a plain app context, the way `confirm_and_delete`'s spawned
|
||
// task calls it — inside the entity's own update this would abort on
|
||
// the reentrant read (#617's lesson).
|
||
cx.update(|cx| {
|
||
crate::ui::windows::delete_workspace(cx, doomed);
|
||
});
|
||
|
||
app.update(cx, |app, cx| {
|
||
let listed: Vec<WorkspaceId> = app
|
||
.switcher_groups(cx)
|
||
.iter()
|
||
.flat_map(|g| g.rows.iter().filter_map(|r| r.remote_id))
|
||
.collect();
|
||
assert!(
|
||
!listed.contains(&machine_ws),
|
||
"the deleted workspace came back from the listing snapshot"
|
||
);
|
||
assert!(
|
||
listed.contains(&kept_ws),
|
||
"its machine's other workspaces are still on offer"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[gpui::test]
|
||
fn ctrl_tab_raises_the_panel_on_the_previously_used_tab(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));
|
||
|
||
app.update(cx, |app, _| {
|
||
let sw = app.switcher.as_ref().expect("Ctrl+Tab raises the panel");
|
||
assert_eq!(sw.column, Column::Right, "the tab column takes the cursor");
|
||
assert_eq!(sw.right_sel, 1, "the cursor lands on the previous tab");
|
||
assert!(
|
||
sw.mru,
|
||
"Ctrl+Tab orders the column most-recently-used first"
|
||
);
|
||
assert!(sw.hold.is_some(), "the held modifier is what commits later");
|
||
});
|
||
}
|
||
|
||
#[gpui::test]
|
||
fn holding_ctrl_and_pressing_tab_again_walks_further_down(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);
|
||
app.tab_switch(true, window, cx);
|
||
});
|
||
|
||
app.update(cx, |app, _| {
|
||
assert_eq!(app.switcher.as_ref().expect("still up").right_sel, 2);
|
||
assert_eq!(app.active, 0, "nothing is committed while the key is held");
|
||
});
|
||
}
|
||
|
||
#[gpui::test]
|
||
fn releasing_ctrl_commits_the_highlighted_tab_and_closes_the_panel(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.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");
|
||
});
|
||
}
|
||
|
||
#[gpui::test]
|
||
fn a_second_ctrl_tab_goes_back_to_where_it_came_from(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.simulate_modifiers_change(Modifiers::none());
|
||
vcx.run_until_parked();
|
||
app.update(cx, |app, _| assert_eq!(app.active, 1));
|
||
|
||
vcx.simulate_modifiers_change(Modifiers::control());
|
||
app.update_in(&mut vcx, |app, window, cx| app.tab_switch(true, window, cx));
|
||
vcx.simulate_modifiers_change(Modifiers::none());
|
||
|
||
app.update(cx, |app, _| {
|
||
assert_eq!(
|
||
app.active, 0,
|
||
"most-recently-used ordering makes the gesture a toggle"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[gpui::test]
|
||
fn a_lone_tab_still_opens_the_panel_but_does_not_hold(cx: &mut TestAppContext) {
|
||
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
|
||
vcx.simulate_modifiers_change(Modifiers::control());
|
||
|
||
app.update_in(&mut vcx, |app, window, cx| app.tab_switch(true, window, cx));
|
||
|
||
app.update(cx, |app, _| {
|
||
let sw = app
|
||
.switcher
|
||
.as_ref()
|
||
.expect("with nothing to cycle it still opens the switcher");
|
||
assert_eq!(
|
||
sw.column,
|
||
Column::Left,
|
||
"the workspace column is the useful one"
|
||
);
|
||
assert!(sw.hold.is_none(), "nothing to commit, so nothing to hold");
|
||
});
|
||
}
|
||
|
||
#[gpui::test]
|
||
fn a_lone_tabs_panel_survives_letting_go_of_ctrl(cx: &mut TestAppContext) {
|
||
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
|
||
vcx.simulate_modifiers_change(Modifiers::control());
|
||
app.update_in(&mut vcx, |app, window, cx| app.tab_switch(true, window, cx));
|
||
|
||
vcx.simulate_modifiers_change(Modifiers::none());
|
||
|
||
app.update(cx, |app, _| {
|
||
assert!(
|
||
app.switcher.is_some(),
|
||
"a panel opened without a hold must not close on release"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[gpui::test]
|
||
fn picking_a_tab_mid_gesture_commits_it_on_release(cx: &mut TestAppContext) {
|
||
// What a click during the hold amounts to: aim the cursor, then let go.
|
||
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));
|
||
|
||
app.update(cx, |app, cx| app.switcher_point_tab(2, cx));
|
||
vcx.simulate_modifiers_change(Modifiers::none());
|
||
|
||
app.update(cx, |app, _| {
|
||
assert!(app.switcher.is_none());
|
||
assert_eq!(app.active, 2, "the tab the pointer picked is now active");
|
||
});
|
||
}
|
||
|
||
#[gpui::test]
|
||
fn a_held_gesture_hides_the_context_menus_it_would_trip_over(cx: &mut TestAppContext) {
|
||
// macOS reports Ctrl+click as a right click, which is exactly what the
|
||
// context menu listens for. Nothing to assert on the element tree from
|
||
// here, so this pins the flag the render path branches on.
|
||
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));
|
||
|
||
app.update(cx, |app, _| {
|
||
assert!(
|
||
app.switcher.as_ref().is_some_and(|sw| sw.hold.is_some()),
|
||
"the render path drops the menus while this is set"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[gpui::test]
|
||
fn the_new_workspace_gesture_raises_the_create_form(cx: &mut TestAppContext) {
|
||
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
|
||
|
||
app.update_in(&mut vcx, |app, window, cx| {
|
||
app.open_workspace_form(window, cx)
|
||
});
|
||
|
||
app.update(cx, |app, cx| {
|
||
let sw = app.switcher.as_ref().expect("the switcher is up");
|
||
let super::Page::Create(form) = &sw.page else {
|
||
panic!("the card shows the create form");
|
||
};
|
||
assert!(
|
||
form.chosen.is_none(),
|
||
"the host defaults to this computer, so Enter alone creates locally"
|
||
);
|
||
assert!(!form.open, "the dropdown starts folded");
|
||
assert!(
|
||
!form.name.read(cx).value().trim().is_empty(),
|
||
"the name box starts prefilled with the generated default"
|
||
);
|
||
});
|
||
}
|
||
|
||
/// Creating a workspace switches this window over to it, and the pull that
|
||
/// follows used to read the window back out of the registry to see what it
|
||
/// was showing — the very entity `switch_workspace` is holding. gpui
|
||
/// aborts the process for that, so the window had to be registered here for
|
||
/// the lookup to find anything and the crash to reproduce (#617).
|
||
#[gpui::test]
|
||
fn creating_a_workspace_does_not_read_the_window_making_it(cx: &mut TestAppContext) {
|
||
use gpui::VisualContext;
|
||
|
||
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
|
||
let handle = vcx.window_handle();
|
||
let weak = app.downgrade();
|
||
app.update(cx, |app, cx| {
|
||
crate::ui::windows::WindowRegistry::init(cx);
|
||
crate::ui::windows::WindowRegistry::register(cx, app.workspace, handle, weak);
|
||
});
|
||
|
||
app.update_in(&mut vcx, |app, window, cx| {
|
||
app.open_workspace_form(window, cx)
|
||
});
|
||
app.update_in(&mut vcx, |app, window, cx| {
|
||
app.switcher_form_create(window, cx)
|
||
});
|
||
|
||
app.update(cx, |app, cx| {
|
||
assert!(
|
||
crate::ui::windows::WindowRegistry::app_for(cx, app.workspace).is_some(),
|
||
"the window followed its new workspace into the registry"
|
||
);
|
||
});
|
||
}
|
||
|
||
/// The name the form collected has to reach the create, not chase it. It
|
||
/// used to go out as a rename the moment the window switched — before the
|
||
/// workspace existed on the machine to be renamed — so it was answered
|
||
/// `NotFound`, dropped, and the generated name stood (#618).
|
||
#[gpui::test]
|
||
fn a_name_typed_into_the_form_is_held_for_the_create(cx: &mut TestAppContext) {
|
||
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
|
||
|
||
app.update_in(&mut vcx, |app, window, cx| {
|
||
app.name_fresh_workspace(Some("deploy".into()), window, cx)
|
||
});
|
||
|
||
app.update(cx, |app, cx| {
|
||
assert_eq!(
|
||
crate::ui::tree_sync::chosen_name_for(cx, app.workspace).as_deref(),
|
||
Some("deploy"),
|
||
"the create this window is about to make spends it"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[gpui::test]
|
||
fn the_form_folds_back_into_the_list_rather_than_closing(cx: &mut TestAppContext) {
|
||
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
|
||
app.update_in(&mut vcx, |app, window, cx| {
|
||
app.open_workspace_form(window, cx)
|
||
});
|
||
|
||
app.update_in(&mut vcx, |app, window, cx| {
|
||
app.switcher_back_to_list(window, cx)
|
||
});
|
||
|
||
app.update(cx, |app, _| {
|
||
let sw = app
|
||
.switcher
|
||
.as_ref()
|
||
.expect("still up — Esc backs out one step");
|
||
assert!(matches!(sw.page, super::Page::List));
|
||
});
|
||
}
|
||
|
||
#[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);
|
||
vcx.simulate_modifiers_change(Modifiers::control());
|
||
app.update_in(&mut vcx, |app, window, cx| app.tab_switch(true, window, cx));
|
||
|
||
vcx.deactivate_window();
|
||
|
||
app.update(cx, |app, _| {
|
||
let sw = app.switcher.as_ref().expect("the panel stays up");
|
||
assert!(
|
||
sw.hold.is_none(),
|
||
"a release over another window never reaches us"
|
||
);
|
||
});
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod key_tests {
|
||
use gpui::Modifiers;
|
||
|
||
use super::{Column, Key, key_intent};
|
||
|
||
#[test]
|
||
fn bare_arrows_and_enter_drive_the_panel() {
|
||
let none = Modifiers::none();
|
||
assert_eq!(key_intent("down", none), Key::Step(true));
|
||
assert_eq!(key_intent("up", none), Key::Step(false));
|
||
assert_eq!(key_intent("left", none), Key::ToColumn(Column::Left));
|
||
assert_eq!(key_intent("right", none), Key::ToColumn(Column::Right));
|
||
assert_eq!(key_intent("enter", none), Key::Confirm(false));
|
||
assert_eq!(key_intent("escape", none), Key::Close);
|
||
}
|
||
|
||
#[test]
|
||
fn the_secondary_modifier_turns_enter_into_a_new_window() {
|
||
// ⌘ on macOS, Ctrl everywhere else. Off macOS this is the case that a
|
||
// blanket "control means not ours" check would have swallowed.
|
||
assert_eq!(
|
||
key_intent("enter", Modifiers::secondary_key()),
|
||
Key::Confirm(true)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn tab_walks_the_tab_column_in_both_directions() {
|
||
assert_eq!(key_intent("tab", Modifiers::none()), Key::Tab(true));
|
||
assert_eq!(key_intent("tab", Modifiers::shift()), Key::Tab(false));
|
||
}
|
||
|
||
#[test]
|
||
fn a_held_control_keeps_tab_working_but_parks_the_arrows() {
|
||
// Mid Ctrl+Tab gesture: Tab still steps, but an arrow key is somebody
|
||
// else's chord. Only macOS sees the raw key here — everywhere else
|
||
// Ctrl is the secondary modifier, the chord arrives as the NextTab
|
||
// action instead, and the raw key must fall through untouched.
|
||
let ctrl = Modifiers::control();
|
||
match cfg!(target_os = "macos") {
|
||
true => assert_eq!(key_intent("tab", ctrl), Key::Tab(true)),
|
||
false => assert_eq!(key_intent("tab", ctrl), Key::Pass),
|
||
}
|
||
assert_eq!(key_intent("up", ctrl), Key::Pass);
|
||
}
|
||
|
||
#[test]
|
||
fn alt_and_fn_chords_are_left_alone() {
|
||
assert_eq!(key_intent("down", Modifiers::alt()), Key::Pass);
|
||
assert_eq!(key_intent("enter", Modifiers::alt()), Key::Pass);
|
||
}
|
||
|
||
#[test]
|
||
fn escape_closes_even_mid_chord() {
|
||
assert_eq!(key_intent("escape", Modifiers::alt()), Key::Close);
|
||
assert_eq!(key_intent("escape", Modifiers::secondary_key()), Key::Close);
|
||
}
|
||
|
||
#[test]
|
||
fn the_secondary_glyph_matches_the_platform() {
|
||
let glyph = crate::ui::keymap::secondary_glyph();
|
||
match cfg!(target_os = "macos") {
|
||
true => assert_eq!(glyph, "⌘"),
|
||
false => assert_eq!(glyph, "Ctrl"),
|
||
}
|
||
}
|
||
}
|