From e04256ddea7ddd23c0123810f8d68f9f126cc210 Mon Sep 17 00:00:00 2001 From: iret77 <63622643+iret77@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:42:48 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(ui):=20Pane-Identit=C3=A4t=20im=20Datei?= =?UTF-8?q?manager-Modus=20und=20bei=20Hostwechsel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Dateimanager-Modus zeigt dieselbe Identität `Host · Verzeichnis` wie die Terminal-Pane, lokal und remote, über die zentrale Ableitung `terminal_identity`. Die Pane-Gruppe löst Kollisionen auch für Dateimanager-Panes auf; nur eine echte Kollision zeigt den vollen Pfad. Das gebundene Hostlabel gilt nur, solange die aktive Shell die eigene Shell der Pane ist. Ein manuelles `ssh` oder `exit` benennt den Host, den die aktive Shell beim Bootstrap meldet; ein Sessionwechsel mit gleichem Verzeichnis aktualisiert die Anzeige. Ein interaktives `ssh` ohne Shell-Integration zeigt ehrlich `Remote-Session · Terminal`. Regressionstests, CI-Testauswahl und Spezifikation (GH-160, HTML-Spec P3) nachgezogen. Compile und Tests nicht gelaufen; sie folgen im nächsten gebündelten ci-batch. Refs #461 --- .github/workflows/pr-check.yml | 3 + app/i18n/de/warp.ftl | 2 - app/i18n/en/warp.ftl | 2 - app/src/pane_group/mod.rs | 21 +++- app/src/sftp_manager/browser.rs | 113 ++++++++++++------- app/src/terminal/view.rs | 13 ++- app/src/terminal/view/pane_impl.rs | 76 +++++++++++-- app/src/terminal/view_test.rs | 135 +++++++++++++++++++++++ app/src/workspace/view_test.rs | 66 +++++++++++ docs/ui/cockpit-sidebar-connections.html | 2 +- specs/GH-160/PRODUCT.md | 6 + specs/GH-160/TECH.md | 17 +++ 12 files changed, 397 insertions(+), 59 deletions(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index ab46726185b..ecc15588822 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -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 diff --git a/app/i18n/de/warp.ftl b/app/i18n/de/warp.ftl index ebaf08ddba6..6c508ac74e0 100644 --- a/app/i18n/de/warp.ftl +++ b/app/i18n/de/warp.ftl @@ -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… @@ -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 diff --git a/app/i18n/en/warp.ftl b/app/i18n/en/warp.ftl index f1b63233bf4..fe8efe5e36c 100644 --- a/app/i18n/en/warp.ftl +++ b/app/i18n/en/warp.ftl @@ -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 @@ -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… diff --git a/app/src/pane_group/mod.rs b/app/src/pane_group/mod.rs index b0a557b9b4d..ef5b020c93f 100644 --- a/app/src/pane_group/mod.rs +++ b/app/src/pane_group/mod.rs @@ -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::(owner)?; - let configuration = terminal.pane_configuration(); + let terminal = self.downcast_pane_by_id::(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::(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 diff --git a/app/src/sftp_manager/browser.rs b/app/src/sftp_manager/browser.rs index 0f29dbb6c7d..ab67e1673ab 100644 --- a/app/src/sftp_manager/browser.rs +++ b/app/src/sftp_manager/browser.rs @@ -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; @@ -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 { +/// 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 { 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 { @@ -1123,20 +1134,46 @@ 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.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) { + let path = self.current_path.display().to_string(); + 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, ctx: &mut ViewContext) -> 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); @@ -1232,6 +1269,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(); @@ -1344,8 +1382,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; @@ -1356,6 +1394,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 } @@ -1433,11 +1473,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(); } @@ -1935,11 +1971,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) { @@ -7115,11 +7147,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 diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 6ea454b7f2e..734c48b0059 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -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 @@ -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); } } diff --git a/app/src/terminal/view/pane_impl.rs b/app/src/terminal/view/pane_impl.rs index 9b35cfcacb7..59286ee6cbc 100644 --- a/app/src/terminal/view/pane_impl.rs +++ b/app/src/terminal/view/pane_impl.rs @@ -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; @@ -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) { - 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. @@ -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) { + 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) { diff --git a/app/src/terminal/view_test.rs b/app/src/terminal/view_test.rs index f2dee16494c..b2b53b034bb 100644 --- a/app/src/terminal/view_test.rs +++ b/app/src/terminal/view_test.rs @@ -143,6 +143,141 @@ fn cli_agent_summary_does_not_replace_terminal_host_identity() { }); } +/// Delivers a real precmd metadata event and returns the pane title it leaves. +fn pane_title_after_prompt( + app: &mut App, + terminal: &ViewHandle, + session: SessionId, + directory: &str, +) -> String { + let directory = directory.to_string(); + terminal.update(&mut *app, |view, ctx| { + let block_index = view.model.lock().block_list().active_block().index(); + view.model_event_dispatcher().update(ctx, |_, ctx| { + ctx.emit(ModelEvent::BlockMetadataReceived( + crate::terminal::event::BlockMetadataReceivedEvent { + block_metadata: BlockMetadata::new(Some(session), Some(directory)), + block_index, + is_after_in_band_command: false, + is_done_bootstrapping: false, + }, + )); + }); + }); + terminal.read(&*app, |view, ctx| { + view.pane_configuration.as_ref(ctx).title().to_owned() + }) +} + +#[test] +fn pane_identity_follows_the_shell_across_a_manual_ssh_hop_and_exit() { + crate::i18n::init(Some("en")); + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let terminal = add_window_with_terminal(&mut app, None); + let local = SessionId::from(9201u64); + let hop = SessionId::from(9202u64); + terminal.update(&mut app, |view, ctx| { + view.sessions.update(ctx, |sessions, _| { + sessions.register_session_for_test(SessionInfo::new_for_test().with_id(local)); + sessions.register_session_for_test( + SessionInfo::new_for_test() + .with_id(hop) + .with_session_type(BootstrapSessionType::ZaplexifiedRemote) + .with_hostname("worknode".to_string()), + ); + }); + }); + + assert_eq!( + pane_title_after_prompt(&mut app, &terminal, local, "/srv/api"), + "Local · api" + ); + // Same directory string, another shell: the hop alone must rename the pane. + assert_eq!( + pane_title_after_prompt(&mut app, &terminal, hop, "/srv/api"), + "worknode · api" + ); + assert_eq!( + pane_title_after_prompt(&mut app, &terminal, local, "/srv/api"), + "Local · api" + ); + + terminal.update(&mut app, |view, ctx| { + // An ssh whose remote shell never bootstraps reports no host and no + // directory; the pane must not keep claiming the local one. + view.model + .lock() + .simulate_long_running_block("ssh build.example.test", ""); + view.maybe_emit_terminal_view_state_changed_for_long_running_block(ctx); + assert_eq!( + view.pane_configuration.as_ref(ctx).title(), + "Remote session · Terminal" + ); + + view.model.lock().finish_block(); + view.update_pane_configuration(ctx); + assert_eq!(view.pane_configuration.as_ref(ctx).title(), "Local · api"); + }); + }); +} + +#[test] +fn bound_host_names_only_the_panes_own_shell() { + crate::i18n::init(Some("en")); + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let terminal = add_window_with_terminal(&mut app, None); + let local = SessionId::from(9301u64); + let root = SessionId::from(9302u64); + let hop = SessionId::from(9303u64); + terminal.update(&mut app, |view, ctx| { + view.sessions.update(ctx, |sessions, _| { + sessions.register_session_for_test(SessionInfo::new_for_test().with_id(local)); + for (id, hostname) in [(root, "ip-10-0-0-5"), (hop, "worknode")] { + sessions.register_session_for_test( + SessionInfo::new_for_test() + .with_id(id) + .with_session_type(BootstrapSessionType::ZaplexifiedRemote) + .with_hostname(hostname.to_string()), + ); + } + }); + view.pane_configuration.update(ctx, |configuration, ctx| { + configuration + .set_terminal_identity_host(Some("build.example.test".to_string()), ctx); + }); + }); + + // Until the pane's own ssh bootstraps, its local shell carries that connection. + assert_eq!( + pane_title_after_prompt(&mut app, &terminal, local, "/home/dev"), + "build.example.test · dev" + ); + terminal.update(&mut app, |view, _| { + view.classic_ssh_root_session_id = Some(root); + }); + // The pane's own shell keeps the bound label, not its raw hostname. + assert_eq!( + pane_title_after_prompt(&mut app, &terminal, root, "/srv/api"), + "build.example.test · api" + ); + assert_eq!( + pane_title_after_prompt(&mut app, &terminal, hop, "/srv/api"), + "worknode · api" + ); + assert_eq!( + pane_title_after_prompt(&mut app, &terminal, root, "/srv/api"), + "build.example.test · api" + ); + // `exit` out of the pane's own ssh leaves the local shell. + assert_eq!( + pane_title_after_prompt(&mut app, &terminal, local, "/home/dev"), + "Local · dev" + ); + }); +} + #[test] fn classic_ssh_phase_updates_are_allowed_only_while_unbound() { let daemon_session = warp_core::SessionId::from(41u64); diff --git a/app/src/workspace/view_test.rs b/app/src/workspace/view_test.rs index 64b5d6236a8..6658477686f 100644 --- a/app/src/workspace/view_test.rs +++ b/app/src/workspace/view_test.rs @@ -6707,6 +6707,72 @@ fn file_manager_round_trip_does_not_revalidate_an_old_split_target() { }); } +fn pane_title(group: &PaneGroup, pane_id: PaneId, ctx: &AppContext) -> String { + group + .pane_by_id(pane_id) + .unwrap() + .pane_configuration() + .as_ref(ctx) + .title() + .to_owned() +} + +#[test] +fn file_manager_mode_keeps_the_host_and_directory_identity() { + crate::i18n::init(Some("en")); + App::test((), |mut app| async move { + initialize_app(&mut app); + app.add_singleton_model(|_| crate::sftp_manager::fm_registry::FileManagerRegistry::new()); + let root = tempfile::tempdir().unwrap(); + let first = root.path().join("one").join("api"); + let second = root.path().join("two").join("api"); + for directory in [&first, &second] { + std::fs::create_dir_all(directory).unwrap(); + } + let workspace = mock_workspace(&mut app); + workspace.update(&mut app, |workspace, ctx| { + let group = workspace.active_tab_pane_group().clone(); + group.update(ctx, |group, ctx| { + let shell = group.focused_pane_id(ctx); + let neighbor: PaneId = group + .add_terminal_pane_ignoring_default_session_mode(Direction::Right, None, ctx) + .into(); + + group.open_file_manager_in_place( + shell, + crate::pane_group::FileManagerTarget::Local { + start_path: first.clone(), + }, + ctx, + ); + let first_manager = group.focused_pane_id(ctx); + assert_eq!(pane_title(group, first_manager, ctx), "Local · api"); + assert_eq!(group.display_title(ctx), "Local · api"); + + // A second file manager on the same host and basename is a real + // collision: both expose their full path, the tab follows focus. + group.open_file_manager_in_place( + neighbor, + crate::pane_group::FileManagerTarget::Local { + start_path: second.clone(), + }, + ctx, + ); + let second_manager = group.focused_pane_id(ctx); + let first_full = format!("Local · {}", first.display()); + let second_full = format!("Local · {}", second.display()); + assert_eq!(pane_title(group, first_manager, ctx), first_full); + assert_eq!(pane_title(group, second_manager, ctx), second_full); + assert_eq!(group.display_title(ctx), second_full); + + // Leaving file manager mode ends the collision. + group.close_pane(second_manager, ctx); + assert_eq!(pane_title(group, first_manager, ctx), "Local · api"); + }); + }); + }); +} + #[test] fn hiding_and_showing_a_neighbor_invalidates_captured_split_layouts() { App::test((), |mut app| async move { diff --git a/docs/ui/cockpit-sidebar-connections.html b/docs/ui/cockpit-sidebar-connections.html index b6406df35c8..16e90b83acb 100644 --- a/docs/ui/cockpit-sidebar-connections.html +++ b/docs/ui/cockpit-sidebar-connections.html @@ -80,7 +80,7 @@

