Files
ashell/src/terminal/mod.rs
T

794 lines
24 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, Line, 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, Clone, Copy, PartialEq, Eq)]
pub enum PromptType {
KeyboardInteractive,
Passphrase,
}
#[derive(Debug, Clone)]
pub struct PromptInfo {
pub prompt: String,
pub echo: bool,
}
#[derive(Debug)]
pub enum BackendCommand {
Input(Vec<u8>),
Resize { cols: u16, rows: u16 },
SampleMetrics,
Close,
PromptResponse(Vec<String>),
}
#[derive(Debug, Clone)]
pub enum BackendEvent {
PromptRequest {
tab_id: String,
prompt_type: PromptType,
instruction: String,
prompts: Vec<PromptInfo>,
},
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,
},
SyncFinished(crate::sync::SyncResult),
}
#[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,
}
}
/// Return `(grid_line_base, rows_data)` for the **entire** terminal buffer
/// including scrollback history. `grid_line_base` is the grid line index of
/// the first row (typically `-history_size`). Each entry in `rows_data` is
/// a sorted `Vec<(col, char)>` for that row.
pub fn full_grid_rows(&self) -> (i32, Vec<Vec<(i32, char)>>) {
let grid = self.term.grid();
let history = grid.history_size() as i32;
let screen = grid.screen_lines() as i32;
let total = history + screen;
let cols = self.cols as i32;
let start_line = -history;
let mut rows_data: Vec<Vec<(i32, char)>> = Vec::with_capacity(total as usize);
for line_idx in start_line..(start_line + total) {
let line = Line(line_idx);
let mut cells: Vec<(i32, char)> = Vec::new();
for col_idx in 0..cols {
let point = Point::new(line, Column(col_idx as usize));
let c = grid[point].c;
if c != ' ' && c != '\0' {
cells.push((col_idx, c));
}
}
rows_data.push(cells);
}
(start_line, rows_data)
}
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,
}