From 6681ca4e63f3570fcd3bd2c1134593d1f03bcea0 Mon Sep 17 00:00:00 2001 From: yiTuoRou <1561074588@qq.com> Date: Sat, 4 Jul 2026 20:10:57 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=A4=9A=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=A4=A7=E9=87=8F=E8=BE=93=E5=87=BA=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E5=8D=A1=E6=AD=BB=20(#209)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解压多文件压缩包等高频刷屏场景下,其他服务器无法操作、无法输入命令。 将全局终端缓冲区锁按标签页拆分,一个会话刷屏不再阻塞其他会话的键盘输入。 把 vt100 解析从 UI 线程移到事件泵线程,并对渲染做合并与约 30fps 节流,避免事件队列被淹没。 后台标签只解析不渲染,切回时补渲染;每次更新终端后主动请求重绘,不再依赖切换窗口触发刷新。 修复在 UI 线程内嵌套调用 invoke_from_event_loop 导致的死锁(打开第二个标签即卡死的真凶)。 fix: prevent UI freeze under heavy multi-session output (#209) When one session floods output (e.g. unzipping many files), other servers became unusable and could not accept input. Split the global terminal buffer lock per tab so a flood on one session no longer blocks keyboard input on another. Move vt100 parsing off the UI thread onto the event-pump threads, and coalesce/throttle renders to ~30fps so the event loop can't be drowned. Background tabs only ingest and are rendered on switch-back; each terminal update now requests a redraw instead of relying on window switching to refresh. Fix a deadlock from nesting invoke_from_event_loop inside a UI callback (the real cause of the freeze when opening a second tab). Co-authored-by: Cursor --- src/app.rs | 448 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 308 insertions(+), 140 deletions(-) diff --git a/src/app.rs b/src/app.rs index 24b98c30..b3e617ea 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,8 +7,9 @@ //! * Route Slint callbacks to the right domain module. use std::cell::RefCell; -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::rc::Rc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; /// Per-terminal state: vt100 parser drives all rendering for both normal @@ -59,6 +60,10 @@ struct TermBuffer { /// How much of the byte stream we retain per tab for resize-reflow (#169). const RAW_CAP: usize = 2 * 1024 * 1024; +/// Max bytes merged into one Output event before starting a fresh chunk (#209). +/// Keeps a single UI callback from spending hundreds of ms in vt100 ingest. +const OUTPUT_MERGE_BYTE_CAP: usize = 64 * 1024; + /// Minimal CSI-final-byte rewriter state (persists across read chunks). #[derive(Clone, Copy, PartialEq)] enum CsiState { @@ -70,7 +75,50 @@ enum CsiState { Csi, } -type TermBuffers = Arc>>; +/// Max UI renders per second for a tab under sustained output (#209). +const RENDER_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(33); + +/// Per-tab terminal buffer — each tab has its own lock so a burst of output on +/// one session (e.g. `unzip` listing thousands of files) doesn't block keyboard +/// input on another (#209). +type TermBufferHandle = Arc>; +type TermBuffers = Arc>>; + +/// Coalesces render requests so a firehose of output schedules at most one UI +/// flush at a time per tab, throttled to ~30 fps (#209). +struct TabRenderGate { + scheduled: AtomicBool, + pending: AtomicBool, + last_render: Mutex, +} + +impl TabRenderGate { + fn new() -> Self { + Self { + scheduled: AtomicBool::new(false), + pending: AtomicBool::new(false), + last_render: Mutex::new(std::time::Instant::now() - RENDER_MIN_INTERVAL), + } + } +} + +type RenderGates = Arc>>>; + +fn term_buf(bufs: &TermBuffers, tab_id: &str) -> Option { + bufs.lock().unwrap().get(tab_id).cloned() +} + +fn with_term_buf(bufs: &TermBuffers, tab_id: &str, f: impl FnOnce(&mut TermBuffer) -> R) -> Option { + let h = term_buf(bufs, tab_id)?; + let mut guard = h.lock().unwrap(); + Some(f(&mut guard)) +} + +fn ingest_terminal_output(bufs: &TermBuffers, tab_id: &str, chunk: &[u8]) { + if let Some(h) = term_buf(bufs, tab_id) { + h.lock().unwrap().ingest(chunk); + } +} use anyhow::{Context, Result}; use i_slint_backend_winit::WinitWindowAccessor; @@ -125,6 +173,111 @@ type LocalSnap = Arc>; // Slint generates types into this scope. slint::include_modules!(); +/// Tab ids currently shown in a pane (`term.id == pane.active-id` in Slint). +fn visible_tab_ids(win: &AppWindow) -> HashSet { + use slint::Model as _; + let mut out = HashSet::new(); + let panes = win.get_panes(); + if let Some(pm) = panes.as_any().downcast_ref::>() { + for i in 0..pm.row_count() { + if let Some(pane) = pm.row_data(i) { + out.insert(pane.active_id.to_string()); + } + } + } + out +} + +fn request_tab_render( + weak: slint::Weak, + tab_id: &str, + bufs: &TermBuffers, + gates: &RenderGates, +) { + let gate = { + let m = gates.lock().unwrap(); + m.get(tab_id).cloned() + }; + let Some(gate) = gate else { return }; + gate.pending.store(true, Ordering::Release); + if gate.scheduled.swap(true, Ordering::AcqRel) { + return; + } + + let weak2 = weak.clone(); + let tid = tab_id.to_string(); + let bufs2 = bufs.clone(); + let gates2 = gates.clone(); + // Always bounce through the event loop from pump / worker threads. + // Never call invoke_from_event_loop from inside a UI callback — that + // deadlocks Slint (opening a second tab then froze the whole app). + let _ = slint::invoke_from_event_loop(move || { + run_coalesced_tab_render(&weak2, &tid, &bufs2, &gates2); + }); +} + +/// UI-thread entry: honour the throttle, then render. Timer must be created +/// here — not on pump threads (#209). +fn run_coalesced_tab_render( + weak: &slint::Weak, + tab_id: &str, + bufs: &TermBuffers, + gates: &RenderGates, +) { + let gate = { + let m = gates.lock().unwrap(); + m.get(tab_id).cloned() + }; + let Some(gate) = gate else { return }; + + let delay = { + let last = *gate.last_render.lock().unwrap(); + RENDER_MIN_INTERVAL.saturating_sub(last.elapsed()) + }; + + let weak2 = weak.clone(); + let tid = tab_id.to_string(); + let bufs2 = bufs.clone(); + let gates2 = gates.clone(); + + if delay.is_zero() { + do_tab_render_flush(&weak2, &tid, &bufs2, &gates2); + } else { + slint::Timer::single_shot(delay, move || { + do_tab_render_flush(&weak2, &tid, &bufs2, &gates2); + }); + } +} + +/// UI-thread only: push the vt100 screen into Slint, then reschedule if more +/// output arrived while we were rendering (#209). +fn do_tab_render_flush( + weak: &slint::Weak, + tab_id: &str, + bufs: &TermBuffers, + gates: &RenderGates, +) { + let gate = { + let m = gates.lock().unwrap(); + m.get(tab_id).cloned() + }; + let Some(gate) = gate else { return }; + gate.scheduled.store(false, Ordering::Release); + + if let Some(win) = weak.upgrade() { + if visible_tab_ids(&win).contains(tab_id) { + rebuild_tab_display(&win, bufs, tab_id); + *gate.last_render.lock().unwrap() = std::time::Instant::now(); + } + } + + if gate.pending.swap(false, Ordering::AcqRel) { + if !gate.scheduled.swap(true, Ordering::AcqRel) { + run_coalesced_tab_render(weak, tab_id, bufs, gates); + } + } +} + /// Number of samples kept for the sparkline. const NET_HISTORY_LEN: usize = 60; @@ -317,6 +470,7 @@ pub fn run() -> Result<()> { // Per-tab vt100 parsers + history logs (Arc so they can be cloned // into the thread that pumps session events into invoke_from_event_loop). let bufs: TermBuffers = Arc::new(Mutex::new(HashMap::new())); + let render_gates: RenderGates = Arc::new(Mutex::new(HashMap::new())); // Last-known terminal pixel dimensions, updated by every terminal-resize // callback. Shared so on_connect_session can pass a sensible initial PTY @@ -1025,6 +1179,7 @@ pub fn run() -> Result<()> { splitters_model.clone(), handles.clone(), bufs.clone(), + render_gates.clone(), runtime.clone(), last_term_size.clone(), sftp_handles.clone(), @@ -1374,6 +1529,7 @@ pub fn run() -> Result<()> { splitters_model.clone(), handles.clone(), bufs.clone(), + render_gates.clone(), sftp_handles.clone(), sftp_last_cwd.clone(), ); @@ -1391,6 +1547,7 @@ pub fn run() -> Result<()> { sftp_handles: sftp_handles.clone(), sftp_last_cwd: sftp_last_cwd.clone(), bufs: bufs.clone(), + render_gates: render_gates.clone(), tab_statuses: tab_statuses.clone(), local_snap: local_snap.clone(), local_net_hist: local_net_hist.clone(), @@ -1988,6 +2145,7 @@ fn wire_session_callbacks( splitters_model: Rc>, handles: Rc>>, bufs: TermBuffers, + render_gates: RenderGates, runtime: Arc, last_term_size: Arc>, sftp_handles: SftpHandles, @@ -2599,6 +2757,7 @@ fn wire_session_callbacks( let content_size = content_size.clone(); let handles = handles.clone(); let bufs = bufs.clone(); + let render_gates = render_gates.clone(); let runtime = runtime.clone(); let last_term_size = last_term_size.clone(); let sftp_handles = sftp_handles.clone(); @@ -2699,7 +2858,7 @@ fn wire_session_callbacks( let is_dark_now = weak.upgrade().map(|w| w.get_dark_mode()).unwrap_or(true); bufs.lock().unwrap().insert( tab_id.clone(), - TermBuffer { + Arc::new(Mutex::new(TermBuffer { parser: vt100::Parser::new(24, 80, 5000), find_query: String::new(), is_dark: is_dark_now, @@ -2711,8 +2870,12 @@ fn wire_session_callbacks( displayed_text: Vec::new(), csi_state: CsiState::Normal, raw: std::collections::VecDeque::new(), - }, + })), ); + render_gates + .lock() + .unwrap() + .insert(tab_id.clone(), Arc::new(TabRenderGate::new())); // No followed-cwd yet: the first OSC 7 always triggers a follow. sftp_last_cwd.lock().unwrap().remove(&tab_id); // Add the new tab to the focused pane and re-flatten (this also sets @@ -2738,6 +2901,7 @@ fn wire_session_callbacks( sftp_handles: sftp_handles.clone(), sftp_last_cwd: sftp_last_cwd.clone(), bufs: bufs.clone(), + render_gates: render_gates.clone(), tab_statuses: tab_statuses.clone(), local_snap: local_snap.clone(), local_net_hist: local_net_hist.clone(), @@ -2791,6 +2955,7 @@ struct ConnectCtx { sftp_handles: SftpHandles, sftp_last_cwd: SftpLastCwd, bufs: TermBuffers, + render_gates: RenderGates, tab_statuses: TabStatuses, local_snap: LocalSnap, local_net_hist: NetHist, @@ -2853,6 +3018,7 @@ fn start_session_in_tab(tab_id: &str, session: Session, ctx: &ConnectCtx) { let local_pump = ctx.local_snap.clone(); let net_pump = ctx.local_net_hist.clone(); let follow_cd_pump = ctx.sftp_follow_cd.clone(); + let render_gates_pump = ctx.render_gates.clone(); std::thread::spawn(move || { let mut shell_rx = rx; let mut cwd_debounce: Option> = None; @@ -2925,9 +3091,15 @@ fn start_session_in_tab(tab_id: &str, session: Session, ctx: &ConnectCtx) { // Merge with the immediately preceding Output so the // whole run is one vt100 ingest + one render. Only // *adjacent* chunks merge, so byte order (and any - // interleaved event) is preserved exactly. + // interleaved event) is preserved exactly. Cap the + // merged size so one batch can't monopolize the UI + // thread for hundreds of ms (#209). if let Some(SessionEvent::Output(prev)) = ui_batch.last_mut() { - prev.push_str(&chunk); + if prev.len() + chunk.len() <= OUTPUT_MERGE_BYTE_CAP { + prev.push_str(&chunk); + } else { + ui_batch.push(SessionEvent::Output(chunk)); + } } else { ui_batch.push(SessionEvent::Output(chunk)); } @@ -2939,17 +3111,49 @@ fn start_session_in_tab(tab_id: &str, session: Session, ctx: &ConnectCtx) { continue; } + // Ingest terminal output on this pump thread (not the UI thread) + // so a firehose can't block keyboard input or repaints (#209). + let mut had_output = false; + let mut ui_only: Vec = Vec::with_capacity(ui_batch.len()); + for evt in ui_batch { + match evt { + SessionEvent::Output(chunk) => { + ingest_terminal_output( + &bufs_thread, + &tab_id_pump, + chunk.as_bytes(), + ); + had_output = true; + } + other => ui_only.push(other), + } + } + + if had_output { + request_tab_render( + weak_inner.clone(), + &tab_id_pump, + &bufs_thread, + &render_gates_pump, + ); + } + + if ui_only.is_empty() { + continue; + } + let weak_evt = weak_inner.clone(); let tid = tab_id_pump.clone(); let bufs_evt = bufs_thread.clone(); let st_evt = statuses_pump.clone(); let lc_evt = local_pump.clone(); let nh_evt = net_pump.clone(); + let gates_evt = render_gates_pump.clone(); let _ = slint::invoke_from_event_loop(move || { if let Some(win) = weak_evt.upgrade() { - for evt in ui_batch { + for evt in ui_only { apply_session_event_to_window( - &win, &tid, evt, &bufs_evt, &st_evt, &lc_evt, &nh_evt, + &win, &tid, evt, &bufs_evt, &gates_evt, &st_evt, &lc_evt, &nh_evt, ); } } @@ -2966,27 +3170,42 @@ fn start_session_in_tab(tab_id: &str, session: Session, ctx: &ConnectCtx) { let statuses_sftp = ctx.tab_statuses.clone(); let local_sftp = ctx.local_snap.clone(); let net_sftp = ctx.local_net_hist.clone(); + let gates_sftp = ctx.render_gates.clone(); std::thread::spawn(move || { let mut sftp_rx = sftp_evt_tx; + let mut drained: Vec = Vec::new(); loop { match sftp_rx.blocking_recv() { None => break, - Some(sftp_evt) => { - let weak_s = weak_sftp.clone(); - let tid = tab_id_sftp.clone(); - let bufs_s = bufs_sftp.clone(); - let st_s = statuses_sftp.clone(); - let lc_s = local_sftp.clone(); - let nh_s = net_sftp.clone(); - let _ = slint::invoke_from_event_loop(move || { - if let Some(win) = weak_s.upgrade() { - apply_session_event_to_window( - &win, &tid, sftp_evt, &bufs_s, &st_s, &lc_s, &nh_s, - ); - } - }); + Some(first) => drained.push(first), + } + const SFTP_DRAIN_CAP: usize = 256; + while drained.len() < SFTP_DRAIN_CAP { + match sftp_rx.try_recv() { + Ok(evt) => drained.push(evt), + Err(_) => break, } } + let ui_batch: Vec = drained.drain(..).collect(); + if ui_batch.is_empty() { + continue; + } + let weak_s = weak_sftp.clone(); + let tid = tab_id_sftp.clone(); + let bufs_s = bufs_sftp.clone(); + let st_s = statuses_sftp.clone(); + let lc_s = local_sftp.clone(); + let nh_s = net_sftp.clone(); + let gates_s = gates_sftp.clone(); + let _ = slint::invoke_from_event_loop(move || { + if let Some(win) = weak_s.upgrade() { + for sftp_evt in ui_batch { + apply_session_event_to_window( + &win, &tid, sftp_evt, &bufs_s, &gates_s, &st_s, &lc_s, &nh_s, + ); + } + } + }); } }); } @@ -3415,7 +3634,8 @@ fn apply_terminal_resize( if let Some(handle) = handles.borrow().get(tab_id) { handle.resize(cols, rows); } - if let Some(buf) = bufs.lock().unwrap().get_mut(tab_id) { + if let Some(h) = term_buf(bufs, tab_id) { + let mut buf = h.lock().unwrap(); let (old_rows, old_cols) = buf.parser.screen().size(); let (new_rows, new_cols) = (rows as u16, cols as u16); if (new_rows, new_cols) != (old_rows, old_cols) { @@ -3439,18 +3659,16 @@ fn apply_terminal_resize( /// current vt100 screen (respecting scrollback) and push them to the model. /// Used by scroll + selection callbacks (Output has its own equivalent inline). fn rebuild_tab_display(win: &AppWindow, bufs: &TermBuffers, tab_id: &str) { - let data = { - let mut map = bufs.lock().unwrap(); - let Some(buf) = map.get_mut(tab_id) else { - return; - }; + let data = with_term_buf(bufs, tab_id, |buf| { let cols = buf.parser.screen().size().1; let b = buf.render(); // also refreshes buf.displayed_text let matches = compute_find_matches(&buf.displayed_text, &buf.find_query); let sel = buf.selection_rects_visible(cols); (b, matches, sel) + }); + let Some((b, matches, sel)) = data else { + return; }; - let (b, matches, sel) = data; let spans = ModelRc::from(Rc::new(VecModel::from(b.spans))); let fm = ModelRc::from(Rc::new(VecModel::from(matches))); let sm = ModelRc::from(Rc::new(VecModel::from(sel))); @@ -3467,6 +3685,7 @@ fn rebuild_tab_display(win: &AppWindow, bufs: &TermBuffers, tab_id: &str) { row.scroll_max = smax; row.scroll_offset = soff; }); + win.window().request_redraw(); } /// Resolve the user's saved theme preference to a dark/light bool (mirrors the @@ -3491,9 +3710,9 @@ fn theme_pref_is_dark(store: &ConfigStore) -> bool { fn apply_dark_mode(window: &AppWindow, bufs: &TermBuffers, dark: bool) { window.set_dark_mode(dark); { - let mut map = bufs.lock().unwrap(); - for buf in map.values_mut() { - buf.is_dark = dark; + let handles: Vec<_> = bufs.lock().unwrap().values().cloned().collect(); + for h in handles { + h.lock().unwrap().is_dark = dark; } } let tab_ids: Vec = bufs.lock().unwrap().keys().cloned().collect(); @@ -3698,6 +3917,7 @@ fn apply_session_event_to_window( tab_id: &str, event: SessionEvent, bufs: &TermBuffers, + gates: &RenderGates, statuses: &TabStatuses, local: &LocalSnap, local_net_hist: &NetHist, @@ -3766,48 +3986,10 @@ fn apply_session_event_to_window( update_terminal(&|t| t.status = status.clone().into()); } SessionEvent::Output(chunk) => { - // Feed raw bytes into the vt100 parser. vt100 correctly handles - // cursor movement, \r + line-redraw (readline), \x1b[K (erase to - // EOL), alternate-screen switching, and all VT100/xterm sequences. - // We then split the rendered screen at cursor_position() so Slint - // can insert the blinking "█" at the exact cursor cell. - let built = { - let mut map = bufs.lock().unwrap(); - if let Some(buf) = map.get_mut(tab_id) { - // Capture scrolled-off lines into history, then render the - // current view (live or scrolled-back). - buf.ingest(chunk.as_bytes()); - let cols = buf.parser.screen().size().1; - let b = buf.render(); // refreshes buf.displayed_text - let matches = compute_find_matches(&buf.displayed_text, &buf.find_query); - let sel = buf.selection_rects_visible(cols); - Some((b, matches, sel)) - } else { - None - } - }; - if let Some((b, matches, sel)) = built { - let spans_model: ModelRc = - ModelRc::from(std::rc::Rc::new(VecModel::from(b.spans))); - let matches_model: ModelRc = - ModelRc::from(std::rc::Rc::new(VecModel::from(matches))); - let sel_model: ModelRc = - ModelRc::from(std::rc::Rc::new(VecModel::from(sel))); - let (cur_row, cur_col, rows_used, is_alt) = - (b.cursor_row, b.cursor_col, b.rows_used, b.is_alt); - let (smax, soff) = (b.scroll_max, b.scroll_offset); - update_terminal(&|t| { - t.spans = spans_model.clone(); - t.cursor_row = cur_row; - t.cursor_col = cur_col; - t.rows_used = rows_used; - t.is_alt_screen = is_alt; - t.find_matches = matches_model.clone(); - t.selection = sel_model.clone(); - t.scroll_max = smax; - t.scroll_offset = soff; - }); - } + // Synthetic Output (disconnect hint, editor error, …) — rare, already + // on the UI thread. Live shell output is ingested on the pump thread. + ingest_terminal_output(bufs, tab_id, chunk.as_bytes()); + run_coalesced_tab_render(&win.as_weak(), tab_id, bufs, gates); } SessionEvent::Connected => { update_tab(&|t| t.connected = true); @@ -3833,6 +4015,7 @@ fn apply_session_event_to_window( ) )), bufs, + gates, statuses, local, local_net_hist, @@ -3954,6 +4137,7 @@ fn apply_session_event_to_window( error )), bufs, + gates, statuses, local, local_net_hist, @@ -4666,6 +4850,7 @@ fn wire_tab_callbacks( splitters_model: Rc>, handles: Rc>>, bufs: TermBuffers, + render_gates: RenderGates, sftp_handles: SftpHandles, sftp_last_cwd: SftpLastCwd, ) { @@ -4678,6 +4863,7 @@ fn wire_tab_callbacks( let tabs_model = tabs_model.clone(); let panes_model = panes_model.clone(); let splitters_model = splitters_model.clone(); + let bufs_tab_sel = bufs.clone(); window.on_pane_tab_selected(move |pane_id: i32, id: SharedString| { let id = id.to_string(); { @@ -4685,7 +4871,7 @@ fn wire_tab_callbacks( lay.focused = pane_id as u64; if let Some(l) = lay.leaf_mut(pane_id as u64) { if l.tabs.iter().any(|t| t == &id) { - l.active = id; + l.active = id.clone(); } } } @@ -4698,6 +4884,9 @@ fn wire_tab_callbacks( &panes_model, &splitters_model, ); + // Tab just became visible — render any output ingested while it + // was in the background (e.g. another session was unzipping). + rebuild_tab_display(&w, &bufs_tab_sel, &id); } }); } @@ -4752,6 +4941,7 @@ fn wire_tab_callbacks( let terminals_model = terminals_model.clone(); let handles = handles.clone(); let bufs = bufs.clone(); + let render_gates = render_gates.clone(); let sftp_handles = sftp_handles.clone(); let sftp_last_cwd = sftp_last_cwd.clone(); let panes_model = panes_model.clone(); @@ -4769,6 +4959,7 @@ fn wire_tab_callbacks( } sftp_last_cwd.lock().unwrap().remove(&id); bufs.lock().unwrap().remove(&id); + render_gates.lock().unwrap().remove(&id); // Remove from tabs + terminals models. let mut idx = None; @@ -6048,8 +6239,8 @@ fn wire_key_input( } // Fresh screen: new parser, cleared history/selection. { - let mut map = ctx.bufs.lock().unwrap(); - if let Some(b) = map.get_mut(tab_id.as_str()) { + if let Some(h) = term_buf(&ctx.bufs, tab_id.as_str()) { + let mut b = h.lock().unwrap(); let (rows, cols) = b.parser.screen().size(); b.parser = vt100::Parser::new(rows, cols, 5000); b.history.clear(); @@ -6081,17 +6272,14 @@ fn wire_key_input( // Check whether the remote PTY switched to application cursor mode // (DECCKM, set by nano/vim via \x1b[?1h). In that mode the terminal // must send \x1bOA/B/C/D instead of \x1b[A/B/C/D. - let app_cursor = { - let mut map = bufs.lock().unwrap(); - match map.get_mut(tab_id.as_str()) { - Some(b) => { - // Typing snaps the view back to the live bottom so the - // user always sees what they're entering. - b.view_offset = 0; - b.parser.screen().application_cursor() - } - None => false, - } + let app_cursor = if let Some(h) = term_buf(&bufs, tab_id.as_str()) { + let mut b = h.lock().unwrap(); + // Typing snaps the view back to the live bottom so the + // user always sees what they're entering. + b.view_offset = 0; + b.parser.screen().application_cursor() + } else { + false }; // Never log the raw key string — it can be a password character // (#15). redact_key keeps control codes but masks printable text. @@ -6370,22 +6558,17 @@ fn wire_key_input( { let bufs = bufs.clone(); window.on_copy_terminal_text(move |tab_id: SharedString| { - let text = { - let map = bufs.lock().unwrap(); - match map.get(tab_id.as_str()) { - Some(buf) => { - // Copy the drag-selection when there is one, else the - // whole displayed screen. - let sel = buf.extract_selection_text(); - if sel.is_empty() { - buf.displayed_text.join("\n") - } else { - sel - } - } - None => String::new(), + let text = term_buf(&bufs, tab_id.as_str()).map(|h| { + let buf = h.lock().unwrap(); + // Copy the drag-selection when there is one, else the + // whole displayed screen. + let sel = buf.extract_selection_text(); + if sel.is_empty() { + buf.displayed_text.join("\n") + } else { + sel } - }; + }).unwrap_or_default(); // Run the clipboard write on a dedicated OS thread. arboard's // Windows backend opens the clipboard and pumps Win32 messages; // doing that on the Slint/winit event-loop thread re-enters the @@ -6430,7 +6613,8 @@ fn wire_key_input( let weak = window.as_weak(); window.on_clear_terminal(move |tab_id: SharedString| { let tid = tab_id.to_string(); - if let Some(buf) = bufs_clear.lock().unwrap().get_mut(&tid) { + if let Some(h) = term_buf(&bufs_clear, &tid) { + let mut buf = h.lock().unwrap(); let (rows, cols) = buf.parser.screen().size(); buf.parser = vt100::Parser::new(rows, cols, 5000); buf.find_query.clear(); @@ -6467,15 +6651,10 @@ fn wire_key_input( window.on_find_query_changed(move |tab_id: SharedString, query: SharedString| { let tid = tab_id.to_string(); let q = query.to_string(); - let matches = { - let mut map = bufs_find.lock().unwrap(); - if let Some(buf) = map.get_mut(&tid) { - buf.find_query = q.clone(); - compute_find_matches(&buf.displayed_text, &q) - } else { - Vec::new() - } - }; + let matches = with_term_buf(&bufs_find, &tid, |buf| { + buf.find_query = q.clone(); + compute_find_matches(&buf.displayed_text, &q) + }).unwrap_or_default(); if let Some(win) = weak.upgrade() { let model = ModelRc::from(Rc::new(VecModel::from(matches))); set_terminal_row(&win, &tid, |row| { @@ -6491,15 +6670,13 @@ fn wire_key_input( let weak = window.as_weak(); window.on_terminal_scroll(move |tab_id: SharedString, delta: i32| { let tid = tab_id.to_string(); - { - let mut map = bufs_scroll.lock().unwrap(); - let Some(buf) = map.get_mut(&tid) else { return }; + with_term_buf(&bufs_scroll, &tid, |buf| { // Scroll within our own session scrollback (history lines above // the live screen). Offset 0 = live bottom. let max_off = buf.history.len() as i64; let cur = buf.view_offset as i64; buf.view_offset = (cur + delta as i64).clamp(0, max_off) as usize; - } + }); if let Some(win) = weak.upgrade() { rebuild_tab_display(&win, &bufs_scroll, &tid); } @@ -6517,9 +6694,8 @@ fn wire_key_input( let handles_wheel = handles.clone(); window.on_terminal_wheel(move |tab_id: SharedString, dir: i32, col: i32, row: i32| { let tid = tab_id.to_string(); - let bytes = { - let map = bufs_wheel.lock().unwrap(); - let Some(buf) = map.get(&tid) else { return }; + let bytes = term_buf(&bufs_wheel, &tid).map(|h| { + let buf = h.lock().unwrap(); let screen = buf.parser.screen(); if screen.mouse_protocol_mode() != vt100::MouseProtocolMode::None { // 1-based cell under the cursor, clamped to the screen. @@ -6551,8 +6727,8 @@ fn wire_key_input( }; one.repeat(3) } - }; - if let Some(h) = handles_wheel.borrow().get(&tid) { + }); + if let (Some(bytes), Some(h)) = (bytes, handles_wheel.borrow().get(&tid)) { h.send_raw(bytes); } }); @@ -6564,12 +6740,10 @@ fn wire_key_input( let weak = window.as_weak(); window.on_terminal_scroll_to(move |tab_id: SharedString, offset: i32| { let tid = tab_id.to_string(); - { - let mut map = bufs_scroll.lock().unwrap(); - let Some(buf) = map.get_mut(&tid) else { return }; + with_term_buf(&bufs_scroll, &tid, |buf| { let max_off = buf.history.len() as i64; buf.view_offset = (offset as i64).clamp(0, max_off) as usize; - } + }); if let Some(win) = weak.upgrade() { rebuild_tab_display(&win, &bufs_scroll, &tid); } @@ -6582,9 +6756,7 @@ fn wire_key_input( let weak = window.as_weak(); window.on_term_select_start(move |tab_id: SharedString, row: i32, col: i32| { let tid = tab_id.to_string(); - { - let mut map = bufs_sel.lock().unwrap(); - let Some(buf) = map.get_mut(&tid) else { return }; + with_term_buf(&bufs_sel, &tid, |buf| { let (rows, cols) = buf.parser.screen().size(); let r = row.clamp(0, rows.saturating_sub(1) as i32) as u16; let c = col.clamp(0, cols.saturating_sub(1) as i32) as u16; @@ -6592,7 +6764,7 @@ fn wire_key_input( let abs = buf.vis_to_abs(r); buf.sel_anchor = Some((abs, c)); buf.sel_focus = Some((abs, c)); - } + }); if let Some(win) = weak.upgrade() { rebuild_tab_display(&win, &bufs_sel, &tid); } @@ -6603,9 +6775,7 @@ fn wire_key_input( let weak = window.as_weak(); window.on_term_select_update(move |tab_id: SharedString, row: i32, col: i32| { let tid = tab_id.to_string(); - { - let mut map = bufs_sel.lock().unwrap(); - let Some(buf) = map.get_mut(&tid) else { return }; + with_term_buf(&bufs_sel, &tid, |buf| { let (rows, cols) = buf.parser.screen().size(); let r = row.clamp(0, rows.saturating_sub(1) as i32) as u16; let c = col.clamp(0, cols.saturating_sub(1) as i32) as u16; @@ -6613,7 +6783,7 @@ fn wire_key_input( let abs = buf.vis_to_abs(r); buf.sel_focus = Some((abs, c)); } - } + }); if let Some(win) = weak.upgrade() { rebuild_tab_display(&win, &bufs_sel, &tid); } @@ -6626,9 +6796,7 @@ fn wire_key_input( let tid = tab_id.to_string(); // Extract the selected text; a zero-area selection (a plain click) // is cleared instead of copied. - let text = { - let mut map = bufs_sel.lock().unwrap(); - let Some(buf) = map.get_mut(&tid) else { return }; + let text = with_term_buf(&bufs_sel, &tid, |buf| { let extracted = buf.extract_selection_text(); if extracted.is_empty() { // Zero-area selection (a plain click) → clear it. @@ -6638,7 +6806,7 @@ fn wire_key_input( } else { Some(extracted) } - }; + }).flatten(); match text { Some(t) if !t.is_empty() => { // Auto-copy on release (select-to-copy, PuTTY style). @@ -6660,9 +6828,9 @@ fn wire_key_input( let weak = window.as_weak(); window.on_term_select_autoscroll(move |tab_id: SharedString, dir: i32| { let tid = tab_id.to_string(); + let Some(h) = term_buf(&bufs_sel, &tid) else { return }; { - let mut map = bufs_sel.lock().unwrap(); - let Some(buf) = map.get_mut(&tid) else { return }; + let mut buf = h.lock().unwrap(); // No scrollback on the alternate screen (vim/btop own the view). if buf.parser.screen().alternate_screen() { return; From a76977c497ee59780a2a07d1abc5ff7028097016 Mon Sep 17 00:00:00 2001 From: yiTuoRou <1561074588@qq.com> Date: Sat, 4 Jul 2026 20:20:48 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E5=A4=8D=E6=AC=A2?= =?UTF-8?q?=E8=BF=8E=E9=A1=B5=E4=BC=9A=E8=AF=9D=E9=9D=A2=E6=9D=BF=E9=A6=96?= =?UTF-8?q?=E6=AC=A1=E5=BB=BA=E4=BC=9A=E8=AF=9D=E6=97=B6=E9=AB=98=E5=BA=A6?= =?UTF-8?q?=E8=B7=B3=E5=8F=98=20(#214)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 无会话时快速连接卡片是自然高度并靠上、下方用 spacer 撑底,建立第一个会话后卡片撑满,导致面板高度突然跳变。 现让卡片始终撑满剩余高度,空状态占位区随之拉伸填充,与会话列表填充方式一致,建会话前后高度保持一致、不再跳动。 fix(ui): stop welcome session panel height jump on first session (#214) The quick-connect card sat at natural height with a bottom spacer when empty, then stretched to fill once the first session existed, causing the panel height to jump. The card now always fills the remaining height and the empty-state placeholder stretches to match the session list, so the height stays stable before and after adding a session. Co-authored-by: Cursor --- ui/welcome.slint | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/ui/welcome.slint b/ui/welcome.slint index 00c2b185..15aa5bdf 100644 --- a/ui/welcome.slint +++ b/ui/welcome.slint @@ -275,12 +275,11 @@ export component Welcome inherits Rectangle { } } - // Quick Connect card — fills the remaining height *when there are - // sessions* so the list can scroll (#116); when empty it stays at its - // natural height right under the header (a spacer below soaks up the rest) - // so the empty placeholder doesn't get stranded at the bottom (#131-ux). + // Quick Connect card — always fills the remaining height so the panel + // doesn't jump when the first session is added (the empty placeholder + // stretches to fill just like the session list does) (#214). Rectangle { - vertical-stretch: (root.compact || root.sessions.length > 0) ? 1 : 0; + vertical-stretch: 1; background: Theme.bg-panel; border-radius: Theme.radius-md; border-width: 1px; @@ -341,7 +340,7 @@ export component Welcome inherits Rectangle { } if sessions.length == 0 : Rectangle { - height: 120px; + vertical-stretch: 1; background: Theme.bg-root; border-radius: Theme.radius-sm; border-width: 1px; @@ -494,9 +493,5 @@ export component Welcome inherits Rectangle { } } } - - // No sessions → soak up the leftover height below the natural-height card - // so it sits right under the header instead of stranded at the bottom. - if !root.compact && root.sessions.length == 0 : Rectangle { vertical-stretch: 1; } } } From 58c728c496a43d5228e291c69b182fb207bbba88 Mon Sep 17 00:00:00 2001 From: yiTuoRou <1561074588@qq.com> Date: Sat, 4 Jul 2026 20:47:46 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20SSH=20?= =?UTF-8?q?=E8=B7=B3=E6=9D=BF=E6=9C=BA=EF=BC=88=E5=A0=A1=E5=9E=92=E6=9C=BA?= =?UTF-8?q?=EF=BC=89=E6=94=AF=E6=8C=81=20(#211)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 会话现在可以指定另一个已保存的 SSH 会话作为跳板机(类似 OpenSSH 的 ProxyJump):先连上跳板并认证,在其上开一条 direct-tcpip 通道到目标机,再在通道上完成目标机的 SSH 握手。终端与 SFTP 两条连接都会经跳板走,跳板连接在整个会话期间保活、会话关闭时一并断开。 跳板复用被引用会话自身的主机/账号/密钥/keyboard-interactive 认证,未存凭据时沿用原有登录弹窗;自动忽略指向自己或已删除会话的无效引用。当前仅支持单级跳板。 会话对话框「高级」区(仅 SSH)新增「跳板机(可选)」下拉,可从其它已保存的 SSH 会话中选择,默认直连。 feat: add SSH jump host (bastion) support (#211) A session can now tunnel through another saved SSH session as a jump host (like OpenSSH's ProxyJump): connect and authenticate to the jump, open a direct-tcpip channel to the target, and run the target's SSH handshake over it. Both the shell and SFTP connections go through the jump, which is kept alive for the whole session and torn down when it closes. The jump reuses the referenced session's own host/user/key/keyboard-interactive auth, falling back to the usual login prompt when no credentials are stored; dangling or self references are ignored. Single hop only for now. The session dialog's Advanced section (SSH only) gains an optional "Jump host" dropdown listing other saved SSH sessions, defaulting to a direct connection. Co-authored-by: Cursor --- lang/en/LC_MESSAGES/meatshell.po | 6 + lang/zh/LC_MESSAGES/meatshell.po | 6 + src/app.rs | 74 ++++++++- src/config.rs | 6 + src/sftp.rs | 52 +++++-- src/ssh.rs | 247 ++++++++++++++++++++++--------- ui/app.slint | 7 + ui/session_dialog.slint | 115 ++++++++++++++ 8 files changed, 433 insertions(+), 80 deletions(-) diff --git a/lang/en/LC_MESSAGES/meatshell.po b/lang/en/LC_MESSAGES/meatshell.po index 839754ce..30b49135 100644 --- a/lang/en/LC_MESSAGES/meatshell.po +++ b/lang/en/LC_MESSAGES/meatshell.po @@ -396,6 +396,12 @@ msgstr "Proxy address" msgid "Format: host:port (with auth: user:pass@host:port)" msgstr "Format: host:port (with auth: user:pass@host:port)" +msgid "Jump host (optional)" +msgstr "Jump host (optional)" + +msgid "Tunnel this connection through another saved SSH session." +msgstr "Tunnel this connection through another saved SSH session." + msgid "Connection type" msgstr "Connection type" diff --git a/lang/zh/LC_MESSAGES/meatshell.po b/lang/zh/LC_MESSAGES/meatshell.po index cba9f094..df64871a 100644 --- a/lang/zh/LC_MESSAGES/meatshell.po +++ b/lang/zh/LC_MESSAGES/meatshell.po @@ -396,6 +396,12 @@ msgstr "代理地址" msgid "Format: host:port (with auth: user:pass@host:port)" msgstr "格式:host:port(带认证:user:pass@host:port)" +msgid "Jump host (optional)" +msgstr "跳板机(可选)" + +msgid "Tunnel this connection through another saved SSH session." +msgstr "通过另一个已保存的 SSH 会话作为跳板来连接。" + msgid "Connection type" msgstr "连接类型" diff --git a/src/app.rs b/src/app.rs index b3e617ea..33a185f0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1553,6 +1553,7 @@ pub fn run() -> Result<()> { local_net_hist: local_net_hist.clone(), last_term_size: last_term_size.clone(), sftp_follow_cd: sftp_follow_cd.clone(), + store: store.clone(), }, ); @@ -2044,6 +2045,46 @@ fn session_groups_model(store: &ConfigStore) -> ModelRc { ))) } +/// Build the jump-host picker's parallel label/id lists for the session dialog +/// (#211). Index 0 is always the "no jump host" entry (empty id); the rest are +/// the saved SSH sessions except `exclude_id` (a session can't jump through +/// itself). Returns `(labels, ids, selected_index)` where `selected_index` +/// points at `current_jump_id` (0 if unset / dangling). +fn jump_candidates( + store: &ConfigStore, + exclude_id: &str, + current_jump_id: &str, +) -> (ModelRc, ModelRc, i32) { + let mut labels: Vec = + vec![t("无(直接连接)", "None (direct)").into()]; + let mut ids: Vec = vec!["".into()]; + let mut selected: i32 = 0; + for s in store.sessions() { + if s.kind != SessionKind::Ssh || s.id == exclude_id { + continue; + } + let label = if s.name.trim().is_empty() { + if s.user.trim().is_empty() { + s.host.clone() + } else { + format!("{}@{}", s.user, s.host) + } + } else { + format!("{} ({}@{})", s.name, s.user, s.host) + }; + if s.id == current_jump_id { + selected = ids.len() as i32; + } + labels.push(label.into()); + ids.push(s.id.clone().into()); + } + ( + ModelRc::from(Rc::new(VecModel::from(labels))), + ModelRc::from(Rc::new(VecModel::from(ids))), + selected, + ) +} + fn sync_sessions_to_model(store: &ConfigStore, model: &VecModel) { // Group sessions by their `group` (named groups alphabetically, ungrouped // last), then by name within each group, and tag the first row of every @@ -2171,6 +2212,11 @@ fn wire_session_callbacks( w.set_session_groups(session_groups_model(&store_ng.borrow())); w.set_dialog_forwards(forward_model(&[])); let empty = Session::new_empty(); + let (jump_labels, jump_ids, jump_idx) = + jump_candidates(&store_ng.borrow(), &empty.id, ""); + w.set_jump_choices(jump_labels); + w.set_jump_ids(jump_ids); + w.set_dialog_jump_index(jump_idx); w.set_dialog_id(empty.id.into()); w.set_dialog_name("".into()); w.set_dialog_host("".into()); @@ -2390,6 +2436,11 @@ fn wire_session_callbacks( let (proxy_type, proxy_hostport) = split_proxy(&session.proxy); w.set_dialog_proxy_type(proxy_type.into()); w.set_dialog_proxy_hostport(proxy_hostport.into()); + let (jump_labels, jump_ids, jump_idx) = + jump_candidates(&store, &session.id, &session.jump_session_id); + w.set_jump_choices(jump_labels); + w.set_jump_ids(jump_ids); + w.set_dialog_jump_index(jump_idx); w.set_dialog_group(session.group.clone().into()); w.set_dialog_kind(session.kind.as_str().into()); w.set_dialog_serial_port(session.serial_port.clone().into()); @@ -2649,6 +2700,7 @@ fn wire_session_callbacks( forwards: edit_forwards.borrow().clone(), disable_shell_integration: draft.disable_shell_integration, note: draft.note.to_string(), + jump_session_id: draft.jump_session_id.to_string(), }; { let mut s = store.borrow_mut(); @@ -2907,6 +2959,7 @@ fn wire_session_callbacks( local_net_hist: local_net_hist.clone(), last_term_size: last_term_size.clone(), sftp_follow_cd: sftp_follow_cd.clone(), + store: store.clone(), }; start_session_in_tab(&tab_id, session, &ctx); }); @@ -2962,6 +3015,21 @@ struct ConnectCtx { last_term_size: Arc>, /// Interface setting: SFTP panel follows the terminal's cd (OSC 7). sftp_follow_cd: Arc, + /// Config store, so a session's jump host (#211) can be resolved by id at + /// connect time on the UI thread. + store: Rc>, +} + +/// Resolve a session's configured SSH jump host to the saved session it points +/// at, ignoring a missing / dangling / self reference (#211). +fn resolve_jump(store: &Rc>, session: &Session) -> Option { + if session.kind != SessionKind::Ssh || session.jump_session_id.trim().is_empty() { + return None; + } + if session.jump_session_id == session.id { + return None; + } + store.borrow().get(&session.jump_session_id).cloned() } /// Spawn the shell (+ SFTP) workers and their event-pump threads for an @@ -2970,11 +3038,15 @@ struct ConnectCtx { fn start_session_in_tab(tab_id: &str, session: Session, ctx: &ConnectCtx) { let has_sftp = session.kind == SessionKind::Ssh; let (initial_cols, initial_rows) = *ctx.last_term_size.lock().unwrap(); + // Resolve the optional SSH jump host now (on the UI thread, where the store + // lives) so the owned Session can be handed to the worker threads (#211). + let jump = resolve_jump(&ctx.store, &session); let (handle, rx) = match session.kind { SessionKind::Ssh => spawn_session( ctx.runtime.handle(), tab_id.to_string(), session.clone(), + jump.clone(), initial_cols, initial_rows, ), @@ -2996,7 +3068,7 @@ fn start_session_in_tab(tab_id: &str, session: Session, ctx: &ConnectCtx) { // Separate SFTP connection for the same session (SSH only). let sftp_evt_tx = if has_sftp { let (sftp_tx, sftp_rx) = tokio::sync::mpsc::unbounded_channel::(); - let sftp_handle = spawn_sftp(ctx.runtime.handle(), session, sftp_tx); + let sftp_handle = spawn_sftp(ctx.runtime.handle(), session, jump, sftp_tx); ctx.sftp_handles .lock() .unwrap() diff --git a/src/config.rs b/src/config.rs index 930909ce..eb88f78a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -388,6 +388,11 @@ pub struct Session { /// "http://user:pass@host:8080". Empty = use $ALL_PROXY, else direct. #[serde(default)] pub proxy: String, + /// Optional SSH jump host (bastion): the id of another saved SSH session to + /// tunnel this connection through, like OpenSSH's ProxyJump. Empty = direct. + /// Single hop only; the jump session supplies its own host/user/auth (#211). + #[serde(default)] + pub jump_session_id: String, #[serde(default)] pub last_used: Option, /// Optional folder/group name to organize sessions in the list (#41). @@ -468,6 +473,7 @@ impl Session { private_key_path: String::new(), private_key_inline: Secret::default(), proxy: String::new(), + jump_session_id: String::new(), last_used: None, group: String::new(), kind: SessionKind::Ssh, diff --git a/src/sftp.rs b/src/sftp.rs index 2ae97cf8..e609f953 100644 --- a/src/sftp.rs +++ b/src/sftp.rs @@ -227,13 +227,14 @@ fn friendly_sftp_error(err: &anyhow::Error) -> String { pub fn spawn_sftp( runtime: &tokio::runtime::Handle, session: Session, + jump: Option, events: UnboundedSender, ) -> SftpHandle { let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); let self_tx = cmd_tx.clone(); let events_err = events.clone(); let join = runtime.spawn(async move { - if let Err(err) = run_sftp(session, cmd_rx, self_tx, events).await { + if let Err(err) = run_sftp(session, jump, cmd_rx, self_tx, events).await { let _ = events_err.send(SessionEvent::SftpStatus(friendly_sftp_error(&err))); } }); @@ -316,6 +317,7 @@ async fn sync_tree_dir( async fn run_sftp( session: Session, + jump: Option, mut commands: UnboundedReceiver, self_tx: UnboundedSender, events: UnboundedSender, @@ -344,19 +346,43 @@ async fn run_sftp( }); let addr = format!("{}:{}", session.host, session.port); - // Tunnel through the same proxy as the shell session, if configured. - let mut handle = match crate::proxy::resolve(&session.proxy) { - Some(p) => { - let stream = crate::proxy::connect(&p, &session.host, session.port) - .await - .with_context(|| format!("sftp proxy connect {} failed", addr))?; - client::connect_stream(config, stream, sftp_handler(&session, &events)) - .await - .with_context(|| format!("sftp connect {} failed", addr))? - } - None => client::connect(config, addr.as_str(), sftp_handler(&session, &events)) + // Keep the jump-host connection alive for the whole SFTP session — the + // direct-tcpip tunnel rides on it (#211). Declared here so it lives to the + // end of the function; `_`-prefixed so it isn't flagged unused. + let _jump_keepalive; + // Tunnel through an SSH jump host (#211), the same proxy as the shell (#7), + // or connect directly. + let mut handle = match &jump { + Some(j) => { + let (h, jh) = crate::ssh::connect_target_via_jump( + j, + &session.host, + session.port, + config, + sftp_handler(&session, &events), + &events, + ) .await - .with_context(|| format!("sftp connect {} failed", addr))?, + .with_context(|| format!("sftp connect {} via jump failed", addr))?; + _jump_keepalive = Some(jh); + h + } + None => { + _jump_keepalive = None; + match crate::proxy::resolve(&session.proxy) { + Some(p) => { + let stream = crate::proxy::connect(&p, &session.host, session.port) + .await + .with_context(|| format!("sftp proxy connect {} failed", addr))?; + client::connect_stream(config, stream, sftp_handler(&session, &events)) + .await + .with_context(|| format!("sftp connect {} failed", addr))? + } + None => client::connect(config, addr.as_str(), sftp_handler(&session, &events)) + .await + .with_context(|| format!("sftp connect {} failed", addr))?, + } + } }; // Resolve missing username/password (shares the shell's prompt; the UI diff --git a/src/ssh.rs b/src/ssh.rs index 002bd871..e6d34f26 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -496,6 +496,7 @@ pub fn spawn_session( runtime: &tokio::runtime::Handle, tab_id: String, session: Session, + jump: Option, initial_cols: u32, initial_rows: u32, ) -> (SessionHandle, UnboundedReceiver) { @@ -506,6 +507,7 @@ pub fn spawn_session( let join = runtime.spawn(async move { if let Err(err) = run_session( session, + jump, cmd_rx, evt_tx_for_task.clone(), initial_cols, @@ -535,9 +537,10 @@ pub fn spawn_session( /// already failed (#86). async fn connect_ssh( session: &Session, + jump: Option<&Session>, config: Arc, events: &UnboundedSender, -) -> Result> { +) -> Result<(Handle, Option>)> { // Remote (-R) forwards are serviced inside the handler when the server opens // channels back, so it needs the bind-port → local-target map up front (the // handler is moved into `connect`) (#56). @@ -554,6 +557,26 @@ async fn connect_ssh( events: events.clone(), }; let addr = format!("{}:{}", session.host, session.port); + + // SSH jump host (bastion): connect + authenticate the jump session, then open + // a direct-tcpip channel through it to this host and run the SSH handshake + // over that tunnel. The returned jump handle must be kept alive for the whole + // session (the tunnel lives on it) (#211). + if let Some(j) = jump { + let _ = events.send(SessionEvent::Status(format!( + "{} {}@{} → {}", + t("经跳板机连接", "via jump host"), + j.user, + j.host, + addr + ))); + let (handle, jump_handle) = + connect_target_via_jump(j, &session.host, session.port, config, handler, events) + .await + .with_context(|| format!("connect {} via jump failed", addr))?; + return Ok((handle, Some(jump_handle))); + } + // Connect directly, or tunnel through a SOCKS5 / HTTP proxy (issue #7). let handle = match crate::proxy::resolve(&session.proxy) { Some(p) => { @@ -574,7 +597,131 @@ async fn connect_ssh( .await .with_context(|| format!("connect {} failed", addr))?, }; - Ok(handle) + Ok((handle, None)) +} + +/// Outcome of authenticating an SSH session, so callers can distinguish a user +/// cancel from a credential rejection and word the status line accordingly. +pub(crate) enum AuthResult { + Success, + Cancelled, + Failed, +} + +/// Authenticate an already-connected SSH handle using the session's method, +/// prompting for missing credentials and falling back from `password` to +/// `keyboard-interactive` on a fresh handle (#86). Shared by the shell, SFTP and +/// jump-host paths. On the keyboard-interactive fallback it reconnects, updating +/// both `handle` and `jump_handle` in place so the caller keeps the live tunnel. +pub(crate) async fn authenticate_session( + handle: &mut Handle, + jump_handle: &mut Option>, + session: &Session, + jump: Option<&Session>, + config: Arc, + events: &UnboundedSender, +) -> Result { + let (user, password) = match resolve_credentials(session, events).await { + Some(c) => c, + None => return Ok(AuthResult::Cancelled), + }; + + let authed = match session.auth { + AuthMethod::Password => { + let mut ok = handle + .authenticate_password(&user, password.as_str()) + .await + .context("password auth failed")?; + if !ok { + // russh can't switch auth methods on a handle whose first attempt + // already failed (it hangs), so reconnect on a fresh handle before + // trying keyboard-interactive (#86). + let _ = handle.disconnect(Disconnect::ByApplication, "", "").await; + let (h, jh) = Box::pin(connect_ssh(session, jump, config.clone(), events)).await?; + *handle = h; + *jump_handle = jh; + ok = keyboard_interactive_auth( + handle, + &user, + password.as_str(), + &session.id, + &session.host, + events, + ) + .await + .context("keyboard-interactive auth failed")?; + } + ok + } + AuthMethod::Key => { + // An encrypted private key needs its passphrase; we reuse the + // session's password field for it (empty = unencrypted key) (#90). + let pass = password.as_str(); + let keypair = load_session_private_key(session, pass)?; + // RSA keys must be signed with an explicit SHA-2 hash; every other + // key type carries its own algorithm, so no override is needed. + let hash = keypair.algorithm().is_rsa().then_some(HashAlg::Sha256); + let key_with_hash = PrivateKeyWithHashAlg::new(Arc::new(keypair), hash) + .context("invalid private key / hash algorithm combination")?; + handle + .authenticate_publickey(&user, key_with_hash) + .await + .context("publickey auth failed")? + } + }; + + if authed { + Ok(AuthResult::Success) + } else { + Ok(AuthResult::Failed) + } +} + +/// Connect + authenticate a jump/bastion session, open a `direct-tcpip` channel +/// to `target_host:target_port`, and run the target's SSH handshake over it. +/// Returns the target handle plus the jump handle, which the caller MUST keep +/// alive for as long as the target session lives (the tunnel rides on it) (#211). +pub(crate) async fn connect_target_via_jump( + jump: &Session, + target_host: &str, + target_port: u16, + config: Arc, + handler: H, + events: &UnboundedSender, +) -> Result<(Handle, Handle)> +where + H: client::Handler + 'static, + H::Error: std::error::Error + Send + Sync + 'static, +{ + // Single hop: the jump session itself never goes through another jump. + // `Box::pin` breaks the async recursion (connect_ssh → jump → connect_ssh). + let (mut jhandle, mut no_nested) = Box::pin(connect_ssh(jump, None, config.clone(), events)) + .await + .with_context(|| format!("connect jump host {}:{} failed", jump.host, jump.port))?; + match authenticate_session(&mut jhandle, &mut no_nested, jump, None, config.clone(), events) + .await? + { + AuthResult::Success => {} + AuthResult::Cancelled => { + return Err(anyhow!(t("跳板机登录已取消", "jump host login cancelled"))) + } + AuthResult::Failed => { + return Err(anyhow!(t("跳板机认证失败", "jump host authentication failed"))) + } + } + let channel = jhandle + .channel_open_direct_tcpip( + target_host.to_string(), + target_port as u32, + "127.0.0.1".to_string(), + 0, + ) + .await + .with_context(|| format!("open jump tunnel to {target_host}:{target_port}"))?; + let handle = client::connect_stream(config, channel.into_stream(), handler) + .await + .with_context(|| format!("SSH handshake to {target_host}:{target_port} via jump"))?; + Ok((handle, jhandle)) } // Key-exchange algorithms offered to the server, strongest first. This is the @@ -617,6 +764,7 @@ pub(crate) const COMPAT_CIPHER: &[russh::cipher::Name] = &[ async fn run_session( session: Session, + jump: Option, mut commands: UnboundedReceiver, events: UnboundedSender, initial_cols: u32, @@ -649,12 +797,25 @@ async fn run_session( ..<_>::default() }); - let mut handle = connect_ssh(&session, config.clone(), &events).await?; - - // Resolve missing username/password by prompting the user (#110). - let (user, password) = match resolve_credentials(&session, &events).await { - Some(c) => c, - None => { + let (mut handle, mut jump_handle) = + connect_ssh(&session, jump.as_ref(), config.clone(), &events).await?; + + // --- Auth (shared with SFTP + jump-host paths) --------------------- + // Try plain `password` first, then `keyboard-interactive` on a fresh handle — + // many bastions (JumpServer) disable `password` (#86). Missing credentials + // are prompted for (#110). + match authenticate_session( + &mut handle, + &mut jump_handle, + &session, + jump.as_ref(), + config.clone(), + &events, + ) + .await? + { + AuthResult::Success => {} + AuthResult::Cancelled => { let _ = events.send(SessionEvent::Closed( t("已取消登录", "login cancelled").into(), )); @@ -663,67 +824,21 @@ async fn run_session( .await; return Ok(()); } - }; - - // --- Auth ---------------------------------------------------------- - let authed = match session.auth { - AuthMethod::Password => { - // Try plain `password` auth first; if the server doesn't offer it, - // fall back to `keyboard-interactive` and answer each prompt with the - // same password. Many bastions (JumpServer especially) disable the - // `password` method and only accept keyboard-interactive, which is - // why other clients (Xshell / MobaXterm / WindTerm) get in but plain - // password auth fails here (#86). - let mut ok = handle - .authenticate_password(&user, password.as_str()) - .await - .context("password auth failed")?; - if !ok { - // russh can't switch auth methods on a handle whose first attempt - // already failed (it hangs), so reconnect on a fresh handle before - // trying keyboard-interactive (#86). - let _ = handle.disconnect(Disconnect::ByApplication, "", "").await; - handle = connect_ssh(&session, config.clone(), &events).await?; - ok = keyboard_interactive_auth( - &mut handle, - &user, - password.as_str(), - &session.id, - &session.host, - &events, - ) - .await - .context("keyboard-interactive auth failed")?; - } - ok - } - AuthMethod::Key => { - // An encrypted private key needs its passphrase; we reuse the - // session's password field for it (empty = unencrypted key) (#90). - let pass = password.as_str(); - let keypair = load_session_private_key(&session, pass)?; - // RSA keys must be signed with an explicit SHA-2 hash; every other - // key type carries its own algorithm, so no override is needed. - let hash = keypair.algorithm().is_rsa().then_some(HashAlg::Sha256); - let key_with_hash = PrivateKeyWithHashAlg::new(Arc::new(keypair), hash) - .context("invalid private key / hash algorithm combination")?; - handle - .authenticate_publickey(&user, key_with_hash) - .await - .context("publickey auth failed")? + AuthResult::Failed => { + tracing::warn!("ssh authentication failed for {}@{}", session.user, session.host); + let _ = events.send(SessionEvent::Closed( + t("认证失败", "authentication failed").into(), + )); + let _ = handle + .disconnect(Disconnect::ByApplication, "auth failed", "") + .await; + return Ok(()); } }; - if !authed { - tracing::warn!("ssh authentication failed for {}@{}", user, session.host); - let _ = events.send(SessionEvent::Closed( - t("认证失败", "authentication failed").into(), - )); - let _ = handle - .disconnect(Disconnect::ByApplication, "auth failed", "") - .await; - return Ok(()); - } + // Keep the jump-host connection alive for the whole session — the direct-tcpip + // tunnel that carries this session rides on it (#211). + let _jump_keepalive = jump_handle; // --- Shell channel -------------------------------------------------- let mut channel = handle diff --git a/ui/app.slint b/ui/app.slint index a5feeec1..e3baf9d3 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -583,6 +583,10 @@ export component AppWindow inherits Window { in-out property dialog-group; // Existing group names (from Rust) for the dialog's group dropdown (#179). in property <[string]> session-groups; + // SSH jump host picker (#211): parallel label/id lists + selected index. + in property <[string]> jump-choices; + in property <[string]> jump-ids; + in-out property dialog-jump-index: 0; in-out property dialog-kind: "ssh"; in-out property dialog-serial-port; in-out property dialog-baud: "115200"; @@ -3115,6 +3119,9 @@ export component AppWindow inherits Window { draft-proxy-hostport <=> root.dialog-proxy-hostport; draft-group <=> root.dialog-group; session-groups: root.session-groups; + jump-choices: root.jump-choices; + jump-ids: root.jump-ids; + draft-jump-index <=> root.dialog-jump-index; draft-kind <=> root.dialog-kind; draft-serial-port <=> root.dialog-serial-port; draft-baud <=> root.dialog-baud; diff --git a/ui/session_dialog.slint b/ui/session_dialog.slint index 684c6f37..60f4dfb0 100644 --- a/ui/session_dialog.slint +++ b/ui/session_dialog.slint @@ -35,6 +35,9 @@ export struct SessionDraft { disable-shell-integration: bool, // Free-form note (B站 suggestion). note: string, + // Id of another saved SSH session to use as a jump host / bastion, or "" for + // a direct connection (#211). + jump-session-id: string, } // A single segment in a segmented selector (auth toggle, baud presets, …). @@ -195,6 +198,96 @@ component GroupCombo inherits VerticalLayout { } } +// Read-only dropdown picking an SSH jump host from a fixed list of saved +// sessions (#211). Index-based: `choices[0]` is the "no jump host" entry, and +// `selected-index` maps into the parallel `ids` list held by the caller. +component JumpCombo inherits VerticalLayout { + in property label; + in property <[string]> choices; // display labels; index 0 = "no jump host" + in-out property selected-index: 0; + spacing: 4px; + + Text { + text: root.label; + color: Theme.text-secondary; + font-size: Theme.fs-sm; + } + + Rectangle { + height: 32px; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: jump-ta.has-hover ? Theme.accent : Theme.border-subtle; + background: Theme.bg-root; + + jump-ta := TouchArea { + mouse-cursor: pointer; + clicked => { jump-popup.show(); } + } + + Text { + x: 10px; + width: parent.width - 44px; + height: parent.height; + text: (root.selected-index >= 0 && root.selected-index < root.choices.length) + ? root.choices[root.selected-index] : ""; + color: root.selected-index > 0 ? Theme.text-primary : Theme.text-muted; + font-size: Theme.fs-md; + vertical-alignment: center; + overflow: elide; + } + + Rectangle { + x: parent.width - 36px; + width: 28px; + height: 28px; + border-radius: Theme.radius-sm; + background: jump-ta.has-hover ? Theme.bg-hover : transparent; + Text { + text: "▼"; + color: Theme.text-secondary; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + jump-popup := PopupWindow { + x: 0; + y: parent.height + 3px; + width: parent.width; + Rectangle { + background: Theme.bg-elevated; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: Theme.border-subtle; + VerticalLayout { + padding: 4px; + spacing: 1px; + for c[idx] in root.choices : Rectangle { + height: 28px; + border-radius: Theme.radius-sm; + background: c-ta.has-hover ? Theme.bg-hover : transparent; + c-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.selected-index = idx; jump-popup.close(); } + } + Text { + x: 10px; + width: parent.width - 20px; + text: c; + color: Theme.text-primary; + font-size: Theme.fs-md; + vertical-alignment: center; + overflow: elide; + } + } + } + } + } + } +} + // Modal dialog for creating / editing a session (SSH / Serial / Telnet). // The dialog is purely presentational — it stores values in mutable // properties and emits `submit` / `cancel` callbacks up to the main window. @@ -218,6 +311,11 @@ export component SessionDialog inherits Rectangle { in-out property draft-proxy-hostport; in-out property draft-group; in property <[string]> session-groups; // existing groups for the dropdown (#179) + // SSH jump host picker (#211): parallel label/id lists (index 0 = "none") + // plus the selected index. The id is resolved on submit via `jump-ids`. + in property <[string]> jump-choices; + in property <[string]> jump-ids; + in-out property draft-jump-index: 0; in-out property draft-serial-port; in-out property draft-baud: "115200"; in-out property draft-data-bits: "8"; @@ -633,6 +731,21 @@ export component SessionDialog inherits Rectangle { } } + // ── SSH jump host / bastion (SSH only) (#211) ─────────────────── + if root.show-advanced && root.draft-kind == "ssh" : VerticalLayout { + spacing: 4px; + JumpCombo { + label: @tr("Jump host (optional)"); + choices: root.jump-choices; + selected-index <=> root.draft-jump-index; + } + Text { + text: @tr("Tunnel this connection through another saved SSH session."); + color: Theme.text-muted; + font-size: Theme.fs-xs; + } + } + // ── Port forwarding / tunnels (SSH only) (#56) ────────────────── if root.show-advanced && root.draft-kind == "ssh" : VerticalLayout { spacing: 6px; @@ -802,6 +915,8 @@ export component SessionDialog inherits Rectangle { flow-control: root.draft-flow, disable-shell-integration: root.draft-disable-shell-integration, note: root.draft-note, + jump-session-id: (root.draft-jump-index > 0 && root.draft-jump-index < root.jump-ids.length) + ? root.jump-ids[root.draft-jump-index] : "", }); } } From 0fc73e0d7b2c5c5477871254c9f766eb9fae9092 Mon Sep 17 00:00:00 2001 From: yiTuoRou <1561074588@qq.com> Date: Sat, 4 Jul 2026 22:43:38 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20/=20Added=20SSH=20?= =?UTF-8?q?=E8=B7=B3=E6=9D=BF=E6=9C=BA=EF=BC=88=E5=A0=A1=E5=9E=92=E6=9C=BA?= =?UTF-8?q?=EF=BC=89=E6=94=AF=E6=8C=81=20(#211)=E3=80=82=20=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=8F=AF=E6=8C=87=E5=AE=9A=E5=8F=A6=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E5=B7=B2=E4=BF=9D=E5=AD=98=E7=9A=84=20SSH=20=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E4=BD=9C=E4=B8=BA=E8=B7=B3=E6=9D=BF=E6=9C=BA=EF=BC=88=E7=B1=BB?= =?UTF-8?q?=E4=BC=BC=20OpenSSH=20=E7=9A=84=20ProxyJump=EF=BC=89=EF=BC=9A?= =?UTF-8?q?=E5=85=88=E8=BF=9E=E4=B8=8A=E8=B7=B3=E6=9D=BF=E5=B9=B6=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=EF=BC=8C=E5=9C=A8=E5=85=B6=E4=B8=8A=E5=BC=80=E4=B8=80?= =?UTF-8?q?=E6=9D=A1=20direct-tcpip=20=E9=80=9A=E9=81=93=E5=88=B0=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E6=9C=BA=EF=BC=8C=E5=86=8D=E5=AE=8C=E6=88=90=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E6=9C=BA=E7=9A=84=20SSH=20=E6=8F=A1=E6=89=8B=E3=80=82?= =?UTF-8?q?=E7=BB=88=E7=AB=AF=E4=B8=8E=20SFTP=20=E4=B8=A4=E6=9D=A1?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E9=83=BD=E7=BB=8F=E8=B7=B3=E6=9D=BF=E8=B5=B0?= =?UTF-8?q?=EF=BC=8C=E8=B7=B3=E6=9D=BF=E8=BF=9E=E6=8E=A5=E5=9C=A8=E6=95=B4?= =?UTF-8?q?=E4=B8=AA=E4=BC=9A=E8=AF=9D=E6=9C=9F=E9=97=B4=E4=BF=9D=E6=B4=BB?= =?UTF-8?q?=E3=80=81=E4=BC=9A=E8=AF=9D=E5=85=B3=E9=97=AD=E6=97=B6=E4=B8=80?= =?UTF-8?q?=E5=B9=B6=E6=96=AD=E5=BC=80=E3=80=82=E8=B7=B3=E6=9D=BF=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E8=A2=AB=E5=BC=95=E7=94=A8=E4=BC=9A=E8=AF=9D=E8=87=AA?= =?UTF-8?q?=E8=BA=AB=E7=9A=84=E4=B8=BB=E6=9C=BA/=E8=B4=A6=E5=8F=B7/?= =?UTF-8?q?=E5=AF=86=E9=92=A5/keyboard-interactive=20=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=EF=BC=8C=E6=9C=AA=E5=AD=98=E5=87=AD=E6=8D=AE=E6=97=B6=E6=B2=BF?= =?UTF-8?q?=E7=94=A8=E5=8E=9F=E6=9C=89=E7=99=BB=E5=BD=95=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=EF=BC=9B=E8=87=AA=E5=8A=A8=E5=BF=BD=E7=95=A5=E6=8C=87=E5=90=91?= =?UTF-8?q?=E8=87=AA=E5=B7=B1=E6=88=96=E5=B7=B2=E5=88=A0=E9=99=A4=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E7=9A=84=E6=97=A0=E6=95=88=E5=BC=95=E7=94=A8=EF=BC=9B?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E4=BB=85=E6=94=AF=E6=8C=81=E5=8D=95=E7=BA=A7?= =?UTF-8?q?=E8=B7=B3=E6=9D=BF=E3=80=82=E4=BC=9A=E8=AF=9D=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=A1=86=E3=80=8C=E9=AB=98=E7=BA=A7=E3=80=8D=E5=8C=BA=EF=BC=88?= =?UTF-8?q?=E4=BB=85=20SSH=EF=BC=89=E6=96=B0=E5=A2=9E=E3=80=8C=E8=B7=B3?= =?UTF-8?q?=E6=9D=BF=E6=9C=BA=EF=BC=88=E5=8F=AF=E9=80=89=EF=BC=89=E3=80=8D?= =?UTF-8?q?=E4=B8=8B=E6=8B=89=E3=80=82=20=E4=BF=AE=E5=A4=8D=20/=20Fixed=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=9A=E4=BC=9A=E8=AF=9D=E5=A4=A7=E9=87=8F?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E5=AF=BC=E8=87=B4=E7=95=8C=E9=9D=A2=E5=8D=A1?= =?UTF-8?q?=E6=AD=BB=20(#209)=E3=80=82=20=E5=B0=86=20VT100=20=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E7=A7=BB=E5=87=BA=20UI=20=E7=BA=BF=E7=A8=8B=E3=80=81?= =?UTF-8?q?=E6=8C=89=E7=BA=A6=2030fps=20=E8=8A=82=E6=B5=81=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E3=80=81=E5=90=88=E5=B9=B6=E8=BE=93=E5=87=BA=E5=9D=97?= =?UTF-8?q?=E5=B9=B6=E6=94=B9=E7=94=A8=E6=8C=89=E6=A0=87=E7=AD=BE=E9=A1=B5?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E7=9A=84=E7=BC=93=E5=86=B2=E9=94=81=EF=BC=8C?= =?UTF-8?q?=E6=9F=90=E5=8F=B0=E6=9C=8D=E5=8A=A1=E5=99=A8=E8=A7=A3=E5=8E=8B?= =?UTF-8?q?=E5=A4=A7=E9=87=8F=E6=96=87=E4=BB=B6=E6=97=B6=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=8B=96=E5=9E=AE=E5=85=B6=E5=AE=83=E4=BC=9A=E8=AF=9D=E4=B8=8E?= =?UTF-8?q?=E6=95=B4=E4=B8=AA=E7=95=8C=E9=9D=A2=E3=80=82=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=AC=A2=E8=BF=8E=E9=A1=B5=E4=BC=9A=E8=AF=9D=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E9=A6=96=E6=AC=A1=E5=BB=BA=E4=BC=9A=E8=AF=9D=E6=97=B6?= =?UTF-8?q?=E9=AB=98=E5=BA=A6=E8=B7=B3=E5=8F=98=20(#214)=E3=80=82=20?= =?UTF-8?q?=E5=BF=AB=E9=80=9F=E8=BF=9E=E6=8E=A5=E5=8D=A1=E7=89=87=E7=8E=B0?= =?UTF-8?q?=E5=9C=A8=E5=A7=8B=E7=BB=88=E6=92=91=E6=BB=A1=E5=89=A9=E4=BD=99?= =?UTF-8?q?=E9=AB=98=E5=BA=A6=E3=80=81=E7=A9=BA=E7=8A=B6=E6=80=81=E5=8D=A0?= =?UTF-8?q?=E4=BD=8D=E5=8C=BA=E9=9A=8F=E4=B9=8B=E6=8B=89=E4=BC=B8=EF=BC=8C?= =?UTF-8?q?=E5=BB=BA=E4=BC=9A=E8=AF=9D=E5=89=8D=E5=90=8E=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E9=AB=98=E5=BA=A6=E4=BF=9D=E6=8C=81=E4=B8=80=E8=87=B4=E3=80=81?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=B7=B3=E5=8A=A8=E3=80=82=20Added=20SSH=20j?= =?UTF-8?q?ump=20host=20(bastion)=20support=20(#211).=20A=20session=20can?= =?UTF-8?q?=20tunnel=20through=20another=20saved=20SSH=20session=20as=20a?= =?UTF-8?q?=20jump=20host=20(like=20OpenSSH's=20ProxyJump):=20connect=20an?= =?UTF-8?q?d=20authenticate=20to=20the=20jump,=20open=20a=20direct-tcpip?= =?UTF-8?q?=20channel=20to=20the=20target,=20and=20run=20the=20target's=20?= =?UTF-8?q?SSH=20handshake=20over=20it.=20Both=20the=20shell=20and=20SFTP?= =?UTF-8?q?=20connections=20go=20through=20the=20jump,=20which=20is=20kept?= =?UTF-8?q?=20alive=20for=20the=20whole=20session=20and=20torn=20down=20wh?= =?UTF-8?q?en=20it=20closes.=20The=20jump=20reuses=20the=20referenced=20se?= =?UTF-8?q?ssion's=20own=20host/user/key/keyboard-interactive=20auth,=20fa?= =?UTF-8?q?lling=20back=20to=20the=20usual=20login=20prompt=20when=20no=20?= =?UTF-8?q?credentials=20are=20stored;=20dangling=20or=20self=20references?= =?UTF-8?q?=20are=20ignored;=20single=20hop=20only=20for=20now.=20The=20se?= =?UTF-8?q?ssion=20dialog's=20Advanced=20section=20(SSH=20only)=20gains=20?= =?UTF-8?q?an=20optional=20"Jump=20host"=20dropdown.=20Fixed=20Fix=20UI=20?= =?UTF-8?q?freeze=20on=20heavy=20output=20across=20multiple=20sessions=20(?= =?UTF-8?q?#209).=20VT100=20parsing=20moved=20off=20the=20UI=20thread,=20r?= =?UTF-8?q?endering=20throttled=20to=20~30fps,=20output=20chunks=20coalesc?= =?UTF-8?q?ed,=20and=20per-tab=20buffer=20locks=20adopted,=20so=20unzippin?= =?UTF-8?q?g=20many=20files=20on=20one=20server=20no=20longer=20stalls=20o?= =?UTF-8?q?ther=20sessions=20or=20the=20whole=20UI.=20Stop=20the=20welcome?= =?UTF-8?q?=20session=20panel=20height=20jump=20on=20the=20first=20session?= =?UTF-8?q?=20(#214).=20The=20quick-connect=20card=20now=20always=20fills?= =?UTF-8?q?=20the=20remaining=20height=20and=20the=20empty-state=20placeho?= =?UTF-8?q?lder=20stretches=20to=20match,=20keeping=20the=20panel=20height?= =?UTF-8?q?=20stable=20before=20and=20after=20adding=20a=20session.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 23 +++++++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b07ba5b..f7eaf903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,29 @@ All notable changes are documented here. 本文件记录所有重要变更。 中英对照(English first, 中文在后). +## [0.5.4] - 2026-07-04 + +### 新增 / Added + +- **SSH 跳板机(堡垒机)支持 (#211)。** 会话可指定另一个已保存的 SSH 会话作为跳板机(类似 OpenSSH 的 ProxyJump):先连上跳板并认证,在其上开一条 direct-tcpip 通道到目标机,再完成目标机的 SSH 握手。终端与 SFTP 两条连接都经跳板走,跳板连接在整个会话期间保活、会话关闭时一并断开。跳板复用被引用会话自身的主机/账号/密钥/keyboard-interactive 认证,未存凭据时沿用原有登录弹窗;自动忽略指向自己或已删除会话的无效引用;当前仅支持单级跳板。会话对话框「高级」区(仅 SSH)新增「跳板机(可选)」下拉。 + +### 修复 / Fixed + +- **修复多会话大量输出导致界面卡死 (#209)。** 将 VT100 解析移出 UI 线程、按约 30fps 节流渲染、合并输出块并改用按标签页独立的缓冲锁,某台服务器解压大量文件时不再拖垮其它会话与整个界面。 +- **修复欢迎页会话面板首次建会话时高度跳变 (#214)。** 快速连接卡片现在始终撑满剩余高度、空状态占位区随之拉伸,建会话前后面板高度保持一致、不再跳动。 + +--- + +### Added + +- **SSH jump host (bastion) support (#211).** A session can tunnel through another saved SSH session as a jump host (like OpenSSH's ProxyJump): connect and authenticate to the jump, open a direct-tcpip channel to the target, and run the target's SSH handshake over it. Both the shell and SFTP connections go through the jump, which is kept alive for the whole session and torn down when it closes. The jump reuses the referenced session's own host/user/key/keyboard-interactive auth, falling back to the usual login prompt when no credentials are stored; dangling or self references are ignored; single hop only for now. The session dialog's Advanced section (SSH only) gains an optional "Jump host" dropdown. + +### Fixed + +- **Fix UI freeze on heavy output across multiple sessions (#209).** VT100 parsing moved off the UI thread, rendering throttled to ~30fps, output chunks coalesced, and per-tab buffer locks adopted, so unzipping many files on one server no longer stalls other sessions or the whole UI. +- **Stop the welcome session panel height jump on the first session (#214).** The quick-connect card now always fills the remaining height and the empty-state placeholder stretches to match, keeping the panel height stable before and after adding a session. + + ## [0.5.3] - 2026-07-04 ### 新增 / Added diff --git a/Cargo.lock b/Cargo.lock index 9719c877..91c7634c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3391,7 +3391,7 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "meatshell" -version = "0.5.3" +version = "0.5.4" dependencies = [ "anyhow", "arboard", diff --git a/Cargo.toml b/Cargo.toml index 5863634f..e29395c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "meatshell" -version = "0.5.3" +version = "0.5.4" edition = "2021" authors = ["meatshell contributors"] description = "A lightweight FinalShell-style SSH/terminal client written in Rust + Slint" From feacbb7695927549918f81b3110dc64c78a81555 Mon Sep 17 00:00:00 2001 From: JackYu Date: Sun, 5 Jul 2026 10:46:20 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=BB=8E~/.ssh/config?= =?UTF-8?q?=E5=AF=BC=E5=85=A5ssh=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lang/en/LC_MESSAGES/meatshell.po | 6 ++ lang/zh/LC_MESSAGES/meatshell.po | 6 ++ src/app.rs | 65 ++++++++++----- src/ssh.rs | 22 +++++- src/ssh_config.rs | 54 +++++++++++++ ui/app.slint | 10 +++ ui/session_dialog.slint | 132 ++++++++++++++++++++++++++++++- 7 files changed, 272 insertions(+), 23 deletions(-) diff --git a/lang/en/LC_MESSAGES/meatshell.po b/lang/en/LC_MESSAGES/meatshell.po index 30b49135..e5f5ff86 100644 --- a/lang/en/LC_MESSAGES/meatshell.po +++ b/lang/en/LC_MESSAGES/meatshell.po @@ -381,6 +381,12 @@ msgstr "Use 'New session' (top-right) to add your first server" msgid "Import ~/.ssh/config" msgstr "Import ~/.ssh/config" +msgid "Import from SSH config" +msgstr "Import from SSH config" + +msgid "Choose a Host from ~/.ssh/config" +msgstr "Choose a Host from ~/.ssh/config" + msgid "Proxy (optional)" msgstr "Proxy (optional)" diff --git a/lang/zh/LC_MESSAGES/meatshell.po b/lang/zh/LC_MESSAGES/meatshell.po index df64871a..193ed48c 100644 --- a/lang/zh/LC_MESSAGES/meatshell.po +++ b/lang/zh/LC_MESSAGES/meatshell.po @@ -381,6 +381,12 @@ msgstr "点击右上角 “新建会话” 添加你的第一台服务器" msgid "Import ~/.ssh/config" msgstr "导入 ~/.ssh/config" +msgid "Import from SSH config" +msgstr "从 SSH 配置导入" + +msgid "Choose a Host from ~/.ssh/config" +msgstr "从 ~/.ssh/config 选择一个 Host" + msgid "Proxy (optional)" msgstr "代理(可选)" diff --git a/src/app.rs b/src/app.rs index 33a185f0..e89d0873 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2045,6 +2045,33 @@ fn session_groups_model(store: &ConfigStore) -> ModelRc { ))) } +fn string_model(values: Vec) -> ModelRc { + ModelRc::from(Rc::new(VecModel::from( + values + .into_iter() + .map(SharedString::from) + .collect::>(), + ))) +} + +fn ssh_config_models( + hosts: &[crate::ssh_config::ImportedHost], +) -> ( + ModelRc, + ModelRc, + ModelRc, + ModelRc, + ModelRc, +) { + ( + string_model(hosts.iter().map(|h| h.alias.clone()).collect()), + string_model(hosts.iter().map(|h| h.hostname.clone()).collect()), + string_model(hosts.iter().map(|h| h.port.to_string()).collect()), + string_model(hosts.iter().map(|h| h.user.clone()).collect()), + string_model(hosts.iter().map(|h| h.identity_file.clone()).collect()), + ) +} + /// Build the jump-host picker's parallel label/id lists for the session dialog /// (#211). Index 0 is always the "no jump host" entry (empty id); the rest are /// the saved SSH sessions except `exclude_id` (a session can't jump through @@ -2211,6 +2238,19 @@ fn wire_session_callbacks( ef_new.borrow_mut().clear(); w.set_session_groups(session_groups_model(&store_ng.borrow())); w.set_dialog_forwards(forward_model(&[])); + let ssh_config_hosts = crate::ssh_config::parse_default(); + let ( + ssh_config_aliases, + ssh_config_hostnames, + ssh_config_ports, + ssh_config_users, + ssh_config_key_paths, + ) = ssh_config_models(&ssh_config_hosts); + w.set_dialog_ssh_config_aliases(ssh_config_aliases); + w.set_dialog_ssh_config_hostnames(ssh_config_hostnames); + w.set_dialog_ssh_config_ports(ssh_config_ports); + w.set_dialog_ssh_config_users(ssh_config_users); + w.set_dialog_ssh_config_key_paths(ssh_config_key_paths); let empty = Session::new_empty(); let (jump_labels, jump_ids, jump_idx) = jump_candidates(&store_ng.borrow(), &empty.id, ""); @@ -2274,24 +2314,7 @@ fn wire_session_callbacks( if dup { continue; } - let auth = if h.identity_file.is_empty() { - AuthMethod::Password - } else { - AuthMethod::Key - }; - s.upsert(Session { - name: h.alias, - host: h.hostname, - port: h.port, - user: if h.user.is_empty() { - "root".into() - } else { - h.user - }, - auth, - private_key_path: h.identity_file, - ..Session::new_empty() - }); + s.upsert(h.into_session()); added += 1; } if added > 0 { @@ -2421,6 +2444,12 @@ fn wire_session_callbacks( if let Some(w) = weak.upgrade() { w.set_session_groups(session_groups_model(&store)); w.set_dialog_forwards(forward_model(&session.forwards)); + let empty_ssh_config = ssh_config_models(&[]); + w.set_dialog_ssh_config_aliases(empty_ssh_config.0); + w.set_dialog_ssh_config_hostnames(empty_ssh_config.1); + w.set_dialog_ssh_config_ports(empty_ssh_config.2); + w.set_dialog_ssh_config_users(empty_ssh_config.3); + w.set_dialog_ssh_config_key_paths(empty_ssh_config.4); w.set_dialog_id(session.id.clone().into()); w.set_dialog_name(session.name.clone().into()); w.set_dialog_host(session.host.clone().into()); diff --git a/src/ssh.rs b/src/ssh.rs index e6d34f26..e10f28af 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -698,15 +698,25 @@ where let (mut jhandle, mut no_nested) = Box::pin(connect_ssh(jump, None, config.clone(), events)) .await .with_context(|| format!("connect jump host {}:{} failed", jump.host, jump.port))?; - match authenticate_session(&mut jhandle, &mut no_nested, jump, None, config.clone(), events) - .await? + match authenticate_session( + &mut jhandle, + &mut no_nested, + jump, + None, + config.clone(), + events, + ) + .await? { AuthResult::Success => {} AuthResult::Cancelled => { return Err(anyhow!(t("跳板机登录已取消", "jump host login cancelled"))) } AuthResult::Failed => { - return Err(anyhow!(t("跳板机认证失败", "jump host authentication failed"))) + return Err(anyhow!(t( + "跳板机认证失败", + "jump host authentication failed" + ))) } } let channel = jhandle @@ -825,7 +835,11 @@ async fn run_session( return Ok(()); } AuthResult::Failed => { - tracing::warn!("ssh authentication failed for {}@{}", session.user, session.host); + tracing::warn!( + "ssh authentication failed for {}@{}", + session.user, + session.host + ); let _ = events.send(SessionEvent::Closed( t("认证失败", "authentication failed").into(), )); diff --git a/src/ssh_config.rs b/src/ssh_config.rs index 1274cd1b..7ed93a7f 100644 --- a/src/ssh_config.rs +++ b/src/ssh_config.rs @@ -7,6 +7,8 @@ use std::path::{Path, PathBuf}; +use crate::config::{AuthMethod, Session}; + /// One importable host parsed from `~/.ssh/config`. #[derive(Debug, Clone)] pub struct ImportedHost { @@ -17,6 +19,38 @@ pub struct ImportedHost { pub identity_file: String, } +impl ImportedHost { + pub fn auth_method(&self) -> AuthMethod { + if self.identity_file.is_empty() { + AuthMethod::Password + } else { + AuthMethod::Key + } + } + + /// Convert an imported OpenSSH host block into a saved meatshell session. + /// + /// The existing bulk importer historically defaulted missing users to + /// `root`; keep that behavior for compatibility while the new-session + /// picker can still use the raw parsed fields and leave user blank. + pub fn into_session(self) -> Session { + let auth = self.auth_method(); + Session { + name: self.alias, + host: self.hostname, + port: self.port, + user: if self.user.is_empty() { + "root".into() + } else { + self.user + }, + auth, + private_key_path: self.identity_file, + ..Session::new_empty() + } + } +} + /// Parse the user's `~/.ssh/config` (returns empty if it doesn't exist). pub fn parse_default() -> Vec { let Some(home) = home_dir() else { @@ -205,6 +239,26 @@ Host alias-only assert_eq!(hosts[1].hostname, "alias-only"); } + #[test] + fn imported_host_converts_to_session() { + let cfg = "\ +Host prod + HostName prod.example.com + Port 2200 + IdentityFile ~/.ssh/prod +"; + let home = Path::new("/home/me"); + let mut hosts = parse_str(cfg, home); + let session = hosts.remove(0).into_session(); + + assert_eq!(session.name, "prod"); + assert_eq!(session.host, "prod.example.com"); + assert_eq!(session.port, 2200); + assert_eq!(session.user, "root"); + assert_eq!(session.auth, AuthMethod::Key); + assert!(session.private_key_path.ends_with("/.ssh/prod")); + } + #[test] fn rejects_invalid_hostnames() { let cfg = "\ diff --git a/ui/app.slint b/ui/app.slint index e3baf9d3..9e9452a0 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -596,6 +596,11 @@ export component AppWindow inherits Window { in-out property dialog-flow: "none"; in-out property dialog-disable-shell-integration: false; in-out property dialog-note; + in property <[string]> dialog-ssh-config-aliases; + in property <[string]> dialog-ssh-config-hostnames; + in property <[string]> dialog-ssh-config-ports; + in property <[string]> dialog-ssh-config-users; + in property <[string]> dialog-ssh-config-key-paths; // Delete-confirmation modal state (#28). in-out property confirm-delete-open: false; @@ -3131,6 +3136,11 @@ export component AppWindow inherits Window { draft-flow <=> root.dialog-flow; draft-disable-shell-integration <=> root.dialog-disable-shell-integration; draft-note <=> root.dialog-note; + ssh-config-aliases: root.dialog-ssh-config-aliases; + ssh-config-hostnames: root.dialog-ssh-config-hostnames; + ssh-config-ports: root.dialog-ssh-config-ports; + ssh-config-users: root.dialog-ssh-config-users; + ssh-config-key-paths: root.dialog-ssh-config-key-paths; forwards: root.dialog-forwards; submit(draft) => { root.session-dialog-submit(draft); } cancel => { root.session-dialog-cancel(); } diff --git a/ui/session_dialog.slint b/ui/session_dialog.slint index 60f4dfb0..844ba895 100644 --- a/ui/session_dialog.slint +++ b/ui/session_dialog.slint @@ -288,6 +288,99 @@ component JumpCombo inherits VerticalLayout { } } +// Read-only dropdown for OpenSSH hosts parsed from ~/.ssh/config. +component SshConfigCombo inherits VerticalLayout { + in property label; + in property <[string]> choices; + in-out property selected-index: -1; + callback picked(int /* index */); + spacing: 4px; + + Text { + text: root.label; + color: Theme.text-secondary; + font-size: Theme.fs-sm; + } + + Rectangle { + height: 32px; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: cfg-ta.has-hover ? Theme.accent : Theme.border-subtle; + background: Theme.bg-root; + + cfg-ta := TouchArea { + mouse-cursor: pointer; + clicked => { cfg-popup.show(); } + } + + Text { + x: 10px; + width: parent.width - 44px; + height: parent.height; + text: (root.selected-index >= 0 && root.selected-index < root.choices.length) + ? root.choices[root.selected-index] : @tr("Choose a Host from ~/.ssh/config"); + color: root.selected-index >= 0 ? Theme.text-primary : Theme.text-muted; + font-size: Theme.fs-md; + vertical-alignment: center; + overflow: elide; + } + + Rectangle { + x: parent.width - 36px; + width: 28px; + height: 28px; + border-radius: Theme.radius-sm; + background: cfg-ta.has-hover ? Theme.bg-hover : transparent; + Text { + text: "v"; + color: Theme.text-secondary; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + cfg-popup := PopupWindow { + x: 0; + y: parent.height + 3px; + width: parent.width; + Rectangle { + background: Theme.bg-elevated; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: Theme.border-subtle; + VerticalLayout { + padding: 4px; + spacing: 1px; + for c[idx] in root.choices : Rectangle { + height: 28px; + border-radius: Theme.radius-sm; + background: c-ta.has-hover ? Theme.bg-hover : transparent; + c-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.selected-index = idx; + root.picked(idx); + cfg-popup.close(); + } + } + Text { + x: 10px; + width: parent.width - 20px; + text: c; + color: Theme.text-primary; + font-size: Theme.fs-md; + vertical-alignment: center; + overflow: elide; + } + } + } + } + } + } +} + // Modal dialog for creating / editing a session (SSH / Serial / Telnet). // The dialog is purely presentational — it stores values in mutable // properties and emits `submit` / `cancel` callbacks up to the main window. @@ -324,6 +417,12 @@ export component SessionDialog inherits Rectangle { in-out property draft-flow: "none"; in-out property draft-disable-shell-integration: false; in-out property draft-note; + in property <[string]> ssh-config-aliases; + in property <[string]> ssh-config-hostnames; + in property <[string]> ssh-config-ports; + in property <[string]> ssh-config-users; + in property <[string]> ssh-config-key-paths; + property ssh-config-index: -1; // Port forwards / tunnels (#56). `forwards` is the current list (fed from // Rust); the nf-* properties hold the add-form fields. @@ -347,7 +446,12 @@ export component SessionDialog inherits Rectangle { background: #000000aa; // Clear the host-required flag each time the dialog opens (#110). - changed is-open => { if (self.is-open) { self.host-missing = false; } } + changed is-open => { + if (self.is-open) { + self.host-missing = false; + self.ssh-config-index = -1; + } + } // Swallow clicks on the backdrop TouchArea { @@ -419,6 +523,32 @@ export component SessionDialog inherits Rectangle { } } + if !root.is-editing && root.draft-kind == "ssh" && root.ssh-config-aliases.length > 0 : SshConfigCombo { + label: @tr("Import from SSH config"); + choices: root.ssh-config-aliases; + selected-index <=> root.ssh-config-index; + picked(idx) => { + if (idx >= 0 + && idx < root.ssh-config-aliases.length + && idx < root.ssh-config-hostnames.length + && idx < root.ssh-config-ports.length + && idx < root.ssh-config-users.length + && idx < root.ssh-config-key-paths.length) { + root.draft-kind = "ssh"; + root.draft-name = root.ssh-config-aliases[idx]; + root.draft-host = root.ssh-config-hostnames[idx]; + root.draft-port = root.ssh-config-ports[idx]; + root.draft-user = root.ssh-config-users[idx]; + root.draft-key-path = root.ssh-config-key-paths[idx]; + root.draft-key-inline = ""; + root.draft-key-inline-mode = false; + root.draft-auth = root.ssh-config-key-paths[idx] == "" ? "password" : "key"; + root.draft-password = ""; + root.host-missing = false; + } + } + } + LabeledInput { label: @tr("Name"); placeholder: @tr("e.g. production web-01");