Tabs und Panes

  • #spec P1 Ein Tab ist Container für Terminal-, Dateimanager- und Konto-Panes verschiedener Hosts; er ist selbst kein Host. Keine neue globale Navigation.
  • #spec P2 Pane-Split in jeder der vier Richtungen öffnet dasselbe Startmenü wie „+“ (siehe pane-launch-and-tab-join.html) und erfasst auslösenden Tab, Pane, Richtung und stabilen Host oder Lokal. Späterer Fokuswechsel lenkt das Ziel nicht um; Abbruch erzeugt nichts.
  • -
  • #spec P3 Terminalidentität ist Host · Projekt/Verzeichnis. Der automatische Tabtitel folgt der fokussierten Pane; ein expliziter Titel hat Vorrang.
  • +
  • #spec P3 Terminalidentität ist Host · Projekt/Verzeichnis, auch im Dateimanager-Modus (lokal Lokal · Verzeichnis). Nur eine echte Kollision zeigt den vollen Pfad. Wechselt die Shell den Host (manuelles ssh, exit), nennt die Pane den Host der aktiven Shell; ohne Shell-Integration auf dem Ziel steht ehrlich Remote-Session · Terminal, nie ein falscher Host oder der alte Ordner. Der automatische Tabtitel folgt der fokussierten Pane; ein expliziter Titel hat Vorrang.
  • #spec P4 Eine Pane füllt die Arbeitsfläche. Header-Drag verschiebt dieselbe Pane links/rechts/oben/unten oder in einen anderen Tab, ohne Verbindung, PTY oder Agent zu duplizieren. Umgekehrt fügt ein in den sichtbaren Tab gezogener Tab seine Panes dort ein.
  • #spec P5 Fokus und Restore bewahren Pane-Geometrie, Host-/Daemon-/PTY-/Generationsidentität, Modus und Entwurf. Konto-Klick fügt die Konto-Pane hinzu oder fokussiert sie, ohne andere Panes zu ersetzen.
diff --git a/specs/GH-160/PRODUCT.md b/specs/GH-160/PRODUCT.md index 846b7d1b881..f52a5d7f60d 100644 --- a/specs/GH-160/PRODUCT.md +++ b/specs/GH-160/PRODUCT.md @@ -390,3 +390,9 @@ The previous static illustrations are superseded, not alternative approved layou - Übernommene Sessions zeigen tatsächlichen Verbindungs-/Installationsfortschritt. Die bestehende Startfrist von 60 Sekunden bleibt erhalten und beendet keine entfernte Session. - Lokale Cockpit-Daten erscheinen vor Remote-Abfragen. Hosts werden parallel abgefragt und einzeln veröffentlicht; jede Inventar-RPC hat eine Frist von zehn Sekunden. Ein fehlerhafter Host bleibt mit ehrlichem Fehlerstatus sichtbar und löscht nur sein eigenes veraltetes Inventar. Ergebnisse veralteter Topologien bleiben verworfen. - Die SFTP-Prüfung bekannter Schlüssel skaliert linear mit der Datei. Gehashte Hosts, Aliaslisten, Ports und unterschiedliche Algorithmen behalten ihre bisherigen Identitätsgrenzen; bestätigte Schlüsselabweichungen werden nicht automatisch übernommen. + +## Pane-Identität im Dateimanager-Modus und bei Hostwechsel (#461) + +- Der Dateimanager-Modus behält die Identität der Pane: `Host · Verzeichnis`, lokal `Lokal · Verzeichnis`, remote mit demselben Hostlabel wie eine Terminal-Pane auf diesem Host. Ein Tab mit zwei Dateimanager-Panes verschiedener Hosts oder Verzeichnisse bleibt unterscheidbar. Nur eine echte Kollision derselben Kurzidentität zeigt den vollen Pfad; gleiche volle Identitäten erhalten den stabilen Session-Suffix aus §33. Tabtitel und Pane-Kopfzeile zeigen dieselbe aufgelöste Identität. +- Das von Zaplex beim Öffnen oder Wiederherstellen gebundene Hostlabel gilt nur, solange die aktive Shell nachweislich die eigene Shell der Pane auf diesem Host ist. Ein manuelles `ssh` mit Shell-Integration auf dem Ziel benennt den Host, den diese Shell beim Bootstrap meldet. `exit` zurück in die lokale Shell zeigt wieder `Lokal`, zurück in die eigene Remote-Shell wieder das gebundene Label. Das gilt auch, wenn das Verzeichnis gleich lautet. +- Grenze: Ein interaktives `ssh` ohne Shell-Integration auf dem Ziel meldet weder Host noch Verzeichnis. Die Pane zeigt dann ehrlich `Remote-Session · Terminal` statt des lokalen Hosts und des alten Ordners, bis der Befehl endet oder die entfernte Shell bootstrappt. Weitere Sprünge innerhalb einer solchen Sitzung sind unsichtbar. Erkannt werden die Befehle, die auch Zaplexify anbietet (`ssh` samt Alias, `gcloud compute ssh`, `eb ssh`, `doctl compute ssh`); andere Werkzeuge wie `mosh` bleiben unerkannt. Eine klassische SSH-Pane, deren eigene Verbindung nie bootstrappt, behält ihr gebundenes Label. diff --git a/specs/GH-160/TECH.md b/specs/GH-160/TECH.md index f64437c499e..5e97ba8a89d 100644 --- a/specs/GH-160/TECH.md +++ b/specs/GH-160/TECH.md @@ -657,3 +657,20 @@ or kdialog: without a dialog provider the diagnostic remains on stderr/in the lo still exits safely. Native visibility is therefore not guaranteed on such installations. This change covers database initialization failures; existing read-state and writer-start degradation paths are unchanged. + +## Pane-Identität im Dateimanager-Modus und bei Hostwechsel (#461) + +Alle Pane-Titel laufen über `terminal::view::terminal_identity(host, cwd, fallback)`; die Signatur bleibt unverändert, weitere Konsumenten (Sessionliste unter Verbindungen) nutzen sie weiter. + +**Dateimanager.** `SftpBrowserView` bindet beim Erzeugen wie eine Terminal-Pane ein Hostlabel in `PaneConfiguration::terminal_identity_host`: lokal `cockpit-spawn-card-host-local`, remote `SshServerInfo.host` des Registry-Knotens (dasselbe Feld, das `bind_ssh_pane_node` für Terminal-Panes bindet), ersatzweise der Knotenname, sonst `terminal-remote-session-label`. Nach jeder installierten Verzeichnisliste setzt `update_pane_identity` die Identität aus Hostlabel und `current_path` über `PaneConfiguration::set_terminal_identity`. Die Kopfzeile liest den aufgelösten Titel der Konfiguration, damit die Kollisionsauflösung sichtbar bleibt. `PaneGroup::refresh_terminal_titles` nimmt sichtbare Dateimanager-Panes mit ihrer eigenen Identität auf. Als Suffix-Schlüssel dient bei einer temporären Ersetzung die persistente Session der verdeckten Terminal-Pane; ein eigenständiger Dateimanager hat keine persistente Session und nutzt seine Pane-ID, sein Suffix ist daher nur innerhalb eines Laufs stabil. Die Schlüssel `fm-title-sftp` und `sftp-local-file-manager-title` entfallen. Die volle Host-/Pfadangabe bleibt über die Breadcrumb-Leiste erreichbar; die Standard-Kopfzeile hat keinen Tooltip-Platz. + +**Hostwechsel.** `TerminalView::identity_host_and_directory` (`terminal/view/pane_impl.rs`) leitet Host und Verzeichnis aus vorhandenen Signalen ab, ohne eigenen Detektor: + +1. Keine aktive gebootstrappte Session, oder eine klassische SSH-Pane, deren eigene Verbindung noch nicht bootstrappte (`classic_ssh_root_session_id` und `remote_input_session_id` leer): gebundenes Label bzw. `Lokal`. +2. Langlaufender Vordergrundbefehl, den `parse_interactive_ssh_command` (Zaplexify-Erkennung, Alias aufgelöst) als interaktives SSH erkennt: `terminal-remote-session-label` ohne Verzeichnis. +3. Aktive Session ist die eigene Shell der Pane, nach derselben Prüfung wie die Verzeichnisübergabe des Dateimanagers (`file_manager_session_matches_target`): Daemon-Wurzel, `classic_ssh_root_session_id` bzw. lokale Shell ohne Subshell. Dann gilt das gebundene Label bzw. `Lokal`. +4. Sonst lokale Session: `Lokal`; Remote-Session: ihr beim Bootstrap gemeldeter `hostname` (leer: `terminal-remote-session-label`). Hostnamen werden nicht auf das gebundene Label abgebildet, weil verschiedene Maschinen denselben Hostnamen tragen können. + +Auslöser sind die vorhandenen Stellen: Session-Bootstrap, Beginn und Ende langlaufender Blöcke und die Precmd-Metadaten. Diese lösen jetzt auch bei Sessionwechsel mit gleichem Verzeichnis aus. + +Tests: `pane_identity_follows_the_shell_across_a_manual_ssh_hop_and_exit`, `bound_host_names_only_the_panes_own_shell` (Terminal-View) und `file_manager_mode_keeps_the_host_and_directory_identity` (Workspace) laufen im gebündelten ci-batch über `pr-check.yml`. From b0e82de742bdf19a2a68ba0a2896902d62908425 Mon Sep 17 00:00:00 2001 From: iret77 <63622643+iret77@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:47:27 +0200 Subject: [PATCH 2/2] fix(ui): Home-Verzeichnis im lokalen Dateimanager wie im Terminal als ~ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zweitreview-Finding: Terminal und Dateimanager schrieben dasselbe lokale Verzeichnis unterschiedlich (~ gegenüber absolutem Pfad). Der lokale Dateimanager nutzt jetzt dieselbe Schreibweise; Test und TECH-Spezifikation ergänzt. Refs #461 --- app/src/sftp_manager/browser.rs | 6 ++++++ app/src/workspace/view_test.rs | 11 +++++++++++ specs/GH-160/TECH.md | 2 +- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app/src/sftp_manager/browser.rs b/app/src/sftp_manager/browser.rs index ab67e1673ab..95e78ae4ba1 100644 --- a/app/src/sftp_manager/browser.rs +++ b/app/src/sftp_manager/browser.rs @@ -1148,6 +1148,12 @@ impl SftpBrowserView { /// real collisions the same way it does for terminals. fn update_pane_identity(&self, ctx: &mut ViewContext) { 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() diff --git a/app/src/workspace/view_test.rs b/app/src/workspace/view_test.rs index 6658477686f..5098bc22046 100644 --- a/app/src/workspace/view_test.rs +++ b/app/src/workspace/view_test.rs @@ -6768,6 +6768,17 @@ fn file_manager_mode_keeps_the_host_and_directory_identity() { // Leaving file manager mode ends the collision. group.close_pane(second_manager, ctx); assert_eq!(pane_title(group, first_manager, ctx), "Local · api"); + + // The home directory reads `~`, as it does in a local terminal. + group.open_file_manager_in_place( + neighbor, + crate::pane_group::FileManagerTarget::Local { + start_path: dirs::home_dir().expect("the test runner has a home directory"), + }, + ctx, + ); + let home_manager = group.focused_pane_id(ctx); + assert_eq!(pane_title(group, home_manager, ctx), "Local · ~"); }); }); }); diff --git a/specs/GH-160/TECH.md b/specs/GH-160/TECH.md index 5e97ba8a89d..f68ac5c6e44 100644 --- a/specs/GH-160/TECH.md +++ b/specs/GH-160/TECH.md @@ -662,7 +662,7 @@ degradation paths are unchanged. Alle Pane-Titel laufen über `terminal::view::terminal_identity(host, cwd, fallback)`; die Signatur bleibt unverändert, weitere Konsumenten (Sessionliste unter Verbindungen) nutzen sie weiter. -**Dateimanager.** `SftpBrowserView` bindet beim Erzeugen wie eine Terminal-Pane ein Hostlabel in `PaneConfiguration::terminal_identity_host`: lokal `cockpit-spawn-card-host-local`, remote `SshServerInfo.host` des Registry-Knotens (dasselbe Feld, das `bind_ssh_pane_node` für Terminal-Panes bindet), ersatzweise der Knotenname, sonst `terminal-remote-session-label`. Nach jeder installierten Verzeichnisliste setzt `update_pane_identity` die Identität aus Hostlabel und `current_path` über `PaneConfiguration::set_terminal_identity`. Die Kopfzeile liest den aufgelösten Titel der Konfiguration, damit die Kollisionsauflösung sichtbar bleibt. `PaneGroup::refresh_terminal_titles` nimmt sichtbare Dateimanager-Panes mit ihrer eigenen Identität auf. Als Suffix-Schlüssel dient bei einer temporären Ersetzung die persistente Session der verdeckten Terminal-Pane; ein eigenständiger Dateimanager hat keine persistente Session und nutzt seine Pane-ID, sein Suffix ist daher nur innerhalb eines Laufs stabil. Die Schlüssel `fm-title-sftp` und `sftp-local-file-manager-title` entfallen. Die volle Host-/Pfadangabe bleibt über die Breadcrumb-Leiste erreichbar; die Standard-Kopfzeile hat keinen Tooltip-Platz. +**Dateimanager.** `SftpBrowserView` bindet beim Erzeugen wie eine Terminal-Pane ein Hostlabel in `PaneConfiguration::terminal_identity_host`: lokal `cockpit-spawn-card-host-local`, remote `SshServerInfo.host` des Registry-Knotens (dasselbe Feld, das `bind_ssh_pane_node` für Terminal-Panes bindet), ersatzweise der Knotenname, sonst `terminal-remote-session-label`. Nach jeder installierten Verzeichnisliste setzt `update_pane_identity` die Identität aus Hostlabel und `current_path` über `PaneConfiguration::set_terminal_identity`. Lokal schreibt sie das Home-Verzeichnis wie das Terminal als `~` (`user_friendly_path`); das entfernte Home kennt der Dateimanager nicht, remote bleibt der Pfad absolut. Die Kopfzeile liest den aufgelösten Titel der Konfiguration, damit die Kollisionsauflösung sichtbar bleibt. `PaneGroup::refresh_terminal_titles` nimmt sichtbare Dateimanager-Panes mit ihrer eigenen Identität auf. Als Suffix-Schlüssel dient bei einer temporären Ersetzung die persistente Session der verdeckten Terminal-Pane; ein eigenständiger Dateimanager hat keine persistente Session und nutzt seine Pane-ID, sein Suffix ist daher nur innerhalb eines Laufs stabil. Die Schlüssel `fm-title-sftp` und `sftp-local-file-manager-title` entfallen. Die volle Host-/Pfadangabe bleibt über die Breadcrumb-Leiste erreichbar; die Standard-Kopfzeile hat keinen Tooltip-Platz. **Hostwechsel.** `TerminalView::identity_host_and_directory` (`terminal/view/pane_impl.rs`) leitet Host und Verzeichnis aus vorhandenen Signalen ab, ohne eigenen Detektor: