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
15 changes: 14 additions & 1 deletion crates/core/src/tab_container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,9 @@ pub enum TabContainerEvent {
TabClosed { id: String },
/// The application navigation sidebar visibility was toggled.
NavigationSidebarToggled { expanded: bool },
/// A tab was created by duplicating another tab. Emitted after the copy is
/// inserted so the host can attach content-specific event handling.
TabDuplicated { id: String },
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
Expand Down Expand Up @@ -2612,6 +2615,14 @@ impl TabContainer {
cx.notify();
}

/// Duplicates the active tab (what the tab menu does); pinned tabs are not duplicable.
pub fn duplicate_active_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.is_pinned_tab_active() {
return;
}
self.duplicate_tab(self.active_index, window, cx);
}

fn duplicate_tab(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
if index >= self.tabs.len() {
return;
Expand Down Expand Up @@ -2657,6 +2668,7 @@ impl TabContainer {
}
self.tabs.insert(insert_index, duplicate);
self.set_active_index(insert_index, window, cx);
cx.emit(TabContainerEvent::TabDuplicated { id: duplicate_id });
}

pub fn dump(&self, cx: &App) -> TabContainerState {
Expand Down Expand Up @@ -5665,7 +5677,8 @@ mod tests {
format!("activated:{index}:{id}")
}
TabContainerEvent::LayoutChanged => "layout".to_string(),
TabContainerEvent::NavigationSidebarToggled { .. } => return,
TabContainerEvent::NavigationSidebarToggled { .. }
| TabContainerEvent::TabDuplicated { .. } => return,
};
events_for_subscription
.lock()
Expand Down
49 changes: 30 additions & 19 deletions crates/terminal/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1094,7 +1094,16 @@ pub fn resolve_local_workspace_root(config: &LocalConfig) -> Option<PathBuf> {
fn prepare_shell_integration(shell: Option<&str>) -> (Vec<(String, String)>, Vec<String>) {
// 使用进程级临时目录,确保不影响其他会话或工具
let session_dir = std::env::temp_dir().join(format!("onetcli-{}", std::process::id()));
if fs::create_dir_all(&session_dir).is_err() {
prepare_shell_integration_in(&session_dir, shell)
}

/// 目录由调用方指定,便于测试各自使用独立目录而不互相删除对方的文件。
#[cfg(not(target_os = "windows"))]
fn prepare_shell_integration_in(
session_dir: &std::path::Path,
shell: Option<&str>,
) -> (Vec<(String, String)>, Vec<String>) {
if fs::create_dir_all(session_dir).is_err() {
tracing::warn!(
"无法创建临时目录 {},跳过 Shell Integration",
session_dir.display()
Expand All @@ -1109,8 +1118,13 @@ fn prepare_shell_integration(shell: Option<&str>) -> (Vec<(String, String)>, Vec
return (vec![], vec![]);
}

let mut extra_env: Vec<(String, String)> =
vec![("ONETCLI_SHELL_INTEGRATION".into(), "1".into())];
// 宿主进程若本身从 Navop 终端启动,会继承已导出的 _ONETCLI_SHELL_INTEGRATED=1,
// 集成脚本的重复加载守卫随即提前返回,OSC 7/133 不再上报(复制标签丢 CWD)。
// 新会话必须显式清空,守卫只用于同一会话内的嵌套子 shell。
let mut extra_env: Vec<(String, String)> = vec![
("ONETCLI_SHELL_INTEGRATION".into(), "1".into()),
("_ONETCLI_SHELL_INTEGRATED".into(), String::new()),
];
let mut extra_args: Vec<String> = Vec::new();

// 判断 shell 类型:优先用显式参数,否则读 $SHELL
Expand Down Expand Up @@ -6655,19 +6669,21 @@ mod tests {
#[cfg(not(target_os = "windows"))]
#[test]
fn prepare_shell_integration_writes_lf_only_script() {
let session_dir = std::env::temp_dir().join(format!("onetcli-{}", std::process::id()));
let integration_path = session_dir.join("shell_integration.sh");
let _ = fs::remove_dir_all(&session_dir);
let session = tempfile::tempdir().expect("应创建临时目录");
let integration_path = session.path().join("shell_integration.sh");

let (_env, _args) = super::prepare_shell_integration(Some("/bin/bash"));
let (env, _args) = super::prepare_shell_integration_in(session.path(), Some("/bin/bash"));

assert!(
env.iter()
.any(|(key, value)| key == "_ONETCLI_SHELL_INTEGRATED" && value.is_empty()),
"从 Navop 终端启动的宿主进程会继承该变量,必须在新会话里显式清空: {env:?}"
);
let script = fs::read_to_string(&integration_path).expect("应写入本地 integration 脚本");
assert!(
!script.contains('\r'),
"本地 shell integration 脚本应统一写成 LF,避免 Windows 工件污染"
);

let _ = fs::remove_dir_all(&session_dir);
}

#[cfg(not(target_os = "windows"))]
Expand All @@ -6678,14 +6694,12 @@ mod tests {
return;
}

let session_dir = std::env::temp_dir().join(format!("onetcli-{}", std::process::id()));
let home_dir =
std::env::temp_dir().join(format!("onetcli-zsh-home-{}", std::process::id()));
let _ = fs::remove_dir_all(&session_dir);
let _ = fs::remove_dir_all(&home_dir);
fs::create_dir_all(&home_dir).expect("应创建临时 HOME");
let session = tempfile::tempdir().expect("应创建临时目录");
let home = tempfile::tempdir().expect("应创建临时 HOME");
let home_dir = home.path().to_path_buf();

let (env_pairs, args) = super::prepare_shell_integration(Some("/bin/zsh"));
let (env_pairs, args) =
super::prepare_shell_integration_in(session.path(), Some("/bin/zsh"));
assert!(args.is_empty(), "zsh 注入不应依赖额外启动参数");

let mut command = Command::new(zsh);
Expand All @@ -6710,9 +6724,6 @@ mod tests {
!stderr.contains("recursion limit") && !stderr.contains("job table full"),
"zsh integration 不应递归 source 临时 .zshrc,stderr: {stderr:?}"
);

let _ = fs::remove_dir_all(&session_dir);
let _ = fs::remove_dir_all(&home_dir);
}

#[test]
Expand Down
147 changes: 38 additions & 109 deletions main/src/home/home_tabs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ use terminal::{
local_config_from_settings_with_profile,
};
use terminal_view::{
TerminalConnectionKind, TerminalWorkspace, TerminalWorkspaceEvent,
current_settings as current_terminal_settings,
TerminalWorkspace, TerminalWorkspaceEvent, current_settings as current_terminal_settings,
};

fn redis_tab_open_context(
Expand Down Expand Up @@ -699,6 +698,24 @@ mod tests {
}
}

#[test]
fn duplicate_hotkey_shares_the_tab_menu_duplicate_path() {
let source = include_str!("home_tabs.rs").replace("\r\n", "\n");
let method = source
.split("\n pub(crate) fn duplicate_active_tab")
.nth(1)
.and_then(|source| source.split("\n }\n").next())
.expect("duplicate_active_tab method");

assert!(method.contains("tc.duplicate_active_tab(window, cx)"));
assert!(!method.contains("add_terminal_tab"));
assert!(!method.contains("self.connections"));

let lifecycle = include_str!("../home_tab/lifecycle.rs");
assert!(lifecycle.contains("TabContainerEvent::TabDuplicated { id }"));
assert!(lifecycle.contains("this.subscribe_terminal_workspace_events(&terminal"));
}

#[test]
fn ssh_terminal_workspace_can_request_an_sftp_tab() {
let source = include_str!("home_tabs.rs").replace("\r\n", "\n");
Expand Down Expand Up @@ -753,15 +770,6 @@ impl HomePage {
current_terminal_settings(cx).sync_path_with_terminal
}

pub(crate) fn open_ssh_terminal(
&mut self,
conn: StoredConnection,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_ssh_terminal_with_mode(conn, TabOpenMode::Activate, window, cx);
}

pub(crate) fn open_ssh_terminal_with_mode(
&mut self,
conn: StoredConnection,
Expand Down Expand Up @@ -795,19 +803,7 @@ impl HomePage {
let terminal_view = cx.new(|cx| {
TerminalWorkspace::new_ssh_with_index(conn, tab_index, window, cx, None, sync_path)
});
let subscription = cx.subscribe_in(
&terminal_view,
window,
|this, _terminal, event: &TerminalWorkspaceEvent, window, cx| match event {
TerminalWorkspaceEvent::OpenSftp(connection) => {
this.open_sftp_view(connection.clone(), window, cx);
}
TerminalWorkspaceEvent::SaveAsConnection(connection) => {
this.show_save_temporary_connection_form(connection.clone(), window, cx);
}
},
);
self._subscriptions.push(subscription);
self.subscribe_terminal_workspace_events(&terminal_view, window, cx);
window.defer(cx, move |window, cx| {
tab_container.update(cx, |tc, cx| {
let tab = TabItem::new(tab_id, "ssh", terminal_view);
Expand All @@ -816,13 +812,26 @@ impl HomePage {
});
}

pub(crate) fn open_serial_terminal(
/// SSH 终端标签(含复制出的标签)请求打开 SFTP / 另存连接时由主页响应。
pub(crate) fn subscribe_terminal_workspace_events(
&mut self,
conn: StoredConnection,
terminal_view: &Entity<TerminalWorkspace>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_serial_terminal_with_mode(conn, TabOpenMode::Activate, window, cx);
let subscription = cx.subscribe_in(
terminal_view,
window,
|this, _terminal, event: &TerminalWorkspaceEvent, window, cx| match event {
TerminalWorkspaceEvent::OpenSftp(connection) => {
this.open_sftp_view(connection.clone(), window, cx);
}
TerminalWorkspaceEvent::SaveAsConnection(connection) => {
this.show_save_temporary_connection_form(connection.clone(), window, cx);
}
},
);
self._subscriptions.push(subscription);
}

pub(crate) fn open_serial_terminal_with_mode(
Expand Down Expand Up @@ -862,15 +871,6 @@ impl HomePage {
});
}

pub(crate) fn open_telnet_terminal(
&mut self,
conn: StoredConnection,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_telnet_terminal_with_mode(conn, TabOpenMode::Activate, window, cx);
}

pub(crate) fn open_telnet_terminal_with_mode(
&mut self,
conn: StoredConnection,
Expand Down Expand Up @@ -1645,80 +1645,9 @@ impl HomePage {

/// 复制当前活动标签并打开
pub(crate) fn duplicate_active_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// 与标签右键菜单「复制」共用 TabContainer::duplicate_active_tab,保证 CWD 等状态一致
let tab_container = self.active_tab_container(cx);
let tc = tab_container.read(cx);

// pinned tab 不支持复制
if tc.is_pinned_tab_active() {
return;
}

let Some(active_tab) = tc.active_tab() else {
return;
};

let content_key = active_tab.content().content_key(cx);

match content_key {
"Terminal" => {
// 获取终端视图的连接信息
let view = active_tab.content().view();
let Ok(terminal_view) = view.downcast::<TerminalWorkspace>() else {
return;
};

let kind = terminal_view.read(cx).connection_kind(cx);
match kind {
TerminalConnectionKind::Ssh => {
// SSH 终端:通过 connection_id 找到 StoredConnection 并打开新连接
let conn_id = terminal_view.read(cx).connection_id(cx);
if let Some(conn_id) = conn_id {
if let Some(conn) = self
.connections
.iter()
.find(|c| c.id == Some(conn_id))
.cloned()
{
self.open_ssh_terminal(conn, window, cx);
}
}
}
TerminalConnectionKind::Serial => {
let conn_id = terminal_view.read(cx).connection_id(cx);
if let Some(conn_id) = conn_id {
if let Some(conn) = self
.connections
.iter()
.find(|c| c.id == Some(conn_id))
.cloned()
{
self.open_serial_terminal(conn, window, cx);
}
}
}
TerminalConnectionKind::Telnet => {
let conn_id = terminal_view.read(cx).connection_id(cx);
if let Some(conn_id) = conn_id {
if let Some(conn) = self
.connections
.iter()
.find(|c| c.id == Some(conn_id))
.cloned()
{
self.open_telnet_terminal(conn, window, cx);
}
}
}
TerminalConnectionKind::Local => {
// 本地终端:直接新建
self.add_terminal_tab(window, cx);
}
}
}
_ => {
// 其他类型暂不支持复制
}
}
tab_container.update(cx, |tc, cx| tc.duplicate_active_tab(window, cx));
}
}

Expand Down
23 changes: 23 additions & 0 deletions main/src/home_tab/lifecycle.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use super::*;
use one_core::tab_container::TabContainerEvent;
use terminal_view::TerminalWorkspace;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct StartupMasterKeyPolicy {
Expand Down Expand Up @@ -66,6 +68,27 @@ impl HomePage {
)
.detach();

// 复制出的终端标签由 TabContainer 内部创建,需在此补上 OpenSftp / SaveAsConnection 的响应。
cx.subscribe_in(
&tab_container,
window,
|this, tab_container, event: &TabContainerEvent, window, cx| {
let TabContainerEvent::TabDuplicated { id } = event else {
return;
};
let terminal = tab_container
.read(cx)
.tabs()
.iter()
.find(|tab| tab.id() == id.as_str())
.and_then(|tab| tab.content().view().downcast::<TerminalWorkspace>().ok());
if let Some(terminal) = terminal {
this.subscribe_terminal_workspace_events(&terminal, window, cx);
}
},
)
.detach();

let mut page = Self {
focus_handle: cx.focus_handle(),
selected_filter: ConnectionFilter::All,
Expand Down
4 changes: 3 additions & 1 deletion main/src/navop_app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1592,7 +1592,9 @@ impl NavopApp {
});
});
}
TabContainerEvent::LayoutChanged | TabContainerEvent::TabClosed { .. } => {
TabContainerEvent::LayoutChanged
| TabContainerEvent::TabClosed { .. }
| TabContainerEvent::TabDuplicated { .. } => {
cx.defer(move |cx| {
app.update(cx, |app, cx| {
app.sync_connection_sidebar_theme(cx);
Expand Down
Loading