mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-10-03 08:02:02 +00:00
SelectWorkspace1-9 and the Window menu numbered workspaces by most recent use, so switching to one moved it to slot 1 and reshuffled the rest. Number them by the order this client first had them instead (the append-only views list), skipping synced remote references until they are opened; opening one moves it to the end so it takes the next free number. The switcher keeps its MRU list and shows each row's number.
1386 lines
50 KiB
Rust
1386 lines
50 KiB
Rust
use gpui::{
|
|
AnyWindowHandle, App, AppContext as _, BorrowAppContext as _, Bounds, Global, Styled as _,
|
|
TitlebarOptions, WeakEntity, Window, WindowBounds, WindowDecorations, WindowOptions, point, px,
|
|
size,
|
|
};
|
|
use gpui_component::{Root, TitleBar};
|
|
|
|
use crate::core::config::{Config, StartupMode};
|
|
use crate::core::session::{WorkspaceId, WorkspaceStore};
|
|
use crate::core::window_state::{WindowGeometry as _, WindowState};
|
|
use crate::ui::app::Tty7App;
|
|
use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural};
|
|
|
|
const CASCADE_STEP: f32 = 28.0;
|
|
|
|
const DEFAULT_SIZE: (f32, f32) = (1440.0, 900.0);
|
|
|
|
/// The smallest window tty7 still holds its shape in.
|
|
///
|
|
/// Below this the chrome stops being chrome: the sidebar is at its 180px floor
|
|
/// with barely forty columns beside it, and the overlays the app drops on top —
|
|
/// the palette at 560 wide, the switcher wider still — have nowhere to land.
|
|
/// Every one of those could be made to shrink further, but a terminal this
|
|
/// small is not a window anyone is working in, and a floor is the honest fix.
|
|
const MIN_SIZE: (f32, f32) = (720.0, 520.0);
|
|
|
|
struct WindowEntry {
|
|
workspace: WorkspaceId,
|
|
handle: AnyWindowHandle,
|
|
app: WeakEntity<Tty7App>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct WindowRegistry {
|
|
windows: Vec<WindowEntry>,
|
|
}
|
|
|
|
impl Global for WindowRegistry {}
|
|
|
|
impl WindowRegistry {
|
|
pub fn init(cx: &mut App) {
|
|
cx.set_global(Self::default());
|
|
}
|
|
|
|
pub fn count(cx: &mut App) -> usize {
|
|
Self::sweep(cx);
|
|
cx.global::<Self>().windows.len()
|
|
}
|
|
|
|
pub fn open_windows(cx: &mut App) -> Vec<(WorkspaceId, WeakEntity<Tty7App>)> {
|
|
Self::sweep(cx);
|
|
cx.global::<Self>()
|
|
.windows
|
|
.iter()
|
|
.map(|w| (w.workspace, w.app.clone()))
|
|
.collect()
|
|
}
|
|
|
|
pub fn window_for(cx: &mut App, workspace: WorkspaceId) -> Option<AnyWindowHandle> {
|
|
Self::sweep(cx);
|
|
cx.global::<Self>()
|
|
.windows
|
|
.iter()
|
|
.find(|w| w.workspace == workspace)
|
|
.map(|w| w.handle)
|
|
}
|
|
|
|
pub fn most_recent(cx: &mut App) -> Option<WorkspaceId> {
|
|
Self::sweep(cx);
|
|
let active = WorkspaceStore::all(cx).active;
|
|
let registry = cx.global::<Self>();
|
|
active
|
|
.filter(|id| registry.windows.iter().any(|w| w.workspace == *id))
|
|
.or_else(|| registry.windows.first().map(|w| w.workspace))
|
|
}
|
|
|
|
pub fn most_recent_local(cx: &mut App) -> Option<WorkspaceId> {
|
|
Self::sweep(cx);
|
|
let views = WorkspaceStore::all(cx);
|
|
let registry = cx.global::<Self>();
|
|
let is_open_local = |id: WorkspaceId| {
|
|
registry.windows.iter().any(|window| window.workspace == id)
|
|
&& views.get(id).is_some_and(|view| !view.is_remote())
|
|
};
|
|
views.active.filter(|id| is_open_local(*id)).or_else(|| {
|
|
registry
|
|
.windows
|
|
.iter()
|
|
.filter(|window| is_open_local(window.workspace))
|
|
.max_by_key(|window| {
|
|
views
|
|
.get(window.workspace)
|
|
.map(|view| view.last_active)
|
|
.unwrap_or_default()
|
|
})
|
|
.map(|window| window.workspace)
|
|
})
|
|
}
|
|
|
|
pub fn app_in(cx: &mut App, window: &Window) -> Option<gpui::Entity<Tty7App>> {
|
|
Self::sweep(cx);
|
|
let handle = window.window_handle();
|
|
cx.global::<Self>()
|
|
.windows
|
|
.iter()
|
|
.find(|w| w.handle == handle)
|
|
.and_then(|w| w.app.upgrade())
|
|
}
|
|
|
|
pub fn app_for(cx: &mut App, workspace: WorkspaceId) -> Option<WeakEntity<Tty7App>> {
|
|
Self::sweep(cx);
|
|
cx.global::<Self>()
|
|
.windows
|
|
.iter()
|
|
.find(|w| w.workspace == workspace)
|
|
.map(|w| w.app.clone())
|
|
}
|
|
|
|
pub fn refresh_locale(cx: &mut App, except: Option<WorkspaceId>) {
|
|
Self::sweep(cx);
|
|
let windows: Vec<_> = cx
|
|
.global::<Self>()
|
|
.windows
|
|
.iter()
|
|
.filter(|entry| Some(entry.workspace) != except)
|
|
.map(|entry| (entry.handle, entry.app.clone()))
|
|
.collect();
|
|
for (handle, app) in windows {
|
|
let _ = handle.update(cx, |_, window, cx| {
|
|
let _ = app.update(cx, |app, cx| app.refresh_locale_state(window, cx));
|
|
window.refresh();
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Rebuilds every window's shell inventory from disk.
|
|
///
|
|
/// `custom_shells` is read while that inventory is assembled, and it lives
|
|
/// in `config.json` — which hot-reloads. The menu it feeds has to follow the
|
|
/// file there, or the one surface the feature has appears not to work until
|
|
/// the app is restarted, while every other key in the same save takes hold
|
|
/// at once.
|
|
pub fn refresh_shells(cx: &mut App) {
|
|
Self::sweep(cx);
|
|
let apps: Vec<_> = cx
|
|
.global::<Self>()
|
|
.windows
|
|
.iter()
|
|
.map(|entry| entry.app.clone())
|
|
.collect();
|
|
for app in apps {
|
|
let _ = app.update(cx, |app, cx| app.refresh_shells(cx));
|
|
}
|
|
}
|
|
|
|
pub(crate) fn register(
|
|
cx: &mut App,
|
|
workspace: WorkspaceId,
|
|
handle: AnyWindowHandle,
|
|
app: WeakEntity<Tty7App>,
|
|
) {
|
|
cx.global_mut::<Self>().windows.push(WindowEntry {
|
|
workspace,
|
|
handle,
|
|
app,
|
|
});
|
|
}
|
|
|
|
pub fn unregister(cx: &mut App, workspace: WorkspaceId) {
|
|
cx.global_mut::<Self>()
|
|
.windows
|
|
.retain(|w| w.workspace != workspace);
|
|
}
|
|
|
|
pub fn rebind(cx: &mut App, from: WorkspaceId, to: WorkspaceId) {
|
|
if let Some(entry) = cx
|
|
.global_mut::<Self>()
|
|
.windows
|
|
.iter_mut()
|
|
.find(|w| w.workspace == from)
|
|
{
|
|
entry.workspace = to;
|
|
}
|
|
}
|
|
|
|
fn sweep(cx: &mut App) {
|
|
let dead: Vec<WorkspaceId> = cx
|
|
.global::<Self>()
|
|
.windows
|
|
.iter()
|
|
.filter(|w| w.app.upgrade().is_none())
|
|
.map(|w| w.workspace)
|
|
.collect();
|
|
if dead.is_empty() {
|
|
return;
|
|
}
|
|
cx.global_mut::<Self>()
|
|
.windows
|
|
.retain(|w| !dead.contains(&w.workspace));
|
|
}
|
|
}
|
|
|
|
pub fn open(cx: &mut App, workspace: Option<WorkspaceId>) {
|
|
open_at(cx, workspace, None);
|
|
}
|
|
|
|
/// Reveals `workspace` and activates one of its tabs. The window may already be
|
|
/// open, may belong to this process but be behind, or may not exist yet — the
|
|
/// caller does not care which. The tab is named by id rather than position
|
|
/// because the caller read it out of the machine tree, not out of that window.
|
|
pub fn open_at_tab(cx: &mut App, workspace: WorkspaceId, tab: tty7_core::core::machine::TabId) {
|
|
open_at(cx, Some(workspace), None);
|
|
let Some(handle) = WindowRegistry::window_for(cx, workspace) else {
|
|
return;
|
|
};
|
|
let Some(app) = WindowRegistry::app_for(cx, workspace) else {
|
|
return;
|
|
};
|
|
let _ = handle.update(cx, |_, window, cx| {
|
|
window.activate_window();
|
|
let _ = app.update(cx, |this, cx| {
|
|
// A window opened just now may still be hydrating its tabs, so the
|
|
// request parks until the tab arrives rather than probing once.
|
|
this.activate_tree_tab(tab, window, cx);
|
|
});
|
|
});
|
|
}
|
|
|
|
pub fn open_at(
|
|
cx: &mut App,
|
|
workspace: Option<WorkspaceId>,
|
|
initial_cwd: Option<std::path::PathBuf>,
|
|
) {
|
|
if let Some(id) = workspace
|
|
&& let Some(handle) = WindowRegistry::window_for(cx, id)
|
|
{
|
|
let _ = handle.update(cx, |_, window, _| window.activate_window());
|
|
return;
|
|
}
|
|
|
|
let options = window_options(cx, workspace);
|
|
let mut created: Option<gpui::Entity<Tty7App>> = None;
|
|
let opened = cx.open_window(options, |window, cx| {
|
|
let app = cx.new(|cx| match initial_cwd.clone() {
|
|
Some(cwd) => Tty7App::for_workspace_at(workspace, Some(cwd), window, cx),
|
|
None => Tty7App::for_workspace(workspace, window, cx),
|
|
});
|
|
created = Some(app.clone());
|
|
cx.new(|cx| Root::new(app, window, cx).bg(gpui::transparent_black()))
|
|
});
|
|
|
|
let handle = match opened {
|
|
Ok(handle) => handle,
|
|
Err(e) => {
|
|
log::error!("failed to open window: {e}");
|
|
return;
|
|
}
|
|
};
|
|
let Some(app) = created else {
|
|
log::error!("opened a window but its Tty7App was never built; not registering");
|
|
return;
|
|
};
|
|
|
|
let id = app.read(cx).workspace;
|
|
WindowRegistry::register(cx, id, handle.into(), app.downgrade());
|
|
refresh_menu(cx);
|
|
}
|
|
|
|
/// A named workspace is the one that gets the window: the CLI made it, knows
|
|
/// its id, and no other window would do. Everything else is `tty7 [PATH]`,
|
|
/// where the CLI has no opinion and this process picks.
|
|
pub fn open_named_workspace_from_cli(cx: &mut App, workspace: WorkspaceId) {
|
|
cx.activate(true);
|
|
open(cx, Some(workspace));
|
|
if let Some(handle) = WindowRegistry::window_for(cx, workspace) {
|
|
let _ = handle.update(cx, |_, window, _| window.activate_window());
|
|
}
|
|
}
|
|
|
|
pub fn open_from_cli(cx: &mut App, path: Option<std::path::PathBuf>) {
|
|
// Only the GUI process knows which of its windows was focused most recently.
|
|
// The daemon deliberately routes to a process, then leaves window selection
|
|
// to this registry.
|
|
let workspace = if path.is_some() {
|
|
WindowRegistry::most_recent_local(cx)
|
|
} else {
|
|
WindowRegistry::most_recent(cx)
|
|
};
|
|
let Some(workspace) = workspace else {
|
|
open_missing_cli_window_with(cx, path, open_at);
|
|
return;
|
|
};
|
|
let Some(handle) = WindowRegistry::window_for(cx, workspace) else {
|
|
return;
|
|
};
|
|
let Some(app) = WindowRegistry::app_for(cx, workspace).and_then(|app| app.upgrade()) else {
|
|
return;
|
|
};
|
|
|
|
cx.activate(true);
|
|
let _ = handle.update(cx, move |_, window, cx| {
|
|
if let Some(path) = path {
|
|
app.update(cx, |app, cx| app.new_tab_at(path, window, cx));
|
|
}
|
|
window.activate_window();
|
|
});
|
|
}
|
|
|
|
/// Runs a local command in a new tab of the most recently active local
|
|
/// workspace, restoring or creating one if every open window is remote or
|
|
/// absent. Takes the same route as [`open_from_cli`], for the same reason: a
|
|
/// window whose layout is still being pulled has to be opened into by the
|
|
/// pull, not underneath it.
|
|
pub fn run_local_command(cx: &mut App, cwd: std::path::PathBuf, command: String) {
|
|
let Some(workspace) = WindowRegistry::most_recent_local(cx) else {
|
|
open_missing_cli_window_with(cx, Some(cwd), |cx, restore, cwd| {
|
|
let Some(cwd) = cwd else { return };
|
|
open_at(cx, restore, Some(cwd.clone()));
|
|
let Some(workspace) = WindowRegistry::most_recent_local(cx) else {
|
|
return;
|
|
};
|
|
// A window that is pulling its layout parked the folder itself, in
|
|
// `for_workspace_at`; the command has to travel with it. One that
|
|
// is not opened its first terminal in `cwd` already, so the command
|
|
// goes straight there rather than into a second tab.
|
|
if crate::ui::tree_sync::park_command_while_pulling(cx, workspace, &cwd, &command) {
|
|
activate(cx, workspace);
|
|
} else {
|
|
run_command_in_active_terminal(cx, workspace, command);
|
|
}
|
|
});
|
|
return;
|
|
};
|
|
if crate::ui::tree_sync::park_command_while_pulling(cx, workspace, &cwd, &command) {
|
|
activate(cx, workspace);
|
|
return;
|
|
}
|
|
run_local_command_in(cx, workspace, cwd, command);
|
|
}
|
|
|
|
fn activate(cx: &mut App, workspace: WorkspaceId) {
|
|
let Some(handle) = WindowRegistry::window_for(cx, workspace) else {
|
|
return;
|
|
};
|
|
cx.activate(true);
|
|
let _ = handle.update(cx, |_, window, _| window.activate_window());
|
|
}
|
|
|
|
fn run_command_in_active_terminal(cx: &mut App, workspace: WorkspaceId, command: String) {
|
|
let Some(handle) = WindowRegistry::window_for(cx, workspace) else {
|
|
return;
|
|
};
|
|
let Some(app) = WindowRegistry::app_for(cx, workspace).and_then(|app| app.upgrade()) else {
|
|
return;
|
|
};
|
|
cx.activate(true);
|
|
let _ = handle.update(cx, move |_, window, cx| {
|
|
app.update(cx, |app, cx| {
|
|
app.run_in_active_terminal(&command, window, cx)
|
|
});
|
|
window.activate_window();
|
|
});
|
|
}
|
|
|
|
fn run_local_command_in(
|
|
cx: &mut App,
|
|
workspace: WorkspaceId,
|
|
cwd: std::path::PathBuf,
|
|
command: String,
|
|
) {
|
|
let Some(handle) = WindowRegistry::window_for(cx, workspace) else {
|
|
return;
|
|
};
|
|
let Some(app) = WindowRegistry::app_for(cx, workspace).and_then(|app| app.upgrade()) else {
|
|
return;
|
|
};
|
|
cx.activate(true);
|
|
let _ = handle.update(cx, move |_, window, cx| {
|
|
app.update(cx, |app, cx| app.new_tab_running(cwd, command, window, cx));
|
|
window.activate_window();
|
|
});
|
|
}
|
|
|
|
/// Opens an `ssh://` link in the most recently active window, restoring or
|
|
/// creating one if none is up. Goes through the same restore as every other
|
|
/// windowless entry point, so a tray-resident tty7 comes back to the workspace
|
|
/// it retired with instead of claiming a fresh one.
|
|
pub fn quick_connect_from_url(cx: &mut App, ssh: tty7_core::core::ssh_profile::QuickConnect) {
|
|
// An SSH link opens a tab, which any window can hold.
|
|
let workspace = WindowRegistry::most_recent_local(cx)
|
|
.or_else(|| WindowRegistry::most_recent(cx))
|
|
.or_else(|| {
|
|
open_missing_cli_window_with(cx, None, open_at);
|
|
WindowRegistry::most_recent(cx)
|
|
});
|
|
let Some(workspace) = workspace else {
|
|
return;
|
|
};
|
|
// A window still pulling its layout would take this tab as the whole
|
|
// workspace and push it back over what it is about to restore.
|
|
if crate::ui::tree_sync::park_ssh_while_pulling(cx, workspace, &ssh) {
|
|
activate(cx, workspace);
|
|
return;
|
|
}
|
|
let Some(handle) = WindowRegistry::window_for(cx, workspace) else {
|
|
return;
|
|
};
|
|
let Some(app) = WindowRegistry::app_for(cx, workspace).and_then(|app| app.upgrade()) else {
|
|
return;
|
|
};
|
|
cx.activate(true);
|
|
let _ = handle.update(cx, move |_, window, cx| {
|
|
app.update(cx, |app, cx| app.quick_connect(ssh, window, cx));
|
|
window.activate_window();
|
|
});
|
|
}
|
|
|
|
/// What a launch reopens: the workspace, and how many other open windows the
|
|
/// restore left detached. Their panes are still running — the count exists so
|
|
/// the launch can say so instead of letting them be forgotten (#597).
|
|
pub type RestoreTarget = (WorkspaceId, usize);
|
|
|
|
/// The workspace a launch reopens, if any.
|
|
///
|
|
/// A launch that carries a directory restores the last layout exactly like a
|
|
/// pathless one does, and the directory becomes another tab in it. The two used
|
|
/// to disagree: "Open in tty7" from Explorer, or `tty7 <PATH>`, skipped the
|
|
/// restore outright whenever no window was already up, so a folder opened that
|
|
/// way came back as one blank terminal with the previous tabs nowhere — still
|
|
/// live on the machine, but reachable only through the switcher.
|
|
///
|
|
/// What a path does change is that it will not follow the layout onto a remote
|
|
/// machine. The requested directory is a path on this computer and a remote
|
|
/// workspace has no business spawning it, so that case starts a fresh local
|
|
/// workspace — which is what every path-carrying launch used to do.
|
|
pub fn restore_target(cx: &mut App, path: Option<&std::path::Path>) -> Option<RestoreTarget> {
|
|
if path.is_some() {
|
|
let views = WorkspaceStore::all(cx);
|
|
let candidate = views.workspace_to_restore()?;
|
|
if views.get(candidate).is_some_and(|view| view.is_remote()) {
|
|
return None;
|
|
}
|
|
}
|
|
WorkspaceStore::restore_one(cx)
|
|
}
|
|
|
|
/// Tell the user about the workspaces a launch restored away. A desktop toast
|
|
/// would work, but the window is right there — and the notification names the
|
|
/// place the workspaces can be got back from.
|
|
pub fn announce_detached_at_launch(cx: &mut App, restored: Option<RestoreTarget>) {
|
|
let Some((workspace, detached)) = restored else {
|
|
return;
|
|
};
|
|
if detached == 0 {
|
|
return;
|
|
}
|
|
let Some(handle) = WindowRegistry::window_for(cx, workspace) else {
|
|
return;
|
|
};
|
|
let _ = handle.update(cx, |_, window, cx| {
|
|
gpui_component::WindowExt::push_notification(
|
|
window,
|
|
t_plural(L10nKey::LaunchWorkspacesLeftRunning, detached, &[]),
|
|
cx,
|
|
);
|
|
});
|
|
}
|
|
|
|
/// Opens a window after CLI routing reaches a GUI process with no live windows.
|
|
///
|
|
/// Both shapes of request follow the same restoration policy as normal startup;
|
|
/// see [`restore_target`] for the one way a path narrows it.
|
|
fn open_missing_cli_window_with(
|
|
cx: &mut App,
|
|
path: Option<std::path::PathBuf>,
|
|
open: impl FnOnce(&mut App, Option<WorkspaceId>, Option<std::path::PathBuf>),
|
|
) {
|
|
let restore = restore_target(cx, path.as_deref());
|
|
open(cx, restore.map(|(id, _)| id), path);
|
|
announce_detached_at_launch(cx, restore);
|
|
}
|
|
|
|
/// Brings the UI back when macOS relaunches a tty7 that is already running.
|
|
///
|
|
/// AppKit only asks when it found no visible window, and that is a state
|
|
/// tty7 lives in on purpose: closing the last window with the tray icon on
|
|
/// retires to the tray, process alive and Dock icon up. A window that is
|
|
/// still there is activated rather than doubled — `activate_window` is
|
|
/// `makeKeyAndOrderFront:`, which is what orders a window AppKit left behind
|
|
/// back to the front. None at all gets the last layout back the way a
|
|
/// pathless launch and the tray's windowless path do, so the workspace that
|
|
/// retired is the one that returns and not a blank one beside it.
|
|
pub fn reopen(cx: &mut App) {
|
|
reopen_with(cx, open_at);
|
|
}
|
|
|
|
fn reopen_with(
|
|
cx: &mut App,
|
|
open: impl FnOnce(&mut App, Option<WorkspaceId>, Option<std::path::PathBuf>),
|
|
) {
|
|
match WindowRegistry::most_recent(cx) {
|
|
Some(workspace) => activate(cx, workspace),
|
|
None => open_missing_cli_window_with(cx, None, open),
|
|
}
|
|
}
|
|
|
|
pub fn refresh_menu(cx: &mut App) {
|
|
crate::ui::theme::set_menus(cx);
|
|
}
|
|
|
|
pub const MENU_SLOTS: usize = 9;
|
|
|
|
/// The workspaces behind `SelectWorkspace1`…`9` and the Workspaces menu, in
|
|
/// slot order, each with whether a window has it open. A stable order —
|
|
/// [`WindowViews::numbered`](crate::core::session::WindowViews::numbered) —
|
|
/// not the most recently used one: slot 3 is the same workspace however
|
|
/// often the others are switched to (#760).
|
|
pub fn menu_order(cx: &App) -> Vec<(WorkspaceId, bool)> {
|
|
WorkspaceStore::all(cx)
|
|
.numbered()
|
|
.take(MENU_SLOTS)
|
|
.map(|w| (w.id, w.open))
|
|
.collect()
|
|
}
|
|
|
|
pub struct PaneCountQuery {
|
|
route: crate::terminal::PaneRoute,
|
|
claimed: Vec<u64>,
|
|
}
|
|
|
|
pub fn pane_count_query(cx: &App, workspace: WorkspaceId) -> Option<PaneCountQuery> {
|
|
let ws = WorkspaceStore::all(cx).get(workspace)?;
|
|
Some(PaneCountQuery {
|
|
route: crate::ui::remote_workspace::pane_route_for(cx, workspace),
|
|
claimed: crate::ui::machine_mirror::pane_ids(cx, ws)?,
|
|
})
|
|
}
|
|
|
|
pub fn live_pane_count(q: &PaneCountQuery) -> Option<usize> {
|
|
let PaneCountQuery { route, claimed } = q;
|
|
if claimed.is_empty() {
|
|
return Some(0);
|
|
}
|
|
match crate::terminal::RemoteTerminal::try_list_panes_on(route) {
|
|
Ok(panes) => {
|
|
let alive: std::collections::HashSet<u64> = panes
|
|
.into_iter()
|
|
.filter(|p| p.alive)
|
|
.map(|p| p.pane_id)
|
|
.collect();
|
|
Some(claimed.iter().filter(|id| alive.contains(id)).count())
|
|
}
|
|
Err(_) if matches!(route, crate::terminal::PaneRoute::Local) => Some(0),
|
|
Err(_) => None,
|
|
}
|
|
}
|
|
|
|
pub fn confirm_and_stop(cx: &mut App, window: &mut Window, workspace: WorkspaceId) {
|
|
confirm_destructive(cx, window, workspace, "Stop", stop_workspace);
|
|
}
|
|
|
|
pub fn confirm_and_delete(cx: &mut App, window: &mut Window, workspace: WorkspaceId) {
|
|
confirm_destructive(cx, window, workspace, "Delete", delete_workspace);
|
|
}
|
|
|
|
fn destructive_detail(live: Option<usize>, verb: &str) -> String {
|
|
match (live, verb) {
|
|
(None, "Delete") => t(L10nKey::WindowDeleteUnreachable).to_string(),
|
|
(None, _) => t(L10nKey::WindowStopUnreachable).to_string(),
|
|
(Some(0), _) => t_plural(L10nKey::WindowStopShells, 0, &[]),
|
|
(Some(n), "Delete") => t_plural(L10nKey::WindowDeleteShells, n, &[]),
|
|
(Some(n), _) => t_plural(L10nKey::WindowStopShells, n, &[]),
|
|
}
|
|
}
|
|
|
|
fn confirm_destructive(
|
|
cx: &mut App,
|
|
window: &mut Window,
|
|
workspace: WorkspaceId,
|
|
verb: &'static str,
|
|
act: fn(&mut App, WorkspaceId),
|
|
) {
|
|
let name = crate::ui::machine_mirror::display_name_for(cx, workspace)
|
|
.unwrap_or_else(|| t(L10nKey::WindowThisWorkspace).to_string());
|
|
let query = pane_count_query(cx, workspace);
|
|
let handle = window.window_handle();
|
|
|
|
cx.spawn(async move |cx| {
|
|
let live = match query {
|
|
Some(q) => {
|
|
cx.background_spawn(async move { live_pane_count(&q) })
|
|
.await
|
|
}
|
|
None => None,
|
|
};
|
|
|
|
if live == Some(0) && verb == "Stop" {
|
|
let _ = cx.update(|cx| act(cx, workspace));
|
|
return;
|
|
}
|
|
|
|
let detail = destructive_detail(live, verb);
|
|
let verb_key = if verb == "Delete" {
|
|
L10nKey::WindowDelete
|
|
} else {
|
|
L10nKey::WindowStop
|
|
};
|
|
let verb_label = t(verb_key);
|
|
let title = t_fmt(
|
|
L10nKey::WindowConfirmTitle,
|
|
&[("verb", verb_label), ("name", &name)],
|
|
);
|
|
let Ok(answer) = handle.update(cx, |_, window, cx| {
|
|
window.prompt(
|
|
gpui::PromptLevel::Warning,
|
|
&title,
|
|
Some(&detail),
|
|
&crate::ui::confirm_answers(verb_label, t(L10nKey::Cancel)),
|
|
cx,
|
|
)
|
|
}) else {
|
|
return;
|
|
};
|
|
|
|
if let Ok(0) = answer.await {
|
|
let _ = cx.update(|cx| act(cx, workspace));
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
pub fn stop_workspace(cx: &mut App, workspace: WorkspaceId) {
|
|
let doomed = doomed_pane_ids(cx, workspace);
|
|
stop_workspace_keeping(cx, workspace, doomed);
|
|
}
|
|
|
|
fn doomed_pane_ids(cx: &App, workspace: WorkspaceId) -> Vec<u64> {
|
|
WorkspaceStore::all(cx)
|
|
.get(workspace)
|
|
.and_then(|ws| crate::ui::machine_mirror::pane_ids(cx, ws))
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn stop_workspace_keeping(cx: &mut App, workspace: WorkspaceId, ids: Vec<u64>) {
|
|
let route = crate::ui::remote_workspace::pane_route_for(cx, workspace);
|
|
let host = WorkspaceStore::all(cx)
|
|
.get(workspace)
|
|
.map(|w| w.host_id())
|
|
.unwrap_or(crate::ui::host_ops::HostId::LOCAL);
|
|
if !ids.is_empty() {
|
|
let route = route.clone();
|
|
cx.background_executor()
|
|
.spawn(async move {
|
|
for pane_id in ids {
|
|
crate::terminal::RemoteTerminal::kill_pane_on(&route, pane_id);
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
if cx
|
|
.try_global::<crate::terminal::pane_liveness::PaneLivenessCache>()
|
|
.is_some()
|
|
{
|
|
cx.update_global::<crate::terminal::pane_liveness::PaneLivenessCache, _>(|cache, _| {
|
|
cache.invalidate(host)
|
|
});
|
|
}
|
|
if let Some(app) = WindowRegistry::app_for(cx, workspace)
|
|
&& let Some(app) = app.upgrade()
|
|
{
|
|
app.read(cx).teardown_workspace_forwards(cx);
|
|
}
|
|
close_window_for(cx, workspace);
|
|
WorkspaceStore::close_window(cx, workspace);
|
|
refresh_menu(cx);
|
|
}
|
|
|
|
pub fn delete_workspace(cx: &mut App, workspace: WorkspaceId) {
|
|
let remote = WorkspaceStore::remote_ref(cx, workspace);
|
|
let doomed = delete_from_tree(cx, workspace);
|
|
stop_workspace_keeping(cx, workspace, doomed);
|
|
WorkspaceStore::remove(cx, workspace);
|
|
if let Some(remote) = remote {
|
|
scrub_host_snapshots(cx, &remote);
|
|
}
|
|
release_unused_hosts(cx);
|
|
refresh_menu(cx);
|
|
}
|
|
|
|
/// Drops a deleted workspace's row from every window's machine-listing
|
|
/// snapshot. The snapshot is captured at connect time and merged into the
|
|
/// switcher every frame, deduped against the store — so with the store entry
|
|
/// just removed, nothing holds that row back any more and the workspace the
|
|
/// user deleted pops straight back into the panel as an adoptable machine row
|
|
/// until the next reconnect replaces the snapshot. `forget_workspace` must
|
|
/// *not* do this: forgetting keeps the machine's session, and re-discovering
|
|
/// it from the listing is that flow's whole point (#485).
|
|
fn scrub_host_snapshots(cx: &mut App, remote: &crate::core::session::RemoteRef) {
|
|
let host = remote.host_id();
|
|
let machine_ws = remote.workspace;
|
|
for (_, app) in WindowRegistry::open_windows(cx) {
|
|
let Some(app) = app.upgrade() else {
|
|
continue;
|
|
};
|
|
app.update(cx, |app, cx| {
|
|
if let Some(snapshot) = app.host_snapshots.get_mut(&host) {
|
|
snapshot.rows.retain(|row| row.id != machine_ws);
|
|
cx.notify();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Drop a workspace's local bookmark without telling its machine anything.
|
|
/// `delete_workspace` goes through `fire_workspace_op`, which sends
|
|
/// `WorkspaceRemove` — and with a live link that really kills the remote
|
|
/// panes. Forgetting only stops tracking the entry here; the remote daemon
|
|
/// keeps the session, and the switcher re-discovers it from the machine's
|
|
/// own workspace list if a route to it ever exists again (#485).
|
|
pub fn forget_workspace(cx: &mut App, workspace: WorkspaceId) {
|
|
crate::ui::tree_sync::forget(cx, workspace);
|
|
WorkspaceStore::remove(cx, workspace);
|
|
release_unused_hosts(cx);
|
|
refresh_menu(cx);
|
|
}
|
|
|
|
/// The local entries routing through a profile, minus the ones a profile
|
|
/// deletion must not touch (#485): an entry with a live or in-flight link
|
|
/// keeps its bookmark — the link holds an authenticated connection rather
|
|
/// than a profile reference, and forgetting the entry would release the
|
|
/// link under any window still attached to the machine. An entry whose
|
|
/// window is still on screen keeps it too: `forget_workspace` drops the
|
|
/// store entry without closing the window, and a window whose workspace
|
|
/// the store has forgotten reads as local — its next tab would open a
|
|
/// local shell on what the user still sees as a remote box. Whatever
|
|
/// survives the deletion falls into the parked state instead, which is
|
|
/// where the entry can be dismissed deliberately.
|
|
pub fn cascade_for_profile(cx: &mut App, profile: uuid::Uuid) -> Vec<WorkspaceId> {
|
|
WorkspaceStore::all(cx)
|
|
.workspaces_via_profile(profile)
|
|
.into_iter()
|
|
.filter(|ws| {
|
|
let host = WorkspaceStore::host_of(cx, *ws);
|
|
!crate::ui::remote_workspace::link_alive_or_connecting(cx, host)
|
|
&& WindowRegistry::app_for(cx, *ws).is_none()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn delete_from_tree(cx: &mut App, workspace: WorkspaceId) -> Vec<u64> {
|
|
let doomed = doomed_pane_ids(cx, workspace);
|
|
crate::ui::tree_sync::fire_workspace_op(cx, workspace, |ws| {
|
|
tty7_core::daemon::control::ControlRequest::WorkspaceRemove { workspace: ws }
|
|
});
|
|
crate::ui::tree_sync::forget(cx, workspace);
|
|
doomed
|
|
}
|
|
|
|
fn release_unused_hosts(cx: &mut App) {
|
|
let live: Vec<_> = WorkspaceStore::all(cx)
|
|
.views
|
|
.iter()
|
|
.filter(|w| w.is_remote())
|
|
.map(|w| w.host_id())
|
|
.collect();
|
|
for id in crate::ui::host_registry::HostRegistry::ids(cx) {
|
|
if !id.is_local() && !live.contains(&id) {
|
|
crate::ui::remote_connect::HostLinks::remove(cx, id);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn close_window_for(cx: &mut App, workspace: WorkspaceId) {
|
|
let showing = WindowRegistry::app_for(cx, workspace);
|
|
let Some(handle) = WindowRegistry::window_for(cx, workspace) else {
|
|
return;
|
|
};
|
|
let Some(app) = showing.and_then(|weak| weak.upgrade()) else {
|
|
return;
|
|
};
|
|
|
|
if WindowRegistry::count(cx) > 1 {
|
|
WindowRegistry::unregister(cx, workspace);
|
|
let _ = handle.update(cx, |_, window, _| window.remove_window());
|
|
return;
|
|
}
|
|
|
|
let fresh = WorkspaceStore::claim(cx, None);
|
|
WindowRegistry::rebind(cx, workspace, fresh);
|
|
let _ = handle.update(cx, |_, window, cx| {
|
|
app.update(cx, |app, cx| {
|
|
app.adopt_workspace(fresh, crate::core::session::Session::default(), window, cx)
|
|
});
|
|
});
|
|
}
|
|
|
|
fn window_options(cx: &mut App, workspace: Option<WorkspaceId>) -> WindowOptions {
|
|
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
|
static APP_ICON: std::sync::LazyLock<Option<std::sync::Arc<image::RgbaImage>>> =
|
|
std::sync::LazyLock::new(|| {
|
|
image::load_from_memory(include_bytes!("../../assets/app-icon.png"))
|
|
.ok()
|
|
.map(|image| std::sync::Arc::new(image.thumbnail(256, 256).into_rgba8()))
|
|
});
|
|
|
|
let remember = cx.global::<Config>().remember_window_size;
|
|
let remembered = remember
|
|
.then(|| {
|
|
workspace
|
|
.and_then(|id| WorkspaceStore::all(cx).get(id).and_then(|w| w.window))
|
|
.or_else(WindowState::load)
|
|
})
|
|
.flatten();
|
|
|
|
let existing = WindowRegistry::count(cx);
|
|
let bounds = match remembered {
|
|
Some(state) => {
|
|
let bounds = at_least_min_size(state.bounds());
|
|
if cx.displays().iter().any(|d| d.bounds().intersects(&bounds)) {
|
|
bounds
|
|
} else {
|
|
Bounds::centered(None, bounds.size, cx)
|
|
}
|
|
}
|
|
None => Bounds::centered(None, size(px(DEFAULT_SIZE.0), px(DEFAULT_SIZE.1)), cx),
|
|
};
|
|
let bounds = cascade(bounds, existing);
|
|
|
|
let window_bounds = match cx.global::<Config>().startup_mode {
|
|
_ if existing > 0 => WindowBounds::Windowed(bounds),
|
|
StartupMode::Normal => WindowBounds::Windowed(bounds),
|
|
StartupMode::Maximized => WindowBounds::Maximized(bounds),
|
|
StartupMode::Fullscreen => WindowBounds::Fullscreen(bounds),
|
|
};
|
|
|
|
WindowOptions {
|
|
window_bounds: Some(window_bounds),
|
|
app_id: Some("tty7".to_owned()),
|
|
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
|
icon: APP_ICON.as_ref().cloned(),
|
|
titlebar: Some(TitlebarOptions {
|
|
traffic_light_position: Some(crate::ui::theme::traffic_light_position()),
|
|
..TitleBar::title_bar_options()
|
|
}),
|
|
// The title bar above is tty7's own, so the compositor must not add a
|
|
// second one: left unset, gpui asks Wayland for server-side
|
|
// decorations (#679). Only the Linux backends read this — macOS and
|
|
// Windows ignore the request — and X11 falls back to the window
|
|
// manager's frame on its own when no compositor is running.
|
|
window_decorations: Some(WindowDecorations::Client),
|
|
window_background: crate::ui::theme::background_appearance(cx),
|
|
window_min_size: Some(size(px(MIN_SIZE.0), px(MIN_SIZE.1))),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// Grow a remembered bound back up to `MIN_SIZE`.
|
|
///
|
|
/// `window_min_size` only governs what a *drag* may do to a window; the bounds
|
|
/// we open with are taken as given. A window that got under the minimum — an
|
|
/// older build that had no minimum, a hand-edited `views.json`, a display that
|
|
/// went away — was reopened at whatever it had been saved at, and the settings
|
|
/// page it opened onto had never been laid out for that width.
|
|
fn at_least_min_size(bounds: Bounds<gpui::Pixels>) -> Bounds<gpui::Pixels> {
|
|
Bounds {
|
|
origin: bounds.origin,
|
|
size: size(
|
|
bounds.size.width.max(px(MIN_SIZE.0)),
|
|
bounds.size.height.max(px(MIN_SIZE.1)),
|
|
),
|
|
}
|
|
}
|
|
|
|
fn cascade(bounds: Bounds<gpui::Pixels>, existing: usize) -> Bounds<gpui::Pixels> {
|
|
if existing == 0 {
|
|
return bounds;
|
|
}
|
|
let step = (existing % 5) as f32 * CASCADE_STEP;
|
|
Bounds {
|
|
origin: bounds.origin + point(px(step), px(step)),
|
|
size: bounds.size,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::core::session::{RemoteRef, RemoteTarget, WindowView, WindowViews};
|
|
use crate::ui::i18n::{L10nKey, set_locale, t_plural};
|
|
|
|
fn bounds_at(x: f32, y: f32) -> Bounds<gpui::Pixels> {
|
|
Bounds {
|
|
origin: point(px(x), px(y)),
|
|
size: size(px(800.), px(600.)),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_first_window_is_not_cascaded() {
|
|
let b = bounds_at(100., 100.);
|
|
assert_eq!(cascade(b, 0).origin, b.origin);
|
|
}
|
|
|
|
#[test]
|
|
fn each_extra_window_steps_down_and_right() {
|
|
let b = bounds_at(100., 100.);
|
|
assert_eq!(
|
|
cascade(b, 1).origin,
|
|
point(px(100. + CASCADE_STEP), px(100. + CASCADE_STEP))
|
|
);
|
|
assert_eq!(
|
|
cascade(b, 2).origin,
|
|
point(px(100. + 2. * CASCADE_STEP), px(100. + 2. * CASCADE_STEP))
|
|
);
|
|
assert_eq!(cascade(b, 3).size, b.size);
|
|
}
|
|
|
|
#[test]
|
|
fn a_remembered_bound_under_the_minimum_is_grown_back_to_it() {
|
|
// The window in the report this was fixed for: 641x830, saved and
|
|
// reopened at a width no settings page is laid out for.
|
|
let undersized = Bounds {
|
|
origin: point(px(700.), px(60.)),
|
|
size: size(px(641.), px(830.)),
|
|
};
|
|
let grown = at_least_min_size(undersized);
|
|
assert_eq!(grown.size.width, px(MIN_SIZE.0));
|
|
assert_eq!(grown.size.height, px(830.), "a tall enough height is kept");
|
|
assert_eq!(grown.origin, undersized.origin, "the corner does not move");
|
|
|
|
let short = at_least_min_size(Bounds {
|
|
origin: point(px(0.), px(0.)),
|
|
size: size(px(1200.), px(300.)),
|
|
});
|
|
assert_eq!(short.size, size(px(1200.), px(MIN_SIZE.1)));
|
|
}
|
|
|
|
#[test]
|
|
fn a_remembered_bound_at_or_over_the_minimum_is_left_alone() {
|
|
let b = bounds_at(100., 100.);
|
|
assert_eq!(at_least_min_size(b).size, b.size);
|
|
let exact = Bounds {
|
|
origin: point(px(0.), px(0.)),
|
|
size: size(px(MIN_SIZE.0), px(MIN_SIZE.1)),
|
|
};
|
|
assert_eq!(at_least_min_size(exact).size, exact.size);
|
|
}
|
|
|
|
#[test]
|
|
fn cascade_wraps_so_windows_never_march_off_screen() {
|
|
let b = bounds_at(100., 100.);
|
|
assert_eq!(cascade(b, 5).origin, b.origin);
|
|
assert_eq!(cascade(b, 6).origin, cascade(b, 1).origin);
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn a_window_asks_the_compositor_for_client_side_decorations(cx: &mut gpui::TestAppContext) {
|
|
// #679: tty7 draws its own title bar, so the request must say so —
|
|
// gpui's default asks Wayland for a server-side frame on top of it.
|
|
cx.update(|cx| {
|
|
WindowRegistry::init(cx);
|
|
let mut config = Config::default();
|
|
config.remember_window_size = false;
|
|
cx.set_global(config);
|
|
|
|
let options = window_options(cx, None);
|
|
assert_eq!(options.window_decorations, Some(WindowDecorations::Client));
|
|
assert!(
|
|
options.titlebar.is_some_and(|t| t.appears_transparent),
|
|
"the in-app title bar is what the client-side request stands in for"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn a_pathless_cli_request_restores_a_workspace_when_no_window_is_open(
|
|
cx: &mut gpui::TestAppContext,
|
|
) {
|
|
let view = WindowView::default();
|
|
let restored = view.id;
|
|
let mut opened = None;
|
|
|
|
cx.update(|cx| {
|
|
WorkspaceStore::install_for_test(
|
|
cx,
|
|
WindowViews {
|
|
views: vec![view],
|
|
active: Some(restored),
|
|
},
|
|
);
|
|
open_missing_cli_window_with(cx, None, |_, workspace, path| {
|
|
opened = Some((workspace, path));
|
|
});
|
|
});
|
|
|
|
assert_eq!(opened, Some((Some(restored), None)));
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn a_pathless_cli_request_creates_a_default_window_without_history(
|
|
cx: &mut gpui::TestAppContext,
|
|
) {
|
|
let mut opened = None;
|
|
|
|
cx.update(|cx| {
|
|
WorkspaceStore::install_for_test(cx, WindowViews::default());
|
|
open_missing_cli_window_with(cx, None, |_, workspace, path| {
|
|
opened = Some((workspace, path));
|
|
});
|
|
});
|
|
|
|
assert_eq!(opened, Some((None, None)));
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn a_reopen_with_no_window_up_restores_the_workspace_that_retired(
|
|
cx: &mut gpui::TestAppContext,
|
|
) {
|
|
// The Dock icon after the last window retired to the tray: the
|
|
// process is alive with nothing on screen, and the click has to bring
|
|
// back the layout that was there, not mint a blank workspace beside it.
|
|
// The restore saves `views.json`; run alone, this test would otherwise
|
|
// write it into the real config dir.
|
|
crate::core::config::pin_test_config_dir();
|
|
let view = WindowView::default();
|
|
let restored = view.id;
|
|
let mut opened = None;
|
|
|
|
cx.update(|cx| {
|
|
WindowRegistry::init(cx);
|
|
WorkspaceStore::install_for_test(
|
|
cx,
|
|
WindowViews {
|
|
views: vec![view],
|
|
active: Some(restored),
|
|
},
|
|
);
|
|
reopen_with(cx, |_, workspace, path| {
|
|
opened = Some((workspace, path));
|
|
});
|
|
});
|
|
|
|
assert_eq!(opened, Some((Some(restored), None)));
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn a_reopen_with_a_window_up_activates_it_instead_of_opening_another(
|
|
cx: &mut gpui::TestAppContext,
|
|
) {
|
|
// AppKit also asks while a window is still there but off screen. That
|
|
// window is the thing to activate; a second one would take the
|
|
// restore away from it.
|
|
use gpui::VisualContext as _;
|
|
|
|
let (app, mut vcx) = crate::ui::app::test_window::harness(cx);
|
|
let handle = vcx.window_handle();
|
|
app.update_in(&mut vcx, |_, _, cx| {
|
|
WindowRegistry::init(cx);
|
|
let view = WindowView::default();
|
|
let open = view.id;
|
|
WorkspaceStore::install_for_test(
|
|
cx,
|
|
WindowViews {
|
|
views: vec![view],
|
|
active: Some(open),
|
|
},
|
|
);
|
|
WindowRegistry::register(cx, open, handle, app.downgrade());
|
|
|
|
reopen_with(cx, |_, workspace, _| {
|
|
panic!("a reopen with a window up opened another for {workspace:?}");
|
|
});
|
|
assert_eq!(WindowRegistry::count(cx), 1);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn a_request_carrying_a_path_restores_the_layout_and_brings_the_path_along(
|
|
cx: &mut gpui::TestAppContext,
|
|
) {
|
|
// The Explorer "Open in tty7" case with no window up: the folder is
|
|
// wanted *and* so are the tabs that were there, which used to be
|
|
// dropped on the floor by every launch that named a directory.
|
|
let view = WindowView::default();
|
|
let restored = view.id;
|
|
let path = std::path::PathBuf::from("/tmp/somewhere");
|
|
let mut opened = None;
|
|
|
|
cx.update(|cx| {
|
|
WorkspaceStore::install_for_test(
|
|
cx,
|
|
WindowViews {
|
|
views: vec![view],
|
|
active: Some(restored),
|
|
},
|
|
);
|
|
open_missing_cli_window_with(cx, Some(path.clone()), |_, workspace, path| {
|
|
opened = Some((workspace, path));
|
|
});
|
|
});
|
|
|
|
assert_eq!(opened, Some((Some(restored), Some(path))));
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn a_path_will_not_follow_the_layout_onto_a_remote_machine(cx: &mut gpui::TestAppContext) {
|
|
// The directory is a path on this computer, so a remote workspace is
|
|
// the one restore candidate a path-carrying launch declines.
|
|
let remote = WindowView::on_remote(RemoteRef::new(
|
|
RemoteTarget::Wsl {
|
|
distro: "Ubuntu".into(),
|
|
},
|
|
WorkspaceId::new(),
|
|
));
|
|
let remote_id = remote.id;
|
|
let path = std::path::PathBuf::from("/tmp/somewhere");
|
|
let mut opened = None;
|
|
|
|
cx.update(|cx| {
|
|
WorkspaceStore::install_for_test(
|
|
cx,
|
|
WindowViews {
|
|
views: vec![remote],
|
|
active: Some(remote_id),
|
|
},
|
|
);
|
|
open_missing_cli_window_with(cx, Some(path.clone()), |_, workspace, path| {
|
|
opened = Some((workspace, path));
|
|
});
|
|
});
|
|
|
|
assert_eq!(opened, Some((None, Some(path))));
|
|
|
|
// Declining it is not the same as detaching it: the remote window is
|
|
// left exactly as it was for the next pathless launch to restore.
|
|
cx.update(|cx| {
|
|
assert!(
|
|
WorkspaceStore::all(cx)
|
|
.get(remote_id)
|
|
.is_some_and(|view| view.open),
|
|
"a declined remote candidate must not be closed on its behalf"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn the_confirmation_says_which_of_the_three_answers_it_got() {
|
|
set_locale("en");
|
|
assert_eq!(
|
|
destructive_detail(Some(1), "Stop"),
|
|
t_plural(L10nKey::WindowStopShells, 1, &[])
|
|
);
|
|
assert_eq!(
|
|
destructive_detail(Some(3), "Stop"),
|
|
t_plural(L10nKey::WindowStopShells, 3, &[])
|
|
);
|
|
assert_eq!(
|
|
destructive_detail(Some(1), "Delete"),
|
|
t_plural(L10nKey::WindowDeleteShells, 1, &[])
|
|
);
|
|
assert_eq!(
|
|
destructive_detail(Some(3), "Delete"),
|
|
t_plural(L10nKey::WindowDeleteShells, 3, &[])
|
|
);
|
|
assert_eq!(
|
|
destructive_detail(Some(0), "Delete"),
|
|
t_plural(L10nKey::WindowStopShells, 0, &[])
|
|
);
|
|
|
|
for verb in ["Stop", "Delete"] {
|
|
let detail = destructive_detail(None, verb);
|
|
assert!(
|
|
detail.contains("could not be reached"),
|
|
"{verb}: {detail:?} must say why there is no count"
|
|
);
|
|
assert!(
|
|
!detail.contains("forgotten.") || verb == "Delete",
|
|
"{verb}: {detail:?} promises a delete-only consequence"
|
|
);
|
|
assert!(
|
|
!detail.chars().any(|c| c.is_ascii_digit()),
|
|
"{verb}: {detail:?} states a count it does not have"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn a_delete_reads_its_kill_list_before_the_removal_blanks_the_mirror(
|
|
cx: &mut gpui::TestAppContext,
|
|
) {
|
|
use tty7_core::core::machine::{Machine, PaneRecord, Tab, Workspace as TreeWorkspace};
|
|
|
|
cx.update(|cx| {
|
|
let view = WindowView::default();
|
|
let id = view.id;
|
|
WorkspaceStore::install_for_test(
|
|
cx,
|
|
WindowViews {
|
|
views: vec![view],
|
|
active: None,
|
|
},
|
|
);
|
|
crate::ui::machine_mirror::MachineMirrors::install(
|
|
cx,
|
|
crate::ui::host_ops::HostId::LOCAL,
|
|
Machine {
|
|
workspaces: vec![TreeWorkspace {
|
|
id,
|
|
tabs: vec![Tab::leaf(1), Tab::leaf(2), Tab::leaf(3)],
|
|
..TreeWorkspace::default()
|
|
}],
|
|
panes: vec![PaneRecord::new(1), PaneRecord::new(2), PaneRecord::new(3)],
|
|
},
|
|
);
|
|
|
|
let doomed = delete_from_tree(cx, id);
|
|
assert_eq!(
|
|
doomed,
|
|
vec![1, 2, 3],
|
|
"every shell the confirm prompt counted must be on the kill list"
|
|
);
|
|
assert!(
|
|
doomed_pane_ids(cx, id).is_empty(),
|
|
"the removal has been folded into the mirror — which is exactly why \
|
|
the list must be read first"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn the_cascade_counts_only_the_deleted_profiles_unlinked_entries(
|
|
cx: &mut gpui::TestAppContext,
|
|
) {
|
|
// #485: deleting a profile forgets every entry routing through it —
|
|
// its own entries, all of them, and nothing else's.
|
|
cx.update(|cx| {
|
|
WindowRegistry::init(cx);
|
|
let doomed = uuid::Uuid::new_v4();
|
|
let on_doomed_a = WindowView::on_remote(RemoteRef::new(
|
|
RemoteTarget::Profile { id: doomed },
|
|
WorkspaceId::new(),
|
|
));
|
|
let on_doomed_b = WindowView::on_remote(RemoteRef::new(
|
|
RemoteTarget::Profile { id: doomed },
|
|
WorkspaceId::new(),
|
|
));
|
|
let on_other = WindowView::on_remote(RemoteRef::new(
|
|
RemoteTarget::Profile {
|
|
id: uuid::Uuid::new_v4(),
|
|
},
|
|
WorkspaceId::new(),
|
|
));
|
|
let local = WindowView::default();
|
|
let (a, b) = (on_doomed_a.id, on_doomed_b.id);
|
|
WorkspaceStore::install_for_test(
|
|
cx,
|
|
WindowViews {
|
|
views: vec![on_doomed_a, on_doomed_b, on_other, local],
|
|
active: None,
|
|
},
|
|
);
|
|
|
|
// No links exist in a test app, so nothing is excluded as
|
|
// connected or connecting.
|
|
let cascade = cascade_for_profile(cx, doomed);
|
|
assert_eq!(cascade.len(), 2, "exactly the deleted profile's entries");
|
|
assert!(cascade.contains(&a) && cascade.contains(&b));
|
|
assert!(
|
|
cascade_for_profile(cx, uuid::Uuid::new_v4()).is_empty(),
|
|
"a profile with no entries cascades to nothing"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn the_cascade_leaves_an_entry_whose_window_is_still_open(cx: &mut gpui::TestAppContext) {
|
|
// #485: forgetting an entry does not close its window, and a window
|
|
// the store has forgotten reads as local — so the one entry the user
|
|
// is looking at must survive the cascade and park instead.
|
|
use gpui::VisualContext as _;
|
|
|
|
let (app, mut vcx) = crate::ui::app::test_window::harness(cx);
|
|
let handle = vcx.window_handle();
|
|
app.update_in(&mut vcx, |_, _, cx| {
|
|
WindowRegistry::init(cx);
|
|
let doomed = uuid::Uuid::new_v4();
|
|
let onscreen = WindowView::on_remote(RemoteRef::new(
|
|
RemoteTarget::Profile { id: doomed },
|
|
WorkspaceId::new(),
|
|
));
|
|
let offscreen = WindowView::on_remote(RemoteRef::new(
|
|
RemoteTarget::Profile { id: doomed },
|
|
WorkspaceId::new(),
|
|
));
|
|
let (open, closed) = (onscreen.id, offscreen.id);
|
|
WorkspaceStore::install_for_test(
|
|
cx,
|
|
WindowViews {
|
|
views: vec![onscreen, offscreen],
|
|
active: None,
|
|
},
|
|
);
|
|
WindowRegistry::register(cx, open, handle, app.downgrade());
|
|
|
|
let cascade = cascade_for_profile(cx, doomed);
|
|
assert_eq!(
|
|
cascade,
|
|
vec![closed],
|
|
"only the entry with no window on screen is forgotten"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn forgetting_a_workspace_never_tells_its_machine(cx: &mut gpui::TestAppContext) {
|
|
// #485: the profile-deletion cascade forgets rather than deletes —
|
|
// `WorkspaceRemove` on a live link would kill the remote panes. The
|
|
// machine mirror observes every op `fire_workspace_op` fires, so "the
|
|
// mirror still lists the workspace" pins "no remove ever went out".
|
|
use tty7_core::core::machine::{Machine, Workspace as TreeWorkspace};
|
|
|
|
// `WorkspaceStore::remove` saves; a test has no business writing the
|
|
// real views.
|
|
let _ = tty7_core::core::config::set_config_dir(
|
|
std::env::temp_dir().join(format!("tty7-windows-test-{}", std::process::id())),
|
|
);
|
|
cx.update(|cx| {
|
|
let target = RemoteTarget::Profile {
|
|
id: uuid::Uuid::new_v4(),
|
|
};
|
|
let machine_ws = WorkspaceId::new();
|
|
let view = WindowView::on_remote(RemoteRef::new(target.clone(), machine_ws));
|
|
let entry = view.id;
|
|
let host = view.host_id();
|
|
WorkspaceStore::install_for_test(
|
|
cx,
|
|
WindowViews {
|
|
views: vec![view],
|
|
active: None,
|
|
},
|
|
);
|
|
crate::ui::machine_mirror::MachineMirrors::install(
|
|
cx,
|
|
host,
|
|
Machine {
|
|
workspaces: vec![TreeWorkspace {
|
|
id: machine_ws,
|
|
..TreeWorkspace::default()
|
|
}],
|
|
panes: vec![],
|
|
},
|
|
);
|
|
|
|
forget_workspace(cx, entry);
|
|
assert!(
|
|
WorkspaceStore::all(cx).get(entry).is_none(),
|
|
"the local bookmark is gone"
|
|
);
|
|
let machine = crate::ui::machine_mirror::MachineMirrors::machine(cx, host)
|
|
.expect("the mirror itself stays");
|
|
assert!(
|
|
machine.workspaces.iter().any(|w| w.id == machine_ws),
|
|
"forgetting fired no WorkspaceRemove — the machine keeps its session"
|
|
);
|
|
|
|
// Contrast: the delete path means it, and the mirror folds the
|
|
// removal in even with the control link down.
|
|
let view = WindowView::on_remote(RemoteRef::new(target, machine_ws));
|
|
let entry = view.id;
|
|
WorkspaceStore::install_for_test(
|
|
cx,
|
|
WindowViews {
|
|
views: vec![view],
|
|
active: None,
|
|
},
|
|
);
|
|
delete_from_tree(cx, entry);
|
|
let machine = crate::ui::machine_mirror::MachineMirrors::machine(cx, host).unwrap();
|
|
assert!(
|
|
!machine.workspaces.iter().any(|w| w.id == machine_ws),
|
|
"delete_from_tree noted the remove it fired"
|
|
);
|
|
});
|
|
}
|
|
}
|