Files
ashell/src/terminal/mod.rs
T
TomZz 162a245a8f feat: improve theme defaults, config compatibility, and SSH key passphrase support
- default follow_system_theme to true and remove empty theme-name fallback override\n- add backward compatibility for v0.3.11 transfer state values during config load\n- preserve old config files by backing up parse failures instead of silently overwriting them\n- add optional SSH key passphrase input to the new SSH form and persist it in sessions\n- use stored passphrases when loading private keys for SSH and SFTP connections\n- make terminal size sync prefer measured bounds to reduce unused space at the bottom
2026-06-16 04:16:13 +08:00

746 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
pub mod custom_blocks;
pub mod element;
pub mod input;
use std::sync::mpsc::Sender;
use alacritty_terminal::{
event::{Event, EventListener},
grid::{Dimensions, Scroll},
index::{Column, Point, Side},
selection::{Selection, SelectionRange, SelectionType},
term::{Config, Term, TermMode, cell::Cell, point_to_viewport, viewport_to_point},
vte::ansi::{CursorShape, Processor},
};
use gpui::Keystroke;
use crate::session::config::Session;
use crate::sftp::{PreviewData, RemoteEntry};
use crate::system::SystemSnapshot;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TabKind {
Local,
Ssh,
}
#[derive(Debug)]
pub enum BackendCommand {
Input(Vec<u8>),
Resize { cols: u16, rows: u16 },
SampleMetrics,
Close,
}
#[derive(Debug, Clone)]
pub enum BackendEvent {
Output {
tab_id: String,
bytes: Vec<u8>,
},
Status {
tab_id: String,
text: String,
},
Connected {
tab_id: String,
},
SftpEntries {
tab_id: String,
path: String,
entries: Vec<RemoteEntry>,
},
SftpPreview {
tab_id: String,
preview: PreviewData,
},
SftpStatus {
tab_id: String,
text: String,
},
RemoteSystem {
tab_id: String,
snapshot: SystemSnapshot,
},
RemoteSystemUnavailable {
tab_id: String,
reason: String,
},
SftpHome {
tab_id: String,
home: String,
},
TransferProgress {
#[allow(dead_code)]
tab_id: String,
id: String,
transferred: u64,
total: Option<u64>,
state: TransferState,
},
TransferStarted {
tab_id: String,
info: TransferInfo,
},
Closed {
tab_id: String,
reason: String,
},
TerminalTitleChanged {
tab_id: String,
title: String,
},
}
#[derive(Clone)]
pub enum BackendTx {
Local(Sender<BackendCommand>),
Ssh(tokio::sync::mpsc::UnboundedSender<BackendCommand>),
}
impl BackendTx {
pub fn send(&self, command: BackendCommand) {
match self {
Self::Local(tx) => {
let _ = tx.send(command);
}
Self::Ssh(tx) => {
let _ = tx.send(command);
}
}
}
}
pub struct TerminalTab {
pub id: String,
pub title: String,
pub kind: TabKind,
pub status: String,
pub connected: bool,
pub session: Option<Session>,
processor: Processor,
term: Term<TerminalListener>,
cols: u16,
rows: u16,
pub backend: BackendTx,
pub scroll_pixel_y: f32,
}
#[derive(Clone, Copy)]
pub struct CursorState {
pub row: usize,
pub col: usize,
pub shape: CursorShape,
}
#[derive(Clone)]
pub struct RenderCell {
pub row: i32,
pub col: i32,
pub cell: Cell,
}
#[derive(Clone)]
pub struct RenderSnapshot {
pub cells: Vec<RenderCell>,
pub cursor: Option<CursorState>,
pub selection: Option<ViewportSelection>,
pub display_offset: usize,
pub history_size: usize,
pub rows: usize,
pub cols: usize,
}
#[derive(Clone, Copy)]
pub struct ViewportSelection {
pub start_row: usize,
pub start_col: usize,
pub end_row: usize,
pub end_col: usize,
pub is_block: bool,
}
#[derive(Clone, Default)]
pub struct SftpUiState {
pub current_path: String,
pub status: String,
pub entries: Vec<RemoteEntry>,
pub selected_path: Option<String>,
pub preview: Option<PreviewData>,
pub selected_entries: std::collections::HashSet<String>,
pub home_dir: String,
}
impl TerminalTab {
pub fn new_local(
id: String,
title: String,
backend: BackendTx,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Self {
Self::new(
id,
title,
TabKind::Local,
"local shell".into(),
backend,
events,
)
}
pub fn new_ssh(
id: String,
session: &Session,
backend: BackendTx,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Self {
let mut tab = Self::new(
id,
session.name.clone(),
TabKind::Ssh,
format!(
"connecting {}@{}:{}",
session.user, session.host, session.port
),
backend,
events,
);
tab.session = Some(session.clone());
tab.connected = false;
tab
}
fn new(
id: String,
title: String,
kind: TabKind,
status: String,
backend: BackendTx,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Self {
Self {
id: id.clone(),
title,
kind,
status,
connected: matches!(kind, TabKind::Local),
session: None,
processor: Processor::new(),
term: new_term(100, 30, backend.clone(), id, events),
cols: 100,
rows: 30,
backend,
scroll_pixel_y: 0.0,
}
}
pub fn feed(&mut self, bytes: &[u8]) {
self.processor.advance(&mut self.term, bytes);
}
pub fn resize(&mut self, cols: u16, rows: u16) {
let new_cols = cols.max(1);
let new_rows = rows.max(1);
if self.cols != new_cols || self.rows != new_rows {
self.cols = new_cols;
self.rows = new_rows;
tracing::info!(
"[ui] terminal resized to {}x{} (cols x rows)",
self.cols,
self.rows
);
self.term.resize(TerminalSize::new(self.cols, self.rows));
self.backend.send(BackendCommand::Resize { cols, rows });
}
}
pub fn cursor_state(&self) -> Option<CursorState> {
let content = self.term.renderable_content();
if matches!(content.cursor.shape, CursorShape::Hidden) || content.display_offset > 0 {
return None;
}
let row = content.cursor.point.line.0;
if row < 0 {
return None;
}
let row = row as usize;
if row >= self.rows as usize {
return None;
}
Some(CursorState {
row,
col: content.cursor.point.column.0,
shape: content.cursor.shape,
})
}
pub fn app_cursor_mode(&self) -> bool {
self.term.mode().contains(TermMode::APP_CURSOR)
}
pub fn render_snapshot(&self) -> RenderSnapshot {
let rows = self.rows;
let cols = self.cols;
let content = self.term.renderable_content();
let display_offset = content.display_offset as i32;
let mut cells = Vec::with_capacity((rows as usize) * (cols as usize));
for indexed in content.display_iter {
let line = indexed.point.line.0;
let row = line + display_offset;
if row < 0 {
continue;
}
if row >= rows as i32 {
continue;
}
let col = indexed.point.column.0 as i32;
if col >= cols as i32 {
continue;
}
cells.push(RenderCell {
row,
col,
cell: indexed.cell.clone(),
});
}
RenderSnapshot {
cells,
cursor: self.cursor_state(),
selection: viewport_selection_from_range(content.display_offset, &content.selection),
display_offset: content.display_offset,
history_size: self.term.grid().history_size(),
rows: self.rows as usize,
cols: self.cols as usize,
}
}
pub fn scroll_history(&mut self, delta: i32) {
if delta != 0 {
self.term.scroll_display(Scroll::Delta(delta));
}
}
pub fn scroll_up_by(&mut self, lines: usize) {
if lines != 0 {
self.term.scroll_display(Scroll::Delta(lines as i32));
}
}
pub fn scroll_down_by(&mut self, lines: usize) {
if lines != 0 {
self.term.scroll_display(Scroll::Delta(-(lines as i32)));
}
}
pub fn scroll_to_bottom(&mut self) {
self.term.scroll_display(Scroll::Bottom);
}
#[allow(dead_code)]
pub fn has_selection(&self) -> bool {
self.term
.selection_to_string()
.is_some_and(|text| !text.is_empty())
}
pub fn clear_selection(&mut self) {
self.term.selection = None;
}
pub fn selection_text(&self) -> Option<String> {
self.term
.selection_to_string()
.filter(|text| !text.is_empty())
}
pub fn begin_selection(
&mut self,
row: usize,
col: usize,
side: Side,
selection_type: SelectionType,
) {
let point = viewport_to_point(
self.term.grid().display_offset(),
Point::new(row, Column(col)),
);
self.term.selection = Some(Selection::new(selection_type, point, side));
}
pub fn update_selection(&mut self, row: usize, col: usize, side: Side) {
let point = viewport_to_point(
self.term.grid().display_offset(),
Point::new(row, Column(col)),
);
if let Some(selection) = self.term.selection.as_mut() {
selection.update(point, side);
}
}
pub fn paste_text(&mut self, text: &str) {
let paste_text = text
.replace('\x1b', "")
.replace("\r\n", "\r")
.replace('\n', "\r");
self.backend
.send(BackendCommand::Input(paste_text.into_bytes()));
}
}
fn viewport_selection_from_range(
display_offset: usize,
selection: &Option<SelectionRange>,
) -> Option<ViewportSelection> {
let SelectionRange {
start,
end,
is_block,
} = selection.as_ref().copied()?;
let start = point_to_viewport(display_offset, start)?;
let end = point_to_viewport(display_offset, end)?;
Some(ViewportSelection {
start_row: start.line,
start_col: start.column.0,
end_row: end.line,
end_col: end.column.0,
is_block,
})
}
#[derive(Clone)]
struct TerminalListener {
tab_id: String,
backend: BackendTx,
events: std::sync::mpsc::Sender<BackendEvent>,
}
impl EventListener for TerminalListener {
fn send_event(&self, event: Event) {
match event {
Event::PtyWrite(output) => self
.backend
.send(BackendCommand::Input(output.into_bytes())),
Event::TextAreaSizeRequest(format) => {
let size = alacritty_terminal::event::WindowSize {
num_lines: 30,
num_cols: 100,
cell_width: 8,
cell_height: 16,
};
self.backend
.send(BackendCommand::Input(format(size).into_bytes()));
}
Event::Title(title) => {
let _ = self.events.send(BackendEvent::TerminalTitleChanged {
tab_id: self.tab_id.clone(),
title,
});
}
_ => {}
}
}
}
fn new_term(
cols: u16,
rows: u16,
backend: BackendTx,
tab_id: String,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Term<TerminalListener> {
Term::new(
Config {
scrolling_history: 2000,
..Config::default()
},
&TerminalSize::new(cols, rows),
TerminalListener {
tab_id,
backend,
events,
},
)
}
struct TerminalSize {
cols: usize,
rows: usize,
}
impl TerminalSize {
fn new(cols: u16, rows: u16) -> Self {
Self {
cols: cols.max(1) as usize,
rows: rows.max(1) as usize,
}
}
}
impl Dimensions for TerminalSize {
fn total_lines(&self) -> usize {
self.rows
}
fn screen_lines(&self) -> usize {
self.rows
}
fn columns(&self) -> usize {
self.cols
}
}
pub fn encode_key(
keystroke: &Keystroke,
app_cursor_mode: bool,
option_as_meta: bool,
) -> Option<Vec<u8>> {
zed_like_to_esc_str(keystroke, app_cursor_mode, option_as_meta)
.map(|text| text.into_owned().into_bytes())
}
#[derive(Debug, PartialEq, Eq)]
enum TerminalModifiers {
None,
Alt,
Ctrl,
Shift,
CtrlShift,
Other,
}
impl TerminalModifiers {
fn new(ks: &Keystroke) -> Self {
match (
ks.modifiers.alt,
ks.modifiers.control,
ks.modifiers.shift,
ks.modifiers.platform,
) {
(false, false, false, false) => Self::None,
(true, false, false, false) => Self::Alt,
(false, true, false, false) => Self::Ctrl,
(false, false, true, false) => Self::Shift,
(false, true, true, false) => Self::CtrlShift,
_ => Self::Other,
}
}
fn any(&self) -> bool {
!matches!(self, Self::None)
}
}
fn zed_like_to_esc_str(
keystroke: &Keystroke,
app_cursor_mode: bool,
option_as_meta: bool,
) -> Option<std::borrow::Cow<'static, str>> {
let modifiers = TerminalModifiers::new(keystroke);
let key = keystroke.key.to_ascii_lowercase();
let manual_esc_str = match (key.as_str(), &modifiers) {
("tab", TerminalModifiers::None) => Some("\x09"),
("tab", TerminalModifiers::Shift) => Some("\x1b[Z"),
("escape", TerminalModifiers::None) => Some("\x1b"),
("enter", TerminalModifiers::None) => Some("\x0d"),
("enter", TerminalModifiers::Shift) => Some("\x0a"),
("enter", TerminalModifiers::Alt) => Some("\x1b\x0d"),
("backspace", TerminalModifiers::None) => Some("\x7f"),
("backspace", TerminalModifiers::Ctrl) => Some("\x08"),
("backspace", TerminalModifiers::Alt) => Some("\x1b\x7f"),
("backspace", TerminalModifiers::Shift) => Some("\x7f"),
("space", TerminalModifiers::Ctrl) => Some("\x00"),
("home", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOH"),
("home", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[H"),
("end", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOF"),
("end", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[F"),
("up", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOA"),
("up", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[A"),
("down", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOB"),
("down", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[B"),
("right", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOC"),
("right", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[C"),
("left", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOD"),
("left", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[D"),
("insert", TerminalModifiers::None) => Some("\x1b[2~"),
("delete", TerminalModifiers::None) => Some("\x1b[3~"),
("pageup", TerminalModifiers::None) => Some("\x1b[5~"),
("pagedown", TerminalModifiers::None) => Some("\x1b[6~"),
("a", TerminalModifiers::Ctrl) | ("A", TerminalModifiers::CtrlShift) => Some("\x01"),
("b", TerminalModifiers::Ctrl) | ("B", TerminalModifiers::CtrlShift) => Some("\x02"),
("c", TerminalModifiers::Ctrl) | ("C", TerminalModifiers::CtrlShift) => Some("\x03"),
("d", TerminalModifiers::Ctrl) | ("D", TerminalModifiers::CtrlShift) => Some("\x04"),
("e", TerminalModifiers::Ctrl) | ("E", TerminalModifiers::CtrlShift) => Some("\x05"),
("f", TerminalModifiers::Ctrl) | ("F", TerminalModifiers::CtrlShift) => Some("\x06"),
("g", TerminalModifiers::Ctrl) | ("G", TerminalModifiers::CtrlShift) => Some("\x07"),
("h", TerminalModifiers::Ctrl) | ("H", TerminalModifiers::CtrlShift) => Some("\x08"),
("i", TerminalModifiers::Ctrl) | ("I", TerminalModifiers::CtrlShift) => Some("\x09"),
("j", TerminalModifiers::Ctrl) | ("J", TerminalModifiers::CtrlShift) => Some("\x0a"),
("k", TerminalModifiers::Ctrl) | ("K", TerminalModifiers::CtrlShift) => Some("\x0b"),
("l", TerminalModifiers::Ctrl) | ("L", TerminalModifiers::CtrlShift) => Some("\x0c"),
("m", TerminalModifiers::Ctrl) | ("M", TerminalModifiers::CtrlShift) => Some("\x0d"),
("n", TerminalModifiers::Ctrl) | ("N", TerminalModifiers::CtrlShift) => Some("\x0e"),
("o", TerminalModifiers::Ctrl) | ("O", TerminalModifiers::CtrlShift) => Some("\x0f"),
("p", TerminalModifiers::Ctrl) | ("P", TerminalModifiers::CtrlShift) => Some("\x10"),
("q", TerminalModifiers::Ctrl) | ("Q", TerminalModifiers::CtrlShift) => Some("\x11"),
("r", TerminalModifiers::Ctrl) | ("R", TerminalModifiers::CtrlShift) => Some("\x12"),
("s", TerminalModifiers::Ctrl) | ("S", TerminalModifiers::CtrlShift) => Some("\x13"),
("t", TerminalModifiers::Ctrl) | ("T", TerminalModifiers::CtrlShift) => Some("\x14"),
("u", TerminalModifiers::Ctrl) | ("U", TerminalModifiers::CtrlShift) => Some("\x15"),
("v", TerminalModifiers::Ctrl) | ("V", TerminalModifiers::CtrlShift) => Some("\x16"),
("w", TerminalModifiers::Ctrl) | ("W", TerminalModifiers::CtrlShift) => Some("\x17"),
("x", TerminalModifiers::Ctrl) | ("X", TerminalModifiers::CtrlShift) => Some("\x18"),
("y", TerminalModifiers::Ctrl) | ("Y", TerminalModifiers::CtrlShift) => Some("\x19"),
("z", TerminalModifiers::Ctrl) | ("Z", TerminalModifiers::CtrlShift) => Some("\x1a"),
("@", TerminalModifiers::Ctrl) => Some("\x00"),
("[", TerminalModifiers::Ctrl) => Some("\x1b"),
("\\", TerminalModifiers::Ctrl) => Some("\x1c"),
("]", TerminalModifiers::Ctrl) => Some("\x1d"),
("^", TerminalModifiers::Ctrl) => Some("\x1e"),
("_", TerminalModifiers::Ctrl) => Some("\x1f"),
("?", TerminalModifiers::Ctrl) => Some("\x7f"),
_ => None,
};
if let Some(esc) = manual_esc_str {
return Some(esc.into());
}
if modifiers.any() {
let modifier_code = modifier_code(keystroke);
let modified = match key.as_str() {
"up" => Some(format!("\x1b[1;{}A", modifier_code)),
"down" => Some(format!("\x1b[1;{}B", modifier_code)),
"right" => Some(format!("\x1b[1;{}C", modifier_code)),
"left" => Some(format!("\x1b[1;{}D", modifier_code)),
"insert" => Some(format!("\x1b[2;{}~", modifier_code)),
"pageup" => Some(format!("\x1b[5;{}~", modifier_code)),
"pagedown" => Some(format!("\x1b[6;{}~", modifier_code)),
"end" => Some(format!("\x1b[1;{}F", modifier_code)),
"home" => Some(format!("\x1b[1;{}H", modifier_code)),
_ => None,
};
if let Some(esc) = modified {
return Some(esc.into());
}
}
if !cfg!(target_os = "macos") || option_as_meta {
let is_alt_lowercase_ascii =
modifiers == TerminalModifiers::Alt && keystroke.key.is_ascii();
let is_alt_uppercase_ascii =
keystroke.modifiers.alt && keystroke.modifiers.shift && keystroke.key.is_ascii();
if is_alt_lowercase_ascii || is_alt_uppercase_ascii {
let key = if is_alt_uppercase_ascii {
keystroke.key.to_ascii_uppercase()
} else {
keystroke.key.clone()
};
return Some(format!("\x1b{}", key).into());
}
}
if let Some(text) = &keystroke.key_char {
return Some(text.clone().into());
}
if keystroke.key.len() == 1 {
return Some(keystroke.key.clone().into());
}
None
}
fn modifier_code(keystroke: &Keystroke) -> u32 {
let mut modifier_code = 0;
if keystroke.modifiers.shift {
modifier_code |= 1;
}
if keystroke.modifiers.alt {
modifier_code |= 1 << 1;
}
if keystroke.modifiers.control {
modifier_code |= 1 << 2;
}
modifier_code + 1
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum TransferType {
Upload,
Download,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub enum TransferState {
Running,
Paused,
Completed,
Failed(String),
Interrupted(String), // 中断传输:包含原因(例如 "User cancelled", "Network timeout")
Zombie(String), // 程序重启后残留的 Running/Paused 任务
// 兼容 v0.3.11 -> v0.4.x:旧配置里曾保存过 `Cancelled`,
// 新版本改成了带原因的状态,因此要手动接住旧枚举值。
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
enum TransferStateCompat {
Running,
Paused,
Completed,
Failed(String),
Interrupted(String),
Zombie(String),
Cancelled,
}
impl From<TransferStateCompat> for TransferState {
fn from(value: TransferStateCompat) -> Self {
match value {
TransferStateCompat::Running => Self::Running,
TransferStateCompat::Paused => Self::Paused,
TransferStateCompat::Completed => Self::Completed,
TransferStateCompat::Failed(reason) => Self::Failed(reason),
TransferStateCompat::Interrupted(reason) => Self::Interrupted(reason),
TransferStateCompat::Zombie(reason) => Self::Zombie(reason),
TransferStateCompat::Cancelled => Self::Interrupted("Cancelled".to_string()),
}
}
}
impl<'de> serde::Deserialize<'de> for TransferState {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
TransferStateCompat::deserialize(deserializer).map(Into::into)
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TransferInfo {
pub id: String,
pub name: String,
pub source: String,
pub target: String,
pub kind: TransferType,
pub total_bytes: Option<u64>,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Transfer {
pub tab_id: String,
pub tab_title: String,
pub info: TransferInfo,
pub transferred: u64,
pub total: Option<u64>,
pub state: TransferState,
}