Initial commit

This commit is contained in:
TomZz
2026-06-08 15:17:18 +08:00
commit 644ec6e2b1
22 changed files with 18270 additions and 0 deletions
+193
View File
@@ -0,0 +1,193 @@
use std::{fs, path::PathBuf};
use anyhow::{Context, Result};
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AuthMethod {
Password,
Key,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: String,
pub name: String,
pub host: String,
pub port: u16,
pub user: String,
pub auth: AuthMethod,
#[serde(default)]
pub password: String,
#[serde(default)]
pub private_key_path: String,
#[serde(default)]
pub private_key_inline: String,
#[serde(default)]
pub last_used: Option<String>,
}
impl Session {
pub fn password(host: String, port: u16, user: String, password: String) -> Self {
let name = format!("{user}@{host}");
Self {
id: Uuid::new_v4().to_string(),
name,
host,
port,
user,
auth: AuthMethod::Password,
password,
private_key_path: String::new(),
private_key_inline: String::new(),
last_used: None,
}
}
pub fn key(
host: String,
port: u16,
user: String,
private_key_path: String,
private_key_inline: String,
) -> Self {
let name = format!("{user}@{host}");
Self {
id: Uuid::new_v4().to_string(),
name,
host,
port,
user,
auth: AuthMethod::Key,
password: String::new(),
private_key_path,
private_key_inline,
last_used: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConfigFile {
#[serde(default)]
pub follow_system_theme: bool,
#[serde(default)]
pub light_theme_name: String,
#[serde(default)]
pub dark_theme_name: String,
#[serde(default = "default_terminal_font_size")]
pub terminal_font_size: f32,
#[serde(default)]
pub sessions: Vec<Session>,
}
fn default_terminal_font_size() -> f32 {
13.0
}
pub struct ConfigStore {
path: PathBuf,
cache: ConfigFile,
}
impl ConfigStore {
pub fn load() -> Result<Self> {
let path = Self::config_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create config dir {}", parent.display()))?;
}
let cache = if path.exists() {
let raw = fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
serde_json::from_str::<ConfigFile>(&raw).unwrap_or_default()
} else {
ConfigFile::default()
};
Ok(Self { path, cache })
}
pub fn in_memory() -> Self {
Self {
path: PathBuf::new(),
cache: ConfigFile::default(),
}
}
fn config_path() -> Result<PathBuf> {
let dirs = BaseDirs::new().context("could not determine user home directory")?;
Ok(dirs
.home_dir()
.join(".config")
.join("ashell")
.join("sessions.json"))
}
pub fn sessions(&self) -> &[Session] {
&self.cache.sessions
}
pub fn follow_system_theme(&self) -> bool {
self.cache.follow_system_theme
}
pub fn light_theme_name(&self) -> &str {
&self.cache.light_theme_name
}
pub fn dark_theme_name(&self) -> &str {
&self.cache.dark_theme_name
}
pub fn terminal_font_size(&self) -> f32 {
if self.cache.terminal_font_size <= 0.0 {
default_terminal_font_size()
} else {
self.cache.terminal_font_size
}
}
pub fn set_theme_preferences(
&mut self,
follow_system_theme: bool,
light_theme_name: impl Into<String>,
dark_theme_name: impl Into<String>,
) {
self.cache.follow_system_theme = follow_system_theme;
self.cache.light_theme_name = light_theme_name.into();
self.cache.dark_theme_name = dark_theme_name.into();
}
pub fn set_terminal_font_size(&mut self, terminal_font_size: f32) {
self.cache.terminal_font_size = terminal_font_size.max(10.0);
}
pub fn get(&self, id: &str) -> Option<&Session> {
self.cache.sessions.iter().find(|s| s.id == id)
}
pub fn upsert(&mut self, session: Session) {
if let Some(existing) = self.cache.sessions.iter_mut().find(|s| s.id == session.id) {
*existing = session;
} else {
self.cache.sessions.push(session);
}
}
pub fn remove(&mut self, id: &str) {
self.cache.sessions.retain(|s| s.id != id);
}
pub fn save(&self) -> Result<()> {
if self.path.as_os_str().is_empty() {
return Ok(());
}
let raw = serde_json::to_string_pretty(&self.cache)?;
fs::write(&self.path, raw).with_context(|| format!("failed to write {}", self.path.display()))
}
}
+126
View File
@@ -0,0 +1,126 @@
use std::{
io::{Read, Write},
sync::mpsc::{self, Sender},
thread,
};
use anyhow::{Context, Result};
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use crate::terminal::{BackendCommand, BackendEvent, BackendTx};
pub fn spawn_local_terminal(
tab_id: String,
cols: u16,
rows: u16,
events: Sender<BackendEvent>,
) -> Result<BackendTx> {
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.context("open local PTY")?;
let shell = std::env::var("SHELL").unwrap_or_else(|_| {
if cfg!(windows) {
"powershell.exe".into()
} else {
"/bin/zsh".into()
}
});
let mut cmd = CommandBuilder::new(&shell);
cmd.env("TERM", std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".into()));
cmd.env(
"COLORTERM",
std::env::var("COLORTERM").unwrap_or_else(|_| "truecolor".into()),
);
cmd.env("TERM_PROGRAM", "ashell");
if let Ok(path) = std::env::var("PATH") {
cmd.env("PATH", path);
}
if let Ok(lang) = std::env::var("LANG") {
cmd.env("LANG", lang);
} else {
cmd.env("LANG", "en_US.UTF-8");
}
if let Ok(home) = std::env::var("HOME") {
cmd.env("HOME", home);
}
cmd.env("SHELL", shell);
let mut child = pair.slave.spawn_command(cmd).context("spawn local shell")?;
drop(pair.slave);
let master = pair.master;
let mut reader = master.try_clone_reader().context("clone PTY reader")?;
let mut writer = master.take_writer().context("take PTY writer")?;
let (cmd_tx, cmd_rx) = mpsc::channel::<BackendCommand>();
let read_tab = tab_id.clone();
let read_events = events.clone();
thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let _ = read_events.send(BackendEvent::Output {
tab_id: read_tab.clone(),
bytes: buf[..n].to_vec(),
});
}
Err(err) => {
let _ = read_events.send(BackendEvent::Closed {
tab_id: read_tab.clone(),
reason: format!("local read error: {err}"),
});
return;
}
}
}
let _ = read_events.send(BackendEvent::Closed {
tab_id: read_tab,
reason: "local shell closed".into(),
});
});
let write_tab = tab_id.clone();
let write_events = events.clone();
thread::spawn(move || {
while let Ok(command) = cmd_rx.recv() {
match command {
BackendCommand::Input(bytes) => {
if let Err(err) = writer.write_all(&bytes) {
let _ = write_events.send(BackendEvent::Closed {
tab_id: write_tab.clone(),
reason: format!("local write error: {err}"),
});
break;
}
let _ = writer.flush();
}
BackendCommand::Resize { cols, rows } => {
let _ = master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
}
BackendCommand::Close => break,
}
}
let _ = child.kill();
});
let _ = events.send(BackendEvent::Status {
tab_id,
text: "local shell ready".into(),
});
Ok(BackendTx::Local(cmd_tx))
}
+3278
View File
File diff suppressed because it is too large Load Diff
+909
View File
@@ -0,0 +1,909 @@
use std::{
fs,
path::{Path, PathBuf},
sync::Arc,
};
use anyhow::{anyhow, Context, Result};
use async_trait::async_trait;
use chrono::{DateTime, TimeZone, Utc};
use directories::BaseDirs;
use flate2::read::GzDecoder;
use russh::{
client::{self, Handler},
keys::{decode_secret_key, key::PrivateKeyWithHashAlg, load_secret_key, HashAlg, PrivateKey},
Disconnect,
};
use russh_sftp::client::SftpSession;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
sync::mpsc::{self, UnboundedReceiver, UnboundedSender},
task::JoinHandle,
};
use uuid::Uuid;
use walkdir::WalkDir;
use zip::read::ZipArchive;
use crate::{
config::{AuthMethod, Session},
terminal::BackendEvent,
};
#[derive(Debug, Clone)]
pub struct RemoteEntry {
pub name: String,
pub full_path: String,
pub is_dir: bool,
pub size: u64,
pub modified: u32,
}
#[derive(Debug, Clone)]
pub struct PreviewData {
pub path: String,
pub title: String,
pub body: String,
pub is_binary: bool,
}
#[derive(Debug)]
enum SftpCommand {
ListDir(String),
Preview(String),
Download { remote: String, local_dir: String },
UploadPaths { locals: Vec<String>, remote_dir: String },
Close,
}
pub struct SftpHandle {
commands: UnboundedSender<SftpCommand>,
#[allow(dead_code)]
join: Option<JoinHandle<()>>,
}
impl Clone for SftpHandle {
fn clone(&self) -> Self {
Self {
commands: self.commands.clone(),
join: None,
}
}
}
impl SftpHandle {
pub fn list_dir(&self, path: String) {
let _ = self.commands.send(SftpCommand::ListDir(path));
}
pub fn preview(&self, path: String) {
let _ = self.commands.send(SftpCommand::Preview(path));
}
pub fn download(&self, remote: String, local_dir: String) {
let _ = self.commands.send(SftpCommand::Download { remote, local_dir });
}
pub fn upload_paths(&self, locals: Vec<String>, remote_dir: String) {
let _ = self.commands.send(SftpCommand::UploadPaths { locals, remote_dir });
}
pub fn close(&self) {
let _ = self.commands.send(SftpCommand::Close);
}
}
pub fn spawn_sftp(
runtime: &tokio::runtime::Handle,
tab_id: String,
session: Session,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> SftpHandle {
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let join = runtime.spawn(async move {
if let Err(err) = run_sftp(tab_id.clone(), session, cmd_rx, events.clone()).await {
let _ = events.send(BackendEvent::SftpStatus {
tab_id,
text: format!("sftp error: {err:#}"),
});
}
});
SftpHandle {
commands: cmd_tx,
join: Some(join),
}
}
async fn run_sftp(
tab_id: String,
session: Session,
mut commands: UnboundedReceiver<SftpCommand>,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Result<()> {
let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(),
text: "sftp connecting...".into(),
});
let handle = connect_and_authenticate(&session).await?;
let channel = handle
.channel_open_session()
.await
.context("open sftp channel")?;
channel
.request_subsystem(true, "sftp")
.await
.context("request sftp subsystem")?;
let sftp = SftpSession::new(channel.into_stream())
.await
.context("sftp handshake")?;
let home = sftp.canonicalize(".").await.unwrap_or_else(|_| "/".to_string());
emit_entries(&events, &tab_id, &sftp, &home).await?;
while let Some(command) = commands.recv().await {
match command {
SftpCommand::Close => break,
SftpCommand::ListDir(path) => {
if let Err(err) = emit_entries(&events, &tab_id, &sftp, &path).await {
let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(),
text: format!("list failed: {err:#}"),
});
}
}
SftpCommand::Preview(path) => match preview_impl(&sftp, &path).await {
Ok(preview) => {
let _ = events.send(BackendEvent::SftpPreview {
tab_id: tab_id.clone(),
preview,
});
}
Err(err) => {
let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(),
text: format!("preview failed: {err:#}"),
});
}
},
SftpCommand::Download { remote, local_dir } => {
let base = base_name(&remote);
let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(),
text: format!("downloading {base}..."),
});
match download_path_impl(&handle, &sftp, &remote, Path::new(&local_dir)).await {
Ok(summary) => {
let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(),
text: summary,
});
}
Err(err) => {
let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(),
text: format!("download failed: {err:#}"),
});
}
}
}
SftpCommand::UploadPaths { locals, remote_dir } => {
let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(),
text: "uploading...".into(),
});
match upload_paths_impl(&sftp, &locals, &remote_dir).await {
Ok(summary) => {
let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(),
text: summary,
});
let _ = emit_entries(&events, &tab_id, &sftp, &remote_dir).await;
}
Err(err) => {
let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(),
text: format!("upload failed: {err:#}"),
});
}
}
}
}
}
let _ = handle.disconnect(Disconnect::ByApplication, "bye", "").await;
Ok(())
}
async fn emit_entries(
events: &std::sync::mpsc::Sender<BackendEvent>,
tab_id: &str,
sftp: &SftpSession,
path: &str,
) -> Result<()> {
let entries = list_dir_impl(sftp, path).await?;
let _ = events.send(BackendEvent::SftpEntries {
tab_id: tab_id.to_string(),
path: path.to_string(),
entries,
});
let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.to_string(),
text: path.to_string(),
});
Ok(())
}
async fn connect_and_authenticate(session: &Session) -> Result<russh::client::Handle<SftpClientHandler>> {
let config = Arc::new(client::Config {
inactivity_timeout: Some(std::time::Duration::from_secs(600)),
..Default::default()
});
let addr = format!("{}:{}", session.host, session.port);
let mut handle = client::connect(config, addr.as_str(), SftpClientHandler)
.await
.with_context(|| format!("connect {addr} failed"))?;
let authed = match session.auth {
AuthMethod::Password => handle
.authenticate_password(&session.user, &session.password)
.await
.context("password authentication failed")?,
AuthMethod::Key => {
let keypair = load_session_private_key(session)?;
let key = private_key_with_alg(keypair).context("invalid private key")?;
handle
.authenticate_publickey(&session.user, key)
.await
.context("public key authentication failed")?
}
};
if !authed {
let _ = handle
.disconnect(Disconnect::ByApplication, "auth failed", "")
.await;
return Err(anyhow!(
"authentication failed: server rejected {} authentication for {}@{}:{}",
match session.auth {
AuthMethod::Password => "password",
AuthMethod::Key => "public key",
},
session.user,
session.host,
session.port
));
}
Ok(handle)
}
fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
let inline_key = normalize_inline_private_key(&session.private_key_inline);
let key_path = expand_key_path(session.private_key_path.trim());
let has_inline = !inline_key.is_empty();
let has_path = key_path.is_some();
if !has_inline && !has_path {
return Err(anyhow!("private key content or path is required"));
}
let mut errors = Vec::new();
if has_inline {
match decode_secret_key(&inline_key, None) {
Ok(key) => return Ok(key),
Err(err) => errors.push(format!("decode private key content: {err}")),
}
}
if let Some(path) = key_path {
match load_secret_key(path.as_path(), None) {
Ok(key) => return Ok(key),
Err(err) => errors.push(format!("load key {}: {err}", path.display())),
}
}
Err(anyhow!(errors.join("; ")))
}
fn private_key_with_alg(keypair: PrivateKey) -> Result<PrivateKeyWithHashAlg> {
let hash_alg = if keypair.algorithm().is_rsa() {
Some(HashAlg::Sha512)
} else {
None
};
Ok(
PrivateKeyWithHashAlg::new(Arc::new(keypair.clone()), hash_alg)
.or_else(|_| PrivateKeyWithHashAlg::new(Arc::new(keypair), Some(HashAlg::Sha256)))?,
)
}
fn normalize_inline_private_key(value: &str) -> String {
let mut normalized = value
.trim()
.replace("\\r\\n", "\n")
.replace("\\n", "\n")
.replace("\r\n", "\n");
if !normalized.ends_with('\n') {
normalized.push('\n');
}
normalized
}
fn expand_key_path(value: &str) -> Option<PathBuf> {
if value.is_empty() {
return None;
}
if value == "~" {
return BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf());
}
if let Some(rest) = value.strip_prefix("~/") {
return BaseDirs::new().map(|dirs| dirs.home_dir().join(rest));
}
Some(Path::new(value).to_path_buf())
}
fn base_name(path: &str) -> String {
let sep = |c: char| c == '/' || c == '\\';
path.trim_end_matches(sep)
.rsplit(sep)
.next()
.unwrap_or(path)
.to_string()
}
fn join_remote(parent: &str, child: &str) -> String {
if parent == "/" {
format!("/{child}")
} else {
format!("{}/{}", parent.trim_end_matches('/'), child)
}
}
fn strip_archive_suffix(name: &str) -> &str {
for suffix in [".tar.gz", ".tgz", ".zip", ".tar"] {
if let Some(stripped) = name.strip_suffix(suffix) {
return stripped;
}
}
name
}
fn format_bytes(bytes: u64) -> String {
if bytes < 1024 {
format!("{bytes} B")
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else if bytes < 1024 * 1024 * 1024 {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
} else {
format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
}
}
pub fn format_mtime(ts: u32) -> String {
let dt: DateTime<Utc> = Utc
.timestamp_opt(ts as i64, 0)
.single()
.unwrap_or_else(Utc::now);
dt.format("%Y-%m-%d %H:%M").to_string()
}
async fn list_dir_impl(sftp: &SftpSession, path: &str) -> Result<Vec<RemoteEntry>> {
let raw = sftp
.read_dir(path)
.await
.with_context(|| format!("read_dir {path} failed"))?;
let mut entries = raw
.into_iter()
.filter(|entry| {
let name = entry.file_name();
name != "." && name != ".."
})
.map(|entry| {
let name = entry.file_name().to_string();
let full_path = join_remote(path, &name);
let meta = entry.metadata();
let permissions = meta.permissions.unwrap_or(0);
let is_dir = (permissions & 0o170_000) == 0o040_000;
let size = meta.size.unwrap_or(0);
let modified = meta.mtime.unwrap_or(0);
RemoteEntry {
name,
full_path,
is_dir,
size,
modified,
}
})
.collect::<Vec<_>>();
entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
});
Ok(entries)
}
async fn preview_impl(sftp: &SftpSession, path: &str) -> Result<PreviewData> {
let metadata = sftp.metadata(path).await.with_context(|| format!("metadata {path}"))?;
let is_dir = metadata
.permissions
.map(|mode| (mode & 0o170_000) == 0o040_000)
.unwrap_or(false);
if is_dir {
let entries = list_dir_impl(sftp, path).await?;
let mut lines = vec![format!("Directory: {path}"), String::new()];
for entry in entries.into_iter().take(200) {
let kind = if entry.is_dir { "dir " } else { "file" };
lines.push(format!("{kind} {}", entry.name));
}
return Ok(PreviewData {
path: path.to_string(),
title: base_name(path),
body: lines.join("\n"),
is_binary: false,
});
}
let mut remote_file = sftp.open(path).await.with_context(|| format!("open remote {path}"))?;
let mut buffer = vec![0u8; 128 * 1024];
let read = remote_file.read(&mut buffer).await.context("read preview bytes")?;
buffer.truncate(read);
let nul_ratio = if buffer.is_empty() {
0.0
} else {
buffer.iter().filter(|byte| **byte == 0).count() as f32 / buffer.len() as f32
};
let is_binary = nul_ratio > 0.01;
let body = if is_binary {
format!(
"Binary file\npath: {path}\nsize: {}\npreview: unavailable in-app",
format_bytes(metadata.size.unwrap_or(0)),
)
} else {
String::from_utf8_lossy(&buffer).into_owned()
};
Ok(PreviewData {
path: path.to_string(),
title: base_name(path),
body,
is_binary,
})
}
async fn download_path_impl(
handle: &russh::client::Handle<SftpClientHandler>,
sftp: &SftpSession,
remote: &str,
local_dir: &Path,
) -> Result<String> {
tokio::fs::create_dir_all(local_dir)
.await
.with_context(|| format!("create {}", local_dir.display()))?;
let metadata = sftp.metadata(remote).await.with_context(|| format!("metadata {remote}"))?;
let is_dir = metadata
.permissions
.map(|mode| (mode & 0o170_000) == 0o040_000)
.unwrap_or(false);
if is_dir {
let local_archive = local_dir.join(format!(
".ashell-{}-{}.tar.gz",
base_name(remote),
Uuid::new_v4()
));
let extracted_to = download_remote_directory_archive(handle, sftp, remote, &local_archive).await?;
return Ok(format!("downloaded folder to {}", extracted_to.display()));
}
let local_path = local_dir.join(base_name(remote));
download_file_impl(sftp, remote, &local_path).await?;
Ok(format!("downloaded file to {}", local_path.display()))
}
async fn download_dir_recursive(sftp: &SftpSession, remote_dir: &str, local_dir: &Path) -> Result<()> {
tokio::fs::create_dir_all(local_dir)
.await
.with_context(|| format!("create {}", local_dir.display()))?;
let entries = list_dir_impl(sftp, remote_dir).await?;
for entry in entries {
let local_path = local_dir.join(&entry.name);
if entry.is_dir {
Box::pin(download_dir_recursive(sftp, &entry.full_path, &local_path)).await?;
} else {
download_file_impl(sftp, &entry.full_path, &local_path).await?;
let _ = maybe_extract_archive(&local_path).await;
}
}
Ok(())
}
async fn download_remote_directory_archive(
handle: &russh::client::Handle<SftpClientHandler>,
sftp: &SftpSession,
remote_dir: &str,
local_archive: &Path,
) -> Result<PathBuf> {
let remote_archive = format!("/tmp/ashell-{}-{}.tar.gz", base_name(remote_dir), Uuid::new_v4());
create_remote_archive(handle, remote_dir, &remote_archive).await?;
let local_extract_root = local_archive
.parent()
.unwrap_or_else(|| Path::new("."))
.join(base_name(remote_dir));
let archive_download = async {
download_file_impl(sftp, &remote_archive, local_archive).await?;
extract_archive_to(local_archive, local_archive.parent().unwrap_or_else(|| Path::new("."))).await?;
tokio::fs::remove_file(local_archive)
.await
.with_context(|| format!("remove {}", local_archive.display()))?;
Ok::<PathBuf, anyhow::Error>(local_extract_root)
}
.await;
let cleanup_result = remove_remote_path(handle, &remote_archive).await;
let extracted_to = archive_download?;
if let Err(err) = cleanup_result {
tracing::warn!("failed to clean remote archive {remote_archive}: {err:#}");
}
Ok(extracted_to)
}
async fn download_file_impl(sftp: &SftpSession, remote: &str, local: &Path) -> Result<()> {
let mut remote_file = sftp.open(remote).await.with_context(|| format!("open remote {remote}"))?;
let mut local_file = tokio::fs::File::create(local)
.await
.with_context(|| format!("create local {}", local.display()))?;
let mut buffer = vec![0u8; 64 * 1024];
loop {
let read = remote_file.read(&mut buffer).await.context("read remote file")?;
if read == 0 {
break;
}
local_file
.write_all(&buffer[..read])
.await
.with_context(|| format!("write {}", local.display()))?;
}
local_file.flush().await.context("flush local file")?;
Ok(())
}
async fn upload_paths_impl(sftp: &SftpSession, locals: &[String], remote_dir: &str) -> Result<String> {
create_remote_dir_all(sftp, remote_dir).await?;
let mut file_count = 0usize;
let mut folder_count = 0usize;
for local in locals {
let path = PathBuf::from(local);
if path.is_dir() {
upload_directory_impl(sftp, &path, remote_dir).await?;
folder_count += 1;
} else {
upload_file_to_dir_impl(sftp, &path, remote_dir).await?;
file_count += 1;
}
}
let summary = match (file_count, folder_count) {
(1, 0) => "uploaded file".to_string(),
(0, 1) => "uploaded folder".to_string(),
(files, 0) => format!("uploaded {files} files"),
(0, folders) => format!("uploaded {folders} folders"),
(files, folders) => format!("uploaded {files} files and {folders} folders"),
};
Ok(summary)
}
async fn upload_directory_impl(sftp: &SftpSession, local_dir: &Path, remote_parent: &str) -> Result<()> {
let root_name = local_dir
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| anyhow!("invalid folder name: {}", local_dir.display()))?;
let remote_root = join_remote(remote_parent, root_name);
create_remote_dir_all(sftp, &remote_root).await?;
for entry in WalkDir::new(local_dir) {
let entry = entry?;
let path = entry.path();
if path == local_dir {
continue;
}
let relative = path.strip_prefix(local_dir)?;
let remote_path = if relative.as_os_str().is_empty() {
remote_root.clone()
} else {
let rel = relative
.components()
.map(|component| component.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>()
.join("/");
join_remote(&remote_root, &rel)
};
if path.is_dir() {
create_remote_dir_all(sftp, &remote_path).await?;
} else {
if let Some(parent) = Path::new(&remote_path).parent() {
let parent_remote = parent.to_string_lossy().replace('\\', "/");
create_remote_dir_all(sftp, &parent_remote).await?;
}
upload_file_impl(sftp, path, &remote_path).await?;
}
}
Ok(())
}
async fn upload_file_to_dir_impl(sftp: &SftpSession, local_file: &Path, remote_dir: &str) -> Result<()> {
let file_name = local_file
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| anyhow!("invalid file name: {}", local_file.display()))?;
let remote_path = join_remote(remote_dir, file_name);
upload_file_impl(sftp, local_file, &remote_path).await
}
async fn upload_file_impl(sftp: &SftpSession, local_file: &Path, remote_path: &str) -> Result<()> {
let mut local = tokio::fs::File::open(local_file)
.await
.with_context(|| format!("open local {}", local_file.display()))?;
let mut remote = sftp
.create(remote_path)
.await
.with_context(|| format!("create remote {remote_path}"))?;
let mut buffer = vec![0u8; 64 * 1024];
loop {
let read = local.read(&mut buffer).await.context("read local file")?;
if read == 0 {
break;
}
remote
.write_all(&buffer[..read])
.await
.with_context(|| format!("write remote {remote_path}"))?;
}
remote.flush().await.context("flush remote file")?;
Ok(())
}
async fn create_remote_dir_all(sftp: &SftpSession, remote_dir: &str) -> Result<()> {
if remote_dir.is_empty() || remote_dir == "/" {
return Ok(());
}
let mut current = String::from("/");
for segment in remote_dir.split('/').filter(|segment| !segment.is_empty()) {
current = join_remote(&current, segment);
let _ = sftp.create_dir(&current).await;
}
Ok(())
}
async fn create_remote_archive(
handle: &russh::client::Handle<SftpClientHandler>,
remote_dir: &str,
remote_archive: &str,
) -> Result<()> {
let remote_dir = remote_dir.trim_end_matches('/');
let parent = remote_parent(remote_dir);
let name = base_name(remote_dir);
let command = format!(
"tar -C {} -czf {} {}",
shell_quote(&parent),
shell_quote(remote_archive),
shell_quote(&name),
);
exec_remote_command(handle, &command)
.await
.with_context(|| format!("archive remote directory {remote_dir}"))?;
Ok(())
}
async fn remove_remote_path(
handle: &russh::client::Handle<SftpClientHandler>,
remote_path: &str,
) -> Result<()> {
let command = format!("rm -f {}", shell_quote(remote_path));
exec_remote_command(handle, &command)
.await
.with_context(|| format!("remove remote temporary file {remote_path}"))?;
Ok(())
}
async fn exec_remote_command(
handle: &russh::client::Handle<SftpClientHandler>,
command: &str,
) -> Result<()> {
let mut channel = handle
.channel_open_session()
.await
.context("open remote exec session")?;
channel
.exec(true, command)
.await
.with_context(|| format!("exec remote command: {command}"))?;
let mut stderr = Vec::new();
let mut stdout = Vec::new();
let mut exit_status = None;
while let Some(msg) = channel.wait().await {
match msg {
russh::ChannelMsg::Data { data } => stdout.extend_from_slice(&data),
russh::ChannelMsg::ExtendedData { data, .. } => stderr.extend_from_slice(&data),
russh::ChannelMsg::ExitStatus { exit_status: code } => exit_status = Some(code),
russh::ChannelMsg::Close => break,
_ => {}
}
}
match exit_status.unwrap_or(0) {
0 => Ok(()),
code => {
let stderr = String::from_utf8_lossy(&stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&stdout).trim().to_string();
Err(anyhow!(
"remote command exited with {code}: {}",
if !stderr.is_empty() { stderr } else { stdout }
))
}
}
}
fn remote_parent(path: &str) -> String {
if path == "/" {
"/".to_string()
} else {
path.rsplit_once('/')
.map(|(parent, _)| {
if parent.is_empty() {
"/".to_string()
} else {
parent.to_string()
}
})
.unwrap_or_else(|| "/".to_string())
}
}
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
async fn maybe_extract_archive(path: &Path) -> Result<Option<PathBuf>> {
let Some(file_name) = path.file_name().and_then(|name| name.to_str()).map(|name| name.to_string()) else {
return Ok(None);
};
let is_archive = [".zip", ".tar", ".tar.gz", ".tgz"]
.iter()
.any(|suffix| file_name.ends_with(suffix));
if !is_archive {
return Ok(None);
}
let extract_root = path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(strip_archive_suffix(&file_name));
let archive_path = path.to_path_buf();
let target_dir = extract_root.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
fs::create_dir_all(&target_dir)
.with_context(|| format!("create {}", target_dir.display()))?;
if file_name.ends_with(".zip") {
let file = fs::File::open(&archive_path)
.with_context(|| format!("open {}", archive_path.display()))?;
let mut zip = ZipArchive::new(file).context("read zip archive")?;
for index in 0..zip.len() {
let mut entry = zip.by_index(index).context("read zip entry")?;
let Some(name) = entry.enclosed_name().map(|name| name.to_path_buf()) else {
continue;
};
let output = target_dir.join(name);
if entry.is_dir() {
fs::create_dir_all(&output)?;
} else {
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)?;
}
let mut output_file = fs::File::create(&output)?;
std::io::copy(&mut entry, &mut output_file)?;
}
}
} else if file_name.ends_with(".tar.gz") || file_name.ends_with(".tgz") {
let file = fs::File::open(&archive_path)
.with_context(|| format!("open {}", archive_path.display()))?;
let decoder = GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
archive.unpack(&target_dir).context("unpack tar.gz archive")?;
} else if file_name.ends_with(".tar") {
let file = fs::File::open(&archive_path)
.with_context(|| format!("open {}", archive_path.display()))?;
let mut archive = tar::Archive::new(file);
archive.unpack(&target_dir).context("unpack tar archive")?;
}
Ok(())
})
.await
.context("extract archive task join failure")??;
Ok(Some(extract_root))
}
async fn extract_archive_to(path: &Path, target_dir: &Path) -> Result<()> {
let Some(file_name) = path.file_name().and_then(|name| name.to_str()).map(|name| name.to_string()) else {
return Ok(());
};
let archive_path = path.to_path_buf();
let target_dir = target_dir.to_path_buf();
tokio::task::spawn_blocking(move || -> Result<()> {
fs::create_dir_all(&target_dir)
.with_context(|| format!("create {}", target_dir.display()))?;
if file_name.ends_with(".zip") {
let file = fs::File::open(&archive_path)
.with_context(|| format!("open {}", archive_path.display()))?;
let mut zip = ZipArchive::new(file).context("read zip archive")?;
for index in 0..zip.len() {
let mut entry = zip.by_index(index).context("read zip entry")?;
let Some(name) = entry.enclosed_name().map(|name| name.to_path_buf()) else {
continue;
};
let output = target_dir.join(name);
if entry.is_dir() {
fs::create_dir_all(&output)?;
} else {
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)?;
}
let mut output_file = fs::File::create(&output)?;
std::io::copy(&mut entry, &mut output_file)?;
}
}
} else if file_name.ends_with(".tar.gz") || file_name.ends_with(".tgz") {
let file = fs::File::open(&archive_path)
.with_context(|| format!("open {}", archive_path.display()))?;
let decoder = GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
archive.unpack(&target_dir).context("unpack tar.gz archive")?;
} else if file_name.ends_with(".tar") {
let file = fs::File::open(&archive_path)
.with_context(|| format!("open {}", archive_path.display()))?;
let mut archive = tar::Archive::new(file);
archive.unpack(&target_dir).context("unpack tar archive")?;
}
Ok(())
})
.await
.context("extract archive task join failure")??;
Ok(())
}
struct SftpClientHandler;
#[async_trait]
impl Handler for SftpClientHandler {
type Error = anyhow::Error;
async fn check_server_key(
&mut self,
_server_public_key: &russh::keys::ssh_key::PublicKey,
) -> Result<bool, Self::Error> {
Ok(true)
}
}
+432
View File
@@ -0,0 +1,432 @@
use std::{
path::{Path, PathBuf},
sync::Arc,
};
use anyhow::{anyhow, Context, Result};
use async_trait::async_trait;
use directories::BaseDirs;
use russh::{
client::{self, Handler},
keys::{decode_secret_key, key::PrivateKeyWithHashAlg, load_secret_key, HashAlg, PrivateKey},
ChannelMsg, Disconnect,
};
use tokio::sync::mpsc;
use crate::{
config::{AuthMethod, Session},
system::{remote_snapshot_from_kv, SystemSnapshot},
terminal::{BackendCommand, BackendEvent, BackendTx},
};
pub fn spawn_ssh_terminal(
runtime: &tokio::runtime::Handle,
tab_id: String,
session: Session,
cols: u16,
rows: u16,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> BackendTx {
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<BackendCommand>();
let task_tab = tab_id.clone();
runtime.spawn(async move {
if let Err(err) = run_ssh(
task_tab.clone(),
session,
cols,
rows,
cmd_rx,
events.clone(),
)
.await
{
let _ = events.send(BackendEvent::Closed {
tab_id: task_tab,
reason: format!("{err:#}"),
});
}
});
BackendTx::Ssh(cmd_tx)
}
pub async fn sample_remote_system(session: Session) -> Result<SystemSnapshot> {
let (events_tx, _events_rx) = std::sync::mpsc::channel();
let handle = connect_and_authenticate("remote-metrics", &session, &events_tx).await?;
let mut channel = handle
.channel_open_session()
.await
.context("open metrics session")?;
channel
.exec(true, REMOTE_SYSTEM_PROBE)
.await
.context("exec remote metrics probe")?;
let mut stdout = Vec::new();
while let Some(msg) = channel.wait().await {
match msg {
ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, ext: _ } => {
stdout.extend_from_slice(&data);
}
ChannelMsg::Close => break,
_ => {}
}
}
let _ = handle
.disconnect(Disconnect::ByApplication, "metrics done", "")
.await;
let output = String::from_utf8_lossy(&stdout);
remote_snapshot_from_kv(&output)
}
async fn run_ssh(
tab_id: String,
session: Session,
cols: u16,
rows: u16,
mut commands: mpsc::UnboundedReceiver<BackendCommand>,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Result<()> {
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.clone(),
text: format!(
"connecting {}@{}:{}...",
session.user, session.host, session.port
),
});
let handle = connect_and_authenticate(&tab_id, &session, &events).await?;
let mut channel = handle
.channel_open_session()
.await
.context("open session")?;
channel
.request_pty(true, "xterm-256color", cols.into(), rows.into(), 0, 0, &[])
.await
.context("request pty")?;
channel.request_shell(true).await.context("request shell")?;
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.clone(),
text: format!("connected {}@{}", session.user, session.host),
});
let _ = events.send(BackendEvent::Connected {
tab_id: tab_id.clone(),
});
loop {
tokio::select! {
command = commands.recv() => {
match command {
Some(BackendCommand::Input(bytes)) => {
if let Err(err) = channel.data(bytes.as_slice()).await {
let _ = events.send(BackendEvent::Closed {
tab_id: tab_id.clone(),
reason: format!("ssh write error: {err}"),
});
break;
}
}
Some(BackendCommand::Resize { cols, rows }) => {
let _ = channel.window_change(cols.into(), rows.into(), 0, 0).await;
}
Some(BackendCommand::Close) | None => {
let _ = channel.eof().await;
break;
}
}
}
msg = channel.wait() => {
match msg {
Some(ChannelMsg::Data { data }) | Some(ChannelMsg::ExtendedData { data, ext: _ }) => {
let _ = events.send(BackendEvent::Output {
tab_id: tab_id.clone(),
bytes: data.to_vec(),
});
}
Some(ChannelMsg::Close) | None => break,
_ => {}
}
}
}
}
let _ = handle
.disconnect(Disconnect::ByApplication, "bye", "")
.await;
let _ = events.send(BackendEvent::Closed {
tab_id,
reason: "ssh session closed".into(),
});
Ok(())
}
async fn connect_and_authenticate(
tab_id: &str,
session: &Session,
events: &std::sync::mpsc::Sender<BackendEvent>,
) -> Result<russh::client::Handle<ClientHandler>> {
let config = Arc::new(client::Config {
inactivity_timeout: Some(std::time::Duration::from_secs(600)),
..Default::default()
});
let addr = format!("{}:{}", session.host, session.port);
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!("opening tcp connection to {addr}"),
});
let mut handle = client::connect(config, addr.as_str(), ClientHandler)
.await
.with_context(|| format!("connect {addr} failed"))?;
let authed = match session.auth {
AuthMethod::Password => {
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!(
"connected to {addr}, sending password authentication for {}",
session.user
),
});
handle
.authenticate_password(&session.user, &session.password)
.await
.context("password authentication failed")?
}
AuthMethod::Key => {
let source = key_source_label(session);
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!("connected to {addr}, loading private key from {source}"),
});
let keypair = load_session_private_key(session)?;
let algorithm = format!("{:?}", keypair.algorithm());
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!("private key loaded from {source}, algorithm {algorithm}, sending public key authentication for {}", session.user),
});
let key = private_key_with_alg(keypair).context("invalid private key")?;
handle
.authenticate_publickey(&session.user, key)
.await
.with_context(|| {
format!(
"public key authentication failed for {}@{}:{} using {} ({})",
session.user, session.host, session.port, source, algorithm
)
})?
}
};
if !authed {
let _ = handle
.disconnect(Disconnect::ByApplication, "auth failed", "")
.await;
return Err(anyhow!(
"{}",
match session.auth {
AuthMethod::Password => format!(
"authentication failed: server rejected password authentication for {}@{}:{}",
session.user, session.host, session.port
),
AuthMethod::Key => format!(
"authentication failed: server rejected public key authentication for {}@{}:{} using {}",
session.user,
session.host,
session.port,
key_source_label(session)
),
}
));
}
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!(
"authentication accepted, opening shell for {}@{}",
session.user, session.host
),
});
Ok(handle)
}
fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
let inline_key = normalize_inline_private_key(&session.private_key_inline);
let key_path = expand_key_path(session.private_key_path.trim());
let has_inline = !inline_key.is_empty();
let has_path = key_path.is_some();
if !has_inline && !has_path {
return Err(anyhow!("private key content or path is required"));
}
let mut errors = Vec::new();
if has_inline {
match decode_secret_key(&inline_key, None) {
Ok(key) => return Ok(key),
Err(err) => errors.push(format!("decode private key content: {err}")),
}
}
if let Some(path) = key_path {
match load_secret_key(path.as_path(), None) {
Ok(key) => return Ok(key),
Err(err) => errors.push(format!("load key {}: {err}", path.display())),
}
}
Err(anyhow!(errors.join("; ")))
}
fn private_key_with_alg(keypair: PrivateKey) -> Result<PrivateKeyWithHashAlg> {
let hash_alg = if keypair.algorithm().is_rsa() {
Some(HashAlg::Sha512)
} else {
None
};
Ok(
PrivateKeyWithHashAlg::new(Arc::new(keypair.clone()), hash_alg)
.or_else(|_| PrivateKeyWithHashAlg::new(Arc::new(keypair), Some(HashAlg::Sha256)))?,
)
}
fn normalize_inline_private_key(value: &str) -> String {
let mut normalized = value
.trim()
.replace("\\r\\n", "\n")
.replace("\\n", "\n")
.replace("\r\n", "\n");
if !normalized.ends_with('\n') {
normalized.push('\n');
}
normalized
}
fn expand_key_path(value: &str) -> Option<PathBuf> {
if value.is_empty() {
return None;
}
if value == "~" {
return BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf());
}
if let Some(rest) = value.strip_prefix("~/") {
return BaseDirs::new().map(|dirs| dirs.home_dir().join(rest));
}
Some(Path::new(value).to_path_buf())
}
fn key_source_label(session: &Session) -> String {
let path = session.private_key_path.trim();
let has_inline = !session.private_key_inline.trim().is_empty();
match (!path.is_empty(), has_inline) {
(true, true) => format!("inline key or {}", path),
(true, false) => path.to_string(),
(false, true) => "inline key text".to_string(),
(false, false) => "unknown key source".to_string(),
}
}
const REMOTE_SYSTEM_PROBE: &str = r#"sh -lc '
os=$(uname -s 2>/dev/null || echo unknown)
if [ "$os" = "Linux" ] && [ -r /proc/stat ]; then
cpu_stat() { awk '"'"'/^cpu / { print ($2+$3+$4+$5+$6+$7+$8), $5 }'"'"' /proc/stat 2>/dev/null; }
net_stat() { awk -F"[: ]+" '"'"'/:/ && $1!="Inter" && $1!="face" { rx += $3; tx += $11 } END { print rx+0, tx+0 }'"'"' /proc/net/dev 2>/dev/null; }
read cpu_total_1 cpu_idle_1 <<EOF
$(cpu_stat)
EOF
read net_rx_1 net_tx_1 <<EOF
$(net_stat)
EOF
sleep 1
read cpu_total_2 cpu_idle_2 <<EOF
$(cpu_stat)
EOF
read net_rx_2 net_tx_2 <<EOF
$(net_stat)
EOF
cpu_delta=$((cpu_total_2 - cpu_total_1))
idle_delta=$((cpu_idle_2 - cpu_idle_1))
cpu_percent=$(awk -v total="$cpu_delta" -v idle="$idle_delta" '"'"'BEGIN { if (total <= 0) print "0.00"; else printf "%.2f", ((total-idle)/total)*100 }'"'"')
mem_total=$(awk '"'"'/^MemTotal:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
mem_available=$(awk '"'"'/^MemAvailable:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
swap_total=$(awk '"'"'/^SwapTotal:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
swap_free=$(awk '"'"'/^SwapFree:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
echo "CPU_PERCENT=${cpu_percent:-0.00}"
echo "MEM_TOTAL=${mem_total:-0}"
echo "MEM_USED=$(( ${mem_total:-0} - ${mem_available:-0} ))"
echo "SWAP_TOTAL=${swap_total:-0}"
echo "SWAP_USED=$(( ${swap_total:-0} - ${swap_free:-0} ))"
echo "NET_RX=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))"
echo "NET_TX=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))"
df -kP 2>/dev/null | awk "NR > 1 { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 4
exit 0
fi
if [ "$os" = "Darwin" ]; then
net_stat() { netstat -ibn 2>/dev/null | awk '"'"'NR > 1 && $7 ~ /^[0-9]+$/ && $10 ~ /^[0-9]+$/ { rx += $7; tx += $10 } END { print rx+0, tx+0 }'"'"'; }
read net_rx_1 net_tx_1 <<EOF
$(net_stat)
EOF
sleep 1
read net_rx_2 net_tx_2 <<EOF
$(net_stat)
EOF
cpu_percent=$(top -l 2 -n 0 -s 1 2>/dev/null | awk -F"[:,% ]+" '"'"'/CPU usage:/ { user=$3; sys=$5 } END { if (user == "" && sys == "") print "0.00"; else printf "%.2f", user + sys }'"'"')
mem_total=$(sysctl -n hw.memsize 2>/dev/null || echo 0)
pagesize=$(sysctl -n hw.pagesize 2>/dev/null || echo 4096)
vm_output=$(vm_stat 2>/dev/null)
pages_active=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages active/ { gsub("\\.","",$3); print $3+0 }'"'"')
pages_wired=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages wired down/ { gsub("\\.","",$4); print $4+0 }'"'"')
pages_compressed=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages occupied by compressor/ { gsub("\\.","",$5); print $5+0 }'"'"')
pages_speculative=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages speculative/ { gsub("\\.","",$3); print $3+0 }'"'"')
mem_used=$(( (${pages_active:-0} + ${pages_wired:-0} + ${pages_compressed:-0} + ${pages_speculative:-0}) * ${pagesize:-4096} ))
swap_line=$(sysctl vm.swapusage 2>/dev/null || true)
swap_used=$(printf "%s\n" "$swap_line" | awk -F"[= ,]+" '"'"'
function mult(unit) { return unit=="K"?1024:(unit=="M"?1048576:(unit=="G"?1073741824:(unit=="T"?1099511627776:1))) }
/used/ { value=$4; unit=substr(value, length(value), 1); sub(/[A-Za-z]+$/, "", value); printf "%.0f", value * mult(unit) }'"'"')
swap_total=$(printf "%s\n" "$swap_line" | awk -F"[= ,]+" '"'"'
function mult(unit) { return unit=="K"?1024:(unit=="M"?1048576:(unit=="G"?1073741824:(unit=="T"?1099511627776:1))) }
/used/ && /free/ { used=$4; free=$8; unit1=substr(used, length(used), 1); unit2=substr(free, length(free), 1); sub(/[A-Za-z]+$/, "", used); sub(/[A-Za-z]+$/, "", free); printf "%.0f", (used * mult(unit1)) + (free * mult(unit2)) }'"'"')
echo "CPU_PERCENT=${cpu_percent:-0.00}"
echo "MEM_TOTAL=${mem_total:-0}"
echo "MEM_USED=${mem_used:-0}"
echo "SWAP_TOTAL=${swap_total:-0}"
echo "SWAP_USED=${swap_used:-0}"
echo "NET_RX=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))"
echo "NET_TX=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))"
df -kP 2>/dev/null | awk "NR > 1 { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 4
exit 0
fi
echo "CPU_PERCENT=0.00"
echo "MEM_TOTAL=0"
echo "MEM_USED=0"
echo "SWAP_TOTAL=0"
echo "SWAP_USED=0"
echo "NET_RX=0"
echo "NET_TX=0"
'"#;
struct ClientHandler;
#[async_trait]
impl Handler for ClientHandler {
type Error = anyhow::Error;
async fn check_server_key(
&mut self,
_server_public_key: &russh::keys::ssh_key::PublicKey,
) -> Result<bool, Self::Error> {
Ok(true)
}
}
+197
View File
@@ -0,0 +1,197 @@
use std::{collections::BTreeMap, time::{Duration, Instant}};
use anyhow::{anyhow, Result};
use sysinfo::{Disks, Networks, System};
#[derive(Debug, Clone, Default)]
pub struct DiskSample {
pub mount: String,
pub available_bytes: u64,
pub total_bytes: u64,
}
#[derive(Debug, Clone, Default)]
pub struct SystemSnapshot {
pub cpu_percent: f32,
pub mem_percent: f32,
pub swap_percent: f32,
pub mem_detail: String,
pub swap_detail: String,
pub net_rx: String,
pub net_tx: String,
pub disks: Vec<DiskSample>,
}
pub struct SystemSampler {
sys: System,
nets: Networks,
disks: Disks,
last_rx_total: u64,
last_tx_total: u64,
last_instant: Instant,
}
impl SystemSampler {
pub fn new() -> Self {
let mut sys = System::new_all();
sys.refresh_all();
let nets = Networks::new_with_refreshed_list();
let disks = Disks::new_with_refreshed_list();
let last_rx_total = nets.iter().map(|(_, d)| d.total_received()).sum();
let last_tx_total = nets.iter().map(|(_, d)| d.total_transmitted()).sum();
Self {
sys,
nets,
disks,
last_rx_total,
last_tx_total,
last_instant: Instant::now(),
}
}
pub fn interval() -> Duration {
Duration::from_millis(1000)
}
pub fn sample(&mut self) -> SystemSnapshot {
self.sys.refresh_cpu_usage();
self.sys.refresh_memory();
self.nets.refresh(true);
self.disks.refresh(true);
let cpu_percent = self.sys.global_cpu_usage() / 100.0;
let mem_total = self.sys.total_memory();
let mem_used = self.sys.used_memory();
let swap_total = self.sys.total_swap();
let swap_used = self.sys.used_swap();
let rx_total: u64 = self.nets.iter().map(|(_, d)| d.total_received()).sum();
let tx_total: u64 = self.nets.iter().map(|(_, d)| d.total_transmitted()).sum();
let now = Instant::now();
let elapsed = now.duration_since(self.last_instant).as_secs_f64().max(0.001);
let rx_rate = (rx_total.saturating_sub(self.last_rx_total) as f64 / elapsed) as u64;
let tx_rate = (tx_total.saturating_sub(self.last_tx_total) as f64 / elapsed) as u64;
self.last_rx_total = rx_total;
self.last_tx_total = tx_total;
self.last_instant = now;
let disks = self
.disks
.iter()
.filter(|disk| disk.total_space() > 0)
.take(4)
.map(|disk| DiskSample {
mount: disk.mount_point().to_string_lossy().to_string(),
available_bytes: disk.available_space(),
total_bytes: disk.total_space(),
})
.collect();
SystemSnapshot {
cpu_percent,
mem_percent: ratio(mem_used, mem_total),
swap_percent: ratio(swap_used, swap_total),
mem_detail: format!(
"{}/{}",
format_bytes(mem_used),
format_bytes(mem_total)
),
swap_detail: format!(
"{}/{}",
format_bytes(swap_used),
format_bytes(swap_total)
),
net_rx: format!("{}/s", format_bytes(rx_rate)),
net_tx: format!("{}/s", format_bytes(tx_rate)),
disks,
}
}
}
fn ratio(used: u64, total: u64) -> f32 {
if total == 0 {
0.0
} else {
(used as f32 / total as f32).clamp(0.0, 1.0)
}
}
pub fn format_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} {}", UNITS[unit])
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
let mut kv = BTreeMap::new();
let mut disks = Vec::new();
for line in raw.lines().map(str::trim).filter(|line| !line.is_empty()) {
if let Some(rest) = line.strip_prefix("DISK=") {
let mut parts = rest.split('\t');
let mount = parts.next().unwrap_or_default().to_string();
let available_bytes = parts
.next()
.unwrap_or("0")
.parse::<u64>()
.unwrap_or_default();
let total_bytes = parts
.next()
.unwrap_or("0")
.parse::<u64>()
.unwrap_or_default();
disks.push(DiskSample {
mount,
available_bytes,
total_bytes,
});
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
kv.insert(key.to_string(), value.to_string());
}
let cpu_percent = kv
.get("CPU_PERCENT")
.ok_or_else(|| anyhow!("missing CPU_PERCENT"))?
.parse::<f32>()
.unwrap_or_default()
/ 100.0;
let mem_used = parse_u64(&kv, "MEM_USED");
let mem_total = parse_u64(&kv, "MEM_TOTAL");
let swap_used = parse_u64(&kv, "SWAP_USED");
let swap_total = parse_u64(&kv, "SWAP_TOTAL");
let rx_rate = parse_u64(&kv, "NET_RX");
let tx_rate = parse_u64(&kv, "NET_TX");
Ok(SystemSnapshot {
cpu_percent: cpu_percent.clamp(0.0, 1.0),
mem_percent: ratio(mem_used, mem_total),
swap_percent: ratio(swap_used, swap_total),
mem_detail: format!("{}/{}", format_bytes(mem_used), format_bytes(mem_total)),
swap_detail: format!("{}/{}", format_bytes(swap_used), format_bytes(swap_total)),
net_rx: format!("{}/s", format_bytes(rx_rate)),
net_tx: format!("{}/s", format_bytes(tx_rate)),
disks,
})
}
fn parse_u64(kv: &BTreeMap<String, String>, key: &str) -> u64 {
kv.get(key)
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or_default()
}
+557
View File
@@ -0,0 +1,557 @@
use std::sync::mpsc::Sender;
use alacritty_terminal::{
event::{Event, EventListener},
grid::{Dimensions, Scroll},
index::{Column, Point, Side},
selection::{Selection, SelectionRange, SelectionType},
term::{
cell::Cell,
point_to_viewport, viewport_to_point, Config, Term, TermMode,
},
vte::ansi::{CursorShape, Processor},
};
use gpui::Keystroke;
use crate::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 },
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 },
Closed { tab_id: String, reason: 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>,
pub sftp: Option<SftpUiState>,
processor: Processor,
term: Term<TerminalListener>,
cols: u16,
rows: u16,
pub backend: BackendTx,
}
#[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>,
}
impl TerminalTab {
pub fn new_local(id: String, title: String, backend: BackendTx) -> Self {
Self::new(id, title, TabKind::Local, "local shell".into(), backend)
}
pub fn new_ssh(id: String, session: &Session, backend: BackendTx) -> Self {
let mut tab = Self::new(
id,
session.name.clone(),
TabKind::Ssh,
format!("connecting {}@{}:{}", session.user, session.host, session.port),
backend,
);
tab.session = Some(session.clone());
tab.connected = false;
tab.sftp = Some(SftpUiState {
current_path: "/".into(),
status: "sftp connecting...".into(),
entries: Vec::new(),
selected_path: None,
preview: None,
});
tab
}
fn new(id: String, title: String, kind: TabKind, status: String, backend: BackendTx) -> Self {
Self {
id,
title,
kind,
status,
connected: matches!(kind, TabKind::Local),
session: None,
sftp: None,
processor: Processor::new(),
term: new_term(100, 30, backend.clone()),
cols: 100,
rows: 30,
backend,
}
}
pub fn feed(&mut self, bytes: &[u8]) {
self.processor.advance(&mut self.term, bytes);
}
pub fn resize(&mut self, cols: u16, rows: u16) {
self.cols = cols.max(1);
self.rows = rows.max(1);
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);
}
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 {
backend: BackendTx,
}
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()));
}
_ => {}
}
}
}
fn new_term(cols: u16, rows: u16, backend: BackendTx) -> Term<TerminalListener> {
Term::new(
Config {
scrolling_history: 2000,
..Config::default()
},
&TerminalSize::new(cols, rows),
TerminalListener { backend },
)
}
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
}
+689
View File
@@ -0,0 +1,689 @@
use alacritty_terminal::{
term::cell::Flags,
vte::ansi::{Color as AnsiColor, CursorShape, NamedColor},
};
use gpui::{
App, Bounds, Element, ElementId, Entity, FocusHandle, Font, FontStyle, FontWeight, GlobalElementId, Hsla, InputHandler, IntoElement,
LayoutId, Pixels, Point, Rgba, StrikethroughStyle, TextRun, TextStyle, UTF16Selection, UnderlineStyle, Window, fill, point, px,
relative, rgb,
};
use gpui_component::ActiveTheme as _;
use crate::Ashell;
use crate::terminal::{RenderSnapshot, ViewportSelection};
#[derive(Clone, Copy)]
struct TerminalMetrics {
cell_width: Pixels,
line_height: Pixels,
}
#[derive(Clone)]
struct LayoutRect {
row: i32,
col: i32,
cells: usize,
color: Hsla,
}
impl LayoutRect {
fn paint(&self, origin: Point<Pixels>, metrics: TerminalMetrics, window: &mut Window) {
let position = point(
origin.x + metrics.cell_width * self.col as f32,
origin.y + metrics.line_height * self.row as f32,
);
let size = gpui::size(metrics.cell_width * self.cells as f32, metrics.line_height);
window.paint_quad(fill(Bounds::new(position, size), self.color));
}
}
#[derive(Clone)]
struct BatchedTextRun {
row: i32,
col: i32,
cell_count: usize,
text: String,
style: TextRun,
font_size: Pixels,
}
impl BatchedTextRun {
fn new(row: i32, col: i32, ch: char, style: TextRun, font_size: Pixels) -> Self {
Self {
row,
col,
cell_count: 1,
text: ch.to_string(),
style,
font_size,
}
}
fn can_append(&self, other: &TextRun, row: i32, col: i32) -> bool {
self.row == row
&& self.col + self.cell_count as i32 == col
&& self.style.font == other.font
&& self.style.color == other.color
&& self.style.background_color == other.background_color
&& self.style.underline == other.underline
&& self.style.strikethrough == other.strikethrough
}
fn append(&mut self, ch: char, zerowidth: Option<&[char]>) {
self.text.push(ch);
self.cell_count += 1;
self.style.len += ch.len_utf8();
if let Some(chars) = zerowidth {
for c in chars {
self.text.push(*c);
self.style.len += c.len_utf8();
}
}
}
fn paint(&self, origin: Point<Pixels>, metrics: TerminalMetrics, window: &mut Window, cx: &mut App) {
let pos = point(
origin.x + metrics.cell_width * self.col as f32,
origin.y + metrics.line_height * self.row as f32,
);
window
.text_system()
.shape_line(
self.text.clone().into(),
self.font_size,
std::slice::from_ref(&self.style),
Some(metrics.cell_width),
)
.paint(
pos,
metrics.line_height,
gpui::TextAlign::Left,
None,
window,
cx,
)
.ok();
}
}
#[derive(Clone, Copy)]
struct CursorLayout {
row: usize,
col: usize,
shape: CursorShape,
color: Hsla,
}
pub struct TerminalElement {
view: Entity<Ashell>,
focus_handle: FocusHandle,
snapshot: RenderSnapshot,
marked_text: Option<String>,
font_family: &'static str,
font_size: Pixels,
line_height: Pixels,
cell_width: Pixels,
}
pub struct PrepaintState {
bounds: Bounds<Pixels>,
metrics: TerminalMetrics,
rects: Vec<LayoutRect>,
runs: Vec<BatchedTextRun>,
cursor: Option<CursorLayout>,
}
struct TerminalInputHandler {
view: Entity<Ashell>,
element_bounds: Bounds<Pixels>,
}
impl InputHandler for TerminalInputHandler {
fn selected_text_range(
&mut self,
_ignore_disabled_input: bool,
_window: &mut Window,
cx: &mut App,
) -> Option<UTF16Selection> {
self.view.read(cx).terminal_accepts_text_input().then_some(UTF16Selection {
range: 0..0,
reversed: false,
})
}
fn marked_text_range(
&mut self,
_window: &mut Window,
cx: &mut App,
) -> Option<std::ops::Range<usize>> {
self.view.read(cx).terminal_marked_text_range()
}
fn text_for_range(
&mut self,
_range_utf16: std::ops::Range<usize>,
_adjusted_range: &mut Option<std::ops::Range<usize>>,
_window: &mut Window,
_cx: &mut App,
) -> Option<String> {
None
}
fn replace_text_in_range(
&mut self,
_replacement_range: Option<std::ops::Range<usize>>,
text: &str,
window: &mut Window,
cx: &mut App,
) {
self.view.update(cx, |view, cx| {
view.commit_terminal_ime_text(text, window, cx);
});
}
fn replace_and_mark_text_in_range(
&mut self,
_range_utf16: Option<std::ops::Range<usize>>,
new_text: &str,
_new_selected_range: Option<std::ops::Range<usize>>,
window: &mut Window,
cx: &mut App,
) {
self.view.update(cx, |view, cx| {
view.set_terminal_marked_text(new_text.to_string(), window, cx);
});
}
fn unmark_text(&mut self, window: &mut Window, cx: &mut App) {
self.view.update(cx, |view, cx| {
view.clear_terminal_marked_text(window, cx);
});
}
fn bounds_for_range(
&mut self,
range_utf16: std::ops::Range<usize>,
_window: &mut Window,
cx: &mut App,
) -> Option<Bounds<Pixels>> {
self.view
.read(cx)
.terminal_ime_bounds_for_range(range_utf16, self.element_bounds)
}
fn character_index_for_point(
&mut self,
_point: Point<Pixels>,
_window: &mut Window,
_cx: &mut App,
) -> Option<usize> {
None
}
fn accepts_text_input(&mut self, _window: &mut Window, cx: &mut App) -> bool {
self.view.read(cx).terminal_accepts_text_input()
}
fn apple_press_and_hold_enabled(&mut self) -> bool {
false
}
fn prefers_ime_for_printable_keys(&mut self, _window: &mut Window, cx: &mut App) -> bool {
self.view.read(cx).terminal_accepts_text_input()
}
}
impl TerminalElement {
pub fn new(
view: Entity<Ashell>,
focus_handle: FocusHandle,
snapshot: RenderSnapshot,
marked_text: Option<String>,
font_family: &'static str,
font_size: Pixels,
line_height: Pixels,
cell_width: Pixels,
) -> Self {
Self {
view,
focus_handle,
snapshot,
marked_text,
font_family,
font_size,
line_height,
cell_width,
}
}
fn base_text_style(&self, cx: &App) -> TextStyle {
TextStyle {
color: cx.theme().foreground,
font_family: self.font_family.into(),
font_size: self.font_size.into(),
line_height: self.line_height.into(),
..Default::default()
}
}
fn cell_run_style(&self, cell: &alacritty_terminal::term::cell::Cell, cx: &App) -> TextRun {
let mut fg = color_to_hsla(cell.fg, true, cx);
let mut bg = color_to_hsla(cell.bg, false, cx);
if cell.flags.contains(Flags::INVERSE) {
std::mem::swap(&mut fg, &mut bg);
}
if cell.flags.contains(Flags::DIM) {
fg.a *= 0.7;
}
let underline = cell.flags.intersects(Flags::ALL_UNDERLINES).then(|| UnderlineStyle {
color: Some(fg),
thickness: px(1.0),
wavy: cell.flags.contains(Flags::UNDERCURL),
});
let strikethrough = cell.flags.contains(Flags::STRIKEOUT).then(|| StrikethroughStyle {
color: Some(fg),
thickness: px(1.0),
});
let weight = if cell.flags.intersects(Flags::BOLD | Flags::DIM_BOLD) {
FontWeight::BOLD
} else {
FontWeight::NORMAL
};
let style = if cell.flags.intersects(Flags::ITALIC | Flags::BOLD_ITALIC) {
FontStyle::Italic
} else {
FontStyle::Normal
};
TextRun {
len: cell.c.len_utf8(),
color: fg,
background_color: None,
font: Font {
family: self.font_family.into(),
weight,
style,
..Font::default()
},
underline,
strikethrough,
}
}
fn layout_grid(&self, cx: &App) -> (Vec<LayoutRect>, Vec<BatchedTextRun>) {
let mut rects = Vec::new();
let mut runs = Vec::new();
let mut current_run: Option<BatchedTextRun> = None;
for render_cell in &self.snapshot.cells {
let cell = &render_cell.cell;
if cell.flags.intersects(
Flags::HIDDEN | Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER,
) {
continue;
}
let selected = self
.snapshot
.selection
.is_some_and(|selection| selection_contains(selection, render_cell.row, render_cell.col));
let bg = color_to_hsla(cell.bg, false, cx);
if selected || !is_default_bg(cell.bg) {
rects.push(LayoutRect {
row: render_cell.row,
col: render_cell.col,
cells: 1,
color: if selected {
cx.theme().selection
} else if cell.flags.contains(Flags::INVERSE) {
color_to_hsla(cell.fg, true, cx)
} else {
bg
},
});
}
if is_blank(cell) {
if let Some(run) = current_run.take() {
runs.push(run);
}
continue;
}
let style = self.cell_run_style(cell, cx);
if let Some(run) = current_run.as_mut() {
if run.can_append(&style, render_cell.row, render_cell.col) {
run.append(cell.c, cell.zerowidth());
continue;
}
}
if let Some(run) = current_run.take() {
runs.push(run);
}
let mut run = BatchedTextRun::new(
render_cell.row,
render_cell.col,
cell.c,
style,
self.font_size,
);
if let Some(chars) = cell.zerowidth() {
for ch in chars {
run.text.push(*ch);
run.style.len += ch.len_utf8();
}
}
current_run = Some(run);
}
if let Some(run) = current_run {
runs.push(run);
}
(merge_rects(rects), runs)
}
fn cursor_layout(&self, cx: &App) -> Option<CursorLayout> {
self.snapshot.cursor.map(|cursor| CursorLayout {
row: cursor.row,
col: cursor.col,
shape: cursor.shape,
color: cx.theme().primary,
})
}
}
impl IntoElement for TerminalElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for TerminalElement {
type RequestLayoutState = ();
type PrepaintState = PrepaintState;
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let mut style = gpui::Style::default();
style.size.width = relative(1.).into();
style.size.height = relative(1.).into();
(window.request_layout(style, None, cx), ())
}
fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
_window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
let _ = self.base_text_style(cx);
let (rects, runs) = self.layout_grid(cx);
PrepaintState {
bounds,
metrics: TerminalMetrics {
cell_width: self.cell_width,
line_height: self.line_height,
},
rects,
runs,
cursor: self.cursor_layout(cx),
}
}
fn paint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
_bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
prepaint: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
for rect in &prepaint.rects {
rect.paint(prepaint.bounds.origin, prepaint.metrics, window);
}
for run in &prepaint.runs {
run.paint(prepaint.bounds.origin, prepaint.metrics, window, cx);
}
window.handle_input(
&self.focus_handle,
TerminalInputHandler {
view: self.view.clone(),
element_bounds: prepaint.bounds,
},
cx,
);
if let Some(marked_text) = self.marked_text.as_ref().filter(|text| !text.is_empty()) {
if let Some(cursor) = prepaint.cursor {
let pos = point(
prepaint.bounds.origin.x + prepaint.metrics.cell_width * cursor.col as f32,
prepaint.bounds.origin.y + prepaint.metrics.line_height * cursor.row as f32,
);
let mut base_style = self.base_text_style(cx);
base_style.underline = Some(UnderlineStyle {
color: Some(base_style.color),
thickness: px(1.0),
wavy: false,
});
let shaped = window.text_system().shape_line(
marked_text.clone().into(),
self.font_size,
&[TextRun {
len: marked_text.len(),
font: Font {
family: self.font_family.into(),
..Font::default()
},
color: base_style.color,
underline: base_style.underline,
..Default::default()
}],
None,
);
let bg_bounds =
Bounds::new(pos, gpui::size(shaped.width, prepaint.metrics.line_height));
window.paint_quad(fill(bg_bounds, cx.theme().background));
shaped
.paint(
pos,
prepaint.metrics.line_height,
gpui::TextAlign::Left,
None,
window,
cx,
)
.ok();
}
}
if let Some(cursor) = prepaint.cursor {
if self.marked_text.as_ref().is_some_and(|text| !text.is_empty()) {
return;
}
let x = prepaint.bounds.origin.x + prepaint.metrics.cell_width * cursor.col as f32;
let y = prepaint.bounds.origin.y + prepaint.metrics.line_height * cursor.row as f32;
match cursor.shape {
CursorShape::Hidden => {}
CursorShape::Beam => {
window.paint_quad(fill(
Bounds::new(point(x, y), gpui::size(px(2.), prepaint.metrics.line_height)),
cursor.color,
));
}
CursorShape::Underline => {
window.paint_quad(fill(
Bounds::new(
point(x, y + prepaint.metrics.line_height - px(2.)),
gpui::size(prepaint.metrics.cell_width, px(2.)),
),
cursor.color,
));
}
CursorShape::Block | CursorShape::HollowBlock => {
let alpha = if matches!(cursor.shape, CursorShape::HollowBlock) {
0.18
} else {
0.32
};
window.paint_quad(fill(
Bounds::new(
point(x, y),
gpui::size(prepaint.metrics.cell_width, prepaint.metrics.line_height),
),
cursor.color.opacity(alpha),
));
}
}
}
}
}
fn merge_rects(mut rects: Vec<LayoutRect>) -> Vec<LayoutRect> {
rects.sort_by_key(|rect| (rect.row, rect.col));
let mut merged: Vec<LayoutRect> = Vec::with_capacity(rects.len());
for rect in rects {
if let Some(last) = merged.last_mut() {
if last.row == rect.row && last.color == rect.color && last.col + last.cells as i32 == rect.col {
last.cells += rect.cells;
continue;
}
}
merged.push(rect);
}
merged
}
fn selection_contains(selection: ViewportSelection, row: i32, col: i32) -> bool {
let row = row.max(0) as usize;
let col = col.max(0) as usize;
if row < selection.start_row || row > selection.end_row {
return false;
}
if selection.is_block {
return col >= selection.start_col && col <= selection.end_col;
}
let after_start = row > selection.start_row || col >= selection.start_col;
let before_end = row < selection.end_row || col <= selection.end_col;
after_start && before_end
}
fn is_blank(cell: &alacritty_terminal::term::cell::Cell) -> bool {
cell.c == ' ' && cell.zerowidth().is_none() && !cell.flags.intersects(Flags::ALL_UNDERLINES | Flags::STRIKEOUT)
}
fn is_default_bg(color: AnsiColor) -> bool {
matches!(color, AnsiColor::Named(NamedColor::Background))
}
fn color_to_hsla(color: AnsiColor, foreground: bool, cx: &App) -> Hsla {
match color {
AnsiColor::Spec(rgb) => Hsla::from(Rgba {
r: rgb.r as f32 / 255.0,
g: rgb.g as f32 / 255.0,
b: rgb.b as f32 / 255.0,
a: 1.0,
}),
AnsiColor::Indexed(index) => ansi_index_color(index, cx),
AnsiColor::Named(named) => named_color(named, foreground, cx),
}
}
fn ansi_index_color(index: u8, _cx: &App) -> Hsla {
const ANSI_16: [u32; 16] = [
0x1f2430, 0xff5c57, 0x5af78e, 0xf3f99d, 0x57c7ff, 0xff6ac1, 0x9aedfe, 0xf1f1f0,
0x686868, 0xff5c57, 0x5af78e, 0xf3f99d, 0x57c7ff, 0xff6ac1, 0x9aedfe, 0xffffff,
];
if (index as usize) < ANSI_16.len() {
return Hsla::from(rgb(ANSI_16[index as usize]));
}
if index >= 232 {
let gray = 8 + (index - 232) * 10;
return Hsla::from(Rgba {
r: gray as f32 / 255.0,
g: gray as f32 / 255.0,
b: gray as f32 / 255.0,
a: 1.0,
});
}
let i = index - 16;
let r = i / 36;
let g = (i % 36) / 6;
let b = i % 6;
let conv = |v: u8| if v == 0 { 0 } else { 55 + v * 40 };
Hsla::from(Rgba {
r: conv(r) as f32 / 255.0,
g: conv(g) as f32 / 255.0,
b: conv(b) as f32 / 255.0,
a: 1.0,
})
}
fn named_color(named: NamedColor, _foreground: bool, cx: &App) -> Hsla {
match named {
NamedColor::Foreground => cx.theme().foreground,
NamedColor::Background => cx.theme().background,
NamedColor::Black => Hsla::from(rgb(0x1f2430)),
NamedColor::Red => Hsla::from(rgb(0xff5c57)),
NamedColor::Green => Hsla::from(rgb(0x5af78e)),
NamedColor::Yellow => Hsla::from(rgb(0xf3f99d)),
NamedColor::Blue => Hsla::from(rgb(0x57c7ff)),
NamedColor::Magenta => Hsla::from(rgb(0xff6ac1)),
NamedColor::Cyan => Hsla::from(rgb(0x9aedfe)),
NamedColor::White => Hsla::from(rgb(0xf1f1f0)),
NamedColor::BrightBlack => Hsla::from(rgb(0x686868)),
NamedColor::BrightRed => Hsla::from(rgb(0xff5c57)),
NamedColor::BrightGreen => Hsla::from(rgb(0x5af78e)),
NamedColor::BrightYellow => Hsla::from(rgb(0xf3f99d)),
NamedColor::BrightBlue => Hsla::from(rgb(0x57c7ff)),
NamedColor::BrightMagenta => Hsla::from(rgb(0xff6ac1)),
NamedColor::BrightCyan => Hsla::from(rgb(0x9aedfe)),
NamedColor::BrightWhite => Hsla::from(rgb(0xffffff)),
NamedColor::Cursor => cx.theme().primary,
NamedColor::DimForeground => cx.theme().muted_foreground,
NamedColor::BrightForeground => cx.theme().foreground,
NamedColor::DimBlack => Hsla::from(rgb(0x3b4252)),
NamedColor::DimRed => Hsla::from(rgb(0xbf616a)),
NamedColor::DimGreen => Hsla::from(rgb(0xa3be8c)),
NamedColor::DimYellow => Hsla::from(rgb(0xebcb8b)),
NamedColor::DimBlue => Hsla::from(rgb(0x81a1c1)),
NamedColor::DimMagenta => Hsla::from(rgb(0xb48ead)),
NamedColor::DimCyan => Hsla::from(rgb(0x88c0d0)),
NamedColor::DimWhite => Hsla::from(rgb(0xe5e9f0)),
}
}