Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,9 @@ jobs:
stale_connection_retry_does_not_restart_an_in_flight_or_connected_browser
running_command_keeps_host_identity_in_pane_chrome
cli_agent_summary_does_not_replace_terminal_host_identity
pane_identity_follows_the_shell_across_a_manual_ssh_hop_and_exit
bound_host_names_only_the_panes_own_shell
file_manager_mode_keeps_the_host_and_directory_identity
confirm_payload_carries_local_selection
confirm_payload_routes_remote_launch_to_node_id
confirm_payload_none_when_nothing_installed
Expand Down
2 changes: 0 additions & 2 deletions app/i18n/de/warp.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -1035,7 +1035,6 @@ fm-transfer-completed-source-restored = Ziel vollständig, Quelle wiederhergeste
fm-transfer-failed = Fehlgeschlagen
fm-transfer-cancelled = Abgebrochen
fm-transfer-title = Übertragungen ({ $count })
fm-title-sftp = SFTP: { $path }
fm-status-connecting = Verbinde…
fm-status-disconnected = Getrennt
fm-status-loading = Lädt…
Expand Down Expand Up @@ -3250,7 +3249,6 @@ skill-manager-meta-duplicate = Duplikat
skill-manager-empty = Keine Skills entsprechen den aktuellen Filtern.
skill-manager-preview-empty = Wähle einen Skill aus, um SKILL.md in der Vorschau anzuzeigen.
menu-pane-open-file-manager = Dateimanager hier öffnen
sftp-local-file-manager-title = Dateien (lokal)
search-filter-placeholder-ssh-servers = SSH-Server durchsuchen…
search-filter-display-ssh-servers = SSH-Server
terminal-su-root-password-confirm = Root-Passwort automatisch ausfüllen
Expand Down
2 changes: 0 additions & 2 deletions app/i18n/en/warp.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -2992,7 +2992,6 @@ workspace-left-panel-ssh-manager-menu-connect = Connect
workspace-left-panel-ssh-manager-menu-sessions = Zaplex sessions
workspace-left-panel-ssh-manager-menu-refresh-sessions = Refresh Zaplex sessions
menu-pane-open-file-manager = Open file manager here
sftp-local-file-manager-title = Files (local)
sftp-context-menu-open = Open
sftp-context-menu-download = Download
sftp-context-menu-rename = Rename
Expand Down Expand Up @@ -4282,7 +4281,6 @@ fm-transfer-completed-source-restored = Destination complete, source restored
fm-transfer-failed = Failed
fm-transfer-cancelled = Cancelled
fm-transfer-title = Transfers ({ $count })
fm-title-sftp = SFTP: { $path }
fm-status-connecting = Connecting…
fm-status-disconnected = Disconnected
fm-status-loading = Loading…
Expand Down
21 changes: 18 additions & 3 deletions app/src/pane_group/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7045,10 +7045,25 @@ impl PaneGroup {
.into_iter()
.filter_map(|pane_id| {
let owner = self.panes.pane_configuration_owner(pane_id);
let terminal = self.downcast_pane_by_id::<TerminalPane>(owner)?;
let configuration = terminal.pane_configuration();
let terminal = self.downcast_pane_by_id::<TerminalPane>(owner);
// A file manager shows its own `host · directory`. Over a terminal
// it keeps that terminal's persistent session as its suffix key;
// a standalone one has no persistent session, only its pane.
let (configuration, session) = match self.downcast_pane_by_id::<SftpPane>(pane_id) {
Some(file_manager) => (
file_manager.pane_configuration(),
terminal.map_or_else(
|| pane_id.to_string().into_bytes(),
TerminalPane::session_uuid,
),
),
None => {
let terminal = terminal?;
(terminal.pane_configuration(), terminal.session_uuid())
}
};
let (short, full) = configuration.as_ref(ctx).terminal_identity()?.clone();
Some((configuration, (terminal.session_uuid(), short, full)))
Some((configuration, (session, short, full)))
})
.collect();
let identities: Vec<_> = terminals
Expand Down
119 changes: 78 additions & 41 deletions app/src/sftp_manager/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use crate::pane_group::focus_state::PaneFocusHandle;
use crate::pane_group::pane::view;
use crate::pane_group::{BackingView, PaneConfiguration, PaneEvent};
use crate::remote_server::manager::{RemoteServerManager, RemoteServerManagerEvent};
use crate::terminal::view::terminal_identity;
use crate::view_components::DismissibleToast;
use crate::workspace::ToastStack;
use instant::Instant;
Expand Down Expand Up @@ -1074,26 +1075,36 @@ pub struct SftpBrowserView {
pick_resolved: bool,
}

/// The display name of an SSH registry node, for the tab title.
/// The host label of an SSH registry node, for the pane and tab title.
///
/// Read from the registry rather than carried in: the browser is opened from
/// several places, and a name threaded through each of them is a name that can
/// be threaded through wrong. `None` when the node is gone or the registry can't
/// be read — the caller then shows a generic title instead of an empty one.
fn host_name_for_node(node_id: &str) -> Option<String> {
/// Terminal panes on a node are labelled with its SSH host, so the file manager
/// uses the same field and both read as the same machine; the node's display
/// name only stands in when no host is configured. Read from the registry
/// rather than carried in: the browser is opened from several places, and a
/// name threaded through each of them is a name that can be threaded through
/// wrong. `None` when the node is gone or the registry can't be read — the
/// caller then shows a generic label instead of an empty one.
fn host_label_for_node(node_id: &str) -> Option<String> {
if node_id.is_empty() {
return None;
}
warp_ssh_manager::with_conn(|c| Ok(warp_ssh_manager::SshRepository::list_nodes(c)?))
.ok()?
.into_iter()
.find(|n| n.id == node_id)
// A blank name is not a title. The editor rejects one, but the
// repository takes any string, so an entry written another way could
// hand back "" — and `Some("")` would beat the fallback and leave an
// empty tab.
.map(|n| n.name)
.filter(|n| !n.trim().is_empty())
warp_ssh_manager::with_conn(|c| {
if let Some(server) = SshRepository::get_server(c, node_id)? {
if !server.host.trim().is_empty() {
return Ok(Some(server.host));
}
}
Ok(SshRepository::list_nodes(c)?
.into_iter()
.find(|n| n.id == node_id)
.map(|n| n.name))
})
.ok()
.flatten()
// A blank name is not a label. The editor rejects one, but the repository
// takes any string, so an entry written another way could hand back "" —
// and `Some("")` would beat the fallback and leave an empty host.
.filter(|label| !label.trim().is_empty())
}

impl SftpBrowserView {
Expand Down Expand Up @@ -1123,20 +1134,52 @@ impl SftpBrowserView {
.retain(|path, _| visible.contains(path));
}

/// Bind the host this pane browses, like a terminal pane's bound host.
fn set_identity_host(&self, host: String, ctx: &mut ViewContext<Self>) {
self.pane_configuration.update(ctx, |configuration, ctx| {
configuration.set_terminal_identity_host(Some(host), ctx);
});
self.update_pane_identity(ctx);
}

/// Name this pane like a terminal pane on the same host, `host · directory`,
/// through the shared terminal identity rule: two file managers in a
/// multi-host tab stay distinguishable, and the pane group disambiguates
/// real collisions the same way it does for terminals.
fn update_pane_identity(&self, ctx: &mut ViewContext<Self>) {
let path = self.current_path.display().to_string();
// Write the directory like a local terminal does (`~` for home), so both
// modes of a pane name it alike. A remote home is not known here.
let home = self.node_id.is_empty().then(dirs::home_dir).flatten();
let path =
warp_util::path::user_friendly_path(&path, home.as_deref().and_then(Path::to_str))
.into_owned();
self.pane_configuration.update(ctx, |configuration, ctx| {
let host = configuration
.terminal_identity_host()
.unwrap_or_default()
.to_owned();
let identity =
terminal_identity(&host, Some(&path), &crate::t!("sftp-file-manager-title"));
configuration.set_terminal_identity(identity.short, identity.full, ctx);
});
}

/// Create a new SFTP browser view, opened at `start_path` (the remote
/// shell's cwd) when known, else the host root `/`.
pub fn new(node_id: String, start_path: Option<PathBuf>, ctx: &mut ViewContext<Self>) -> Self {
// The tab keeps saying which HOST this is; that it happens to be showing
// files rather than a shell is the pane's own obvious business (spec v3
// FM). Titling it "File Manager" dropped the host entirely — with two
// browsers open you could not tell which machine either was on, which is
// the one thing a tab title has to answer here.
// The pane and tab keep saying which HOST this is; that it happens to be
// showing files rather than a shell is the pane's own obvious business
// (spec v3 FM). Titling it "File Manager" dropped the host entirely — with
// two browsers open you could not tell which machine either was on, which
// is the one thing a title has to answer here.
//
// A host whose registry entry has gone (deleted while open) falls back to
// the generic title rather than a blank tab.
let title = host_name_for_node(&node_id)
.unwrap_or_else(|| crate::t!("sftp-file-manager-title").to_string());
let pane_configuration = ctx.add_model(|_ctx| PaneConfiguration::new(title));
// the generic remote label rather than a blank host.
let host = host_label_for_node(&node_id)
.unwrap_or_else(|| crate::t!("terminal-remote-session-label").to_string());
let pane_configuration =
ctx.add_model(|_ctx| PaneConfiguration::new(crate::t!("sftp-file-manager-title")));
let rename_editor = make_editor(&crate::t!("fm-rename-placeholder"), ctx);
let new_folder_editor = make_editor(&crate::t!("fm-folder-name-placeholder"), ctx);
let search_editor = make_editor(&crate::t!("fm-search-placeholder"), ctx);
Expand Down Expand Up @@ -1232,6 +1275,7 @@ impl SftpBrowserView {
pick_resolved: false,
};
me.sync_breadcrumb_mouse_handles();
me.set_identity_host(host, ctx);

// Subscribe to rename editor events
let rename_editor_handle = me.rename_editor.clone();
Expand Down Expand Up @@ -1344,8 +1388,8 @@ impl SftpBrowserView {
// `None` start_path: `new_local` sets `current_path`/`path_history` from
// its own `start_path` below, so `new`'s value would be overwritten.
let mut me = Self::new(String::new(), None, ctx);
me.pane_configuration = ctx
.add_model(|_ctx| PaneConfiguration::new(crate::t!("sftp-local-file-manager-title")));
me.pane_configuration =
ctx.add_model(|_ctx| PaneConfiguration::new(crate::t!("sftp-file-manager-title")));
let backend = Arc::new(
crate::sftp_manager::sftp_backend::InMemorySftpBackend::for_local_filesystem(),
) as Arc<dyn SftpBackend>;
Expand All @@ -1356,6 +1400,8 @@ impl SftpBrowserView {
me.path_history = vec![start_path];
me.history_index = 0;
me.sync_breadcrumb_mouse_handles();
// Same host label as a local terminal pane.
me.set_identity_host(crate::t!("cockpit-spawn-card-host-local").to_string(), ctx);
me.refresh_dir(ctx);
me
}
Expand Down Expand Up @@ -1433,11 +1479,7 @@ impl SftpBrowserView {
}
Err(_) => {}
}
let path = self.current_path.display();
let title = crate::t!("fm-title-sftp", path = path.to_string());
self.pane_configuration.update(ctx, |config, ctx| {
config.set_title(title, ctx);
});
self.update_pane_identity(ctx);
self.publish_to_registry(ctx);
ctx.notify();
}
Expand Down Expand Up @@ -1935,11 +1977,7 @@ impl SftpBrowserView {
};

if installed {
let path = self.current_path.display();
let title = crate::t!("fm-title-sftp", path = path.to_string());
self.pane_configuration.update(ctx, |config, ctx| {
config.set_title(title, ctx);
});
self.update_pane_identity(ctx);
self.publish_to_registry(ctx);
}
if navigation_commit_needs_snapshot(commits_navigation, installed) {
Expand Down Expand Up @@ -7115,11 +7153,10 @@ impl BackingView for SftpBrowserView {
fn render_header_content(
&self,
_ctx: &view::HeaderRenderContext<'_>,
_app: &AppContext,
app: &AppContext,
) -> view::HeaderContent {
let path = self.current_path.display();
let title = crate::t!("fm-title-sftp", path = path.to_string());
view::HeaderContent::simple(title)
// The configured title carries the pane group's collision disambiguation.
view::HeaderContent::simple(self.pane_configuration.as_ref(app).title().to_owned())
}

/// Set the focus handle
Expand Down
13 changes: 10 additions & 3 deletions app/src/terminal/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11101,6 +11101,13 @@ impl TerminalView {
.as_ref()
.and_then(BlockMetadata::current_working_directory)
!= block_metadata.current_working_directory();
// A manual `ssh` hop or an `exit` back moves the prompt to another
// shell session, possibly with the same directory string.
let session_changed = self
.active_block_metadata
.as_ref()
.and_then(BlockMetadata::session_id)
!= block_metadata.session_id();

if let Some(prev_block_metadata) = self.active_block_metadata.take() {
// Only send event to save app state when the block is post bootstrap
Expand Down Expand Up @@ -11251,9 +11258,9 @@ impl TerminalView {
ctx.notify();
});

// The precmd metadata is the first signal of a `cd`; the pane identity
// must not wait for an OSC title or the next block.
if working_directory_changed {
// The precmd metadata is the first signal of a `cd` or a host change;
// the pane identity must not wait for an OSC title or the next block.
if working_directory_changed || session_changed {
self.update_pane_configuration(ctx);
}
}
Expand Down
76 changes: 69 additions & 7 deletions app/src/terminal/view/pane_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ use crate::pane_group::pane::view::header::components::{
};
use crate::pane_group::pane::PaneStack;
use crate::pane_group::{pane::view, pane::view::PaneHeaderAction, BackingView, SplitPaneState};
use crate::terminal::model::session::Session;
use crate::terminal::model::terminal_model::ConversationTranscriptViewerStatus;
use crate::terminal::shared_session::participant_avatar_view::render_participants_and_role_elements;
use crate::terminal::shared_session::render_util::shared_session_indicator_color;
use crate::terminal::shared_session::SharedSessionActionSource;
use crate::terminal::ssh::util::parse_interactive_ssh_command;
use crate::terminal::TerminalManager;
use crate::terminal::TerminalView;
use crate::ui_components::blended_colors;
Expand Down Expand Up @@ -93,15 +95,10 @@ impl TerminalView {

/// Keep automatic terminal chrome tied to its host and directory.
pub(crate) fn update_pane_configuration(&mut self, ctx: &mut ViewContext<Self>) {
let identity_host = self
.pane_configuration
.as_ref(ctx)
.terminal_identity_host()
.map(str::to_owned)
.unwrap_or_else(|| crate::t!("cockpit-spawn-card-host-local").to_string());
let (identity_host, working_directory) = self.identity_host_and_directory(ctx);
let identity = super::tab_metadata::terminal_identity(
&identity_host,
self.display_working_directory(ctx).as_deref(),
working_directory.as_deref(),
&crate::t!("workspace-new-session-terminal"),
);
// OSC titles and agent summaries describe activity, not pane identity.
Expand All @@ -117,6 +114,71 @@ impl TerminalView {
self.update_agent_view_pane_header(ctx);
}

/// The host and directory the pane identity names.
///
/// Zaplex binds a host label when it opens or restores a remote pane. That
/// label holds while the active shell is provably the pane's own shell on
/// that host, by the same proof the file manager uses before it hands a
/// directory back. A manual `ssh` hop or an `exit` back moves the prompt to
/// another bootstrapped session, which names its own host.
fn identity_host_and_directory(&self, ctx: &AppContext) -> (String, Option<String>) {
let local_host = || crate::t!("cockpit-spawn-card-host-local").to_string();
let bound_host = self
.pane_configuration
.as_ref(ctx)
.terminal_identity_host()
.map(str::to_owned);
let directory = self.display_working_directory(ctx);
// Until this pane's own classic ssh connection bootstraps, its local
// shell only carries that connection; the bound host is what Zaplex knows.
let awaiting_own_ssh = bound_host.is_some()
&& self.remote_input_session_id.is_none()
&& self.classic_ssh_root_session_id.is_none();
let Some(session) = self
.active_block_session_id()
.and_then(|id| self.sessions.as_ref(ctx).get(id))
.filter(|_| !awaiting_own_ssh)
else {
return (bound_host.unwrap_or_else(local_host), directory);
};
if self.is_running_unbootstrapped_ssh(&session) {
// No shell integration reports where this ssh landed: name neither a
// host nor the directory it was started from.
return (crate::t!("terminal-remote-session-label").to_string(), None);
}
if self.file_manager_session_matches_target(bound_host.is_none(), ctx) {
return (bound_host.unwrap_or_else(local_host), directory);
}
if session.is_local() {
return (local_host(), directory);
}
let hostname = session.hostname().trim();
let host = if hostname.is_empty() {
crate::t!("terminal-remote-session-label").to_string()
} else {
hostname.to_owned()
};
(host, directory)
}

/// Whether the foreground command is an interactive ssh whose remote shell
/// has not bootstrapped, by the detection that offers Zaplexify (aliases
/// expanded). Once the remote shell bootstraps, its own session takes over.
fn is_running_unbootstrapped_ssh(&self, session: &Session) -> bool {
if !self.is_long_running() {
return false;
}
let command = self
.model
.lock()
.block_list()
.active_block()
.command_to_string();
let expanded = super::command_first_word_and_suffix(&command)
.and_then(|(word, rest)| Some(format!("{}{rest}", session.alias_value(word)?)));
parse_interactive_ssh_command(expanded.as_deref().unwrap_or(&command)).is_some()
}

/// Updates the pane header's shareable object based on agent view state.
/// This should be called when entering/exiting agent view or when the conversation changes.
pub(super) fn update_agent_view_pane_header(&mut self, ctx: &mut ViewContext<Self>) {
Expand Down
Loading
Loading