mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-10-03 16:00:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
690d7418f4 | ||
|
|
aca03c924f | ||
|
|
8c24eb885c | ||
|
|
2afcd1ea90 | ||
|
|
68439bc599 | ||
|
|
f1186e4c84 | ||
|
|
2595cc4853 | ||
|
|
2ae28df8f8 | ||
|
|
58fd03386a | ||
|
|
890ce85cfc | ||
|
|
d5fb956161 |
Generated
+93
-4
@@ -299,6 +299,8 @@ dependencies = [
|
||||
"gpui_platform",
|
||||
"image",
|
||||
"menu",
|
||||
"notify 6.1.1",
|
||||
"open",
|
||||
"portable-pty",
|
||||
"rfd",
|
||||
"russh",
|
||||
@@ -1414,6 +1416,15 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
@@ -2696,7 +2707,7 @@ dependencies = [
|
||||
"lsp-types",
|
||||
"markdown",
|
||||
"markup5ever_rcdom",
|
||||
"notify",
|
||||
"notify 7.0.0",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"paste",
|
||||
@@ -3458,6 +3469,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"inotify-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.10.2"
|
||||
@@ -3547,6 +3569,25 @@ version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.11.0"
|
||||
@@ -4109,6 +4150,18 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.1"
|
||||
@@ -4270,6 +4323,25 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "6.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
|
||||
dependencies = [
|
||||
"bitflags 2.12.1",
|
||||
"crossbeam-channel",
|
||||
"filetime",
|
||||
"fsevent-sys",
|
||||
"inotify 0.9.6",
|
||||
"kqueue",
|
||||
"libc",
|
||||
"log",
|
||||
"mio 0.8.11",
|
||||
"walkdir",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "7.0.0"
|
||||
@@ -4279,11 +4351,11 @@ dependencies = [
|
||||
"bitflags 2.12.1",
|
||||
"filetime",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
"inotify 0.10.2",
|
||||
"kqueue",
|
||||
"libc",
|
||||
"log",
|
||||
"mio",
|
||||
"mio 1.2.1",
|
||||
"notify-types",
|
||||
"walkdir",
|
||||
"windows-sys 0.52.0",
|
||||
@@ -4707,6 +4779,17 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
|
||||
dependencies = [
|
||||
"is-wsl",
|
||||
"libc",
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
@@ -4832,6 +4915,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||
|
||||
[[package]]
|
||||
name = "pathfinder_geometry"
|
||||
version = "0.5.1"
|
||||
@@ -7418,7 +7507,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"mio 1.2.1",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
|
||||
@@ -40,6 +40,8 @@ walkdir = "2"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
image = { version = "0.25.9", default-features = false, features = ["png"] }
|
||||
futures = "0.3.32"
|
||||
notify = "6.1.1"
|
||||
open = "5.1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
@@ -8,6 +8,16 @@
|
||||
|
||||
This project focuses on providing a high-performance and visually appealing shell workspace by combining local and remote environments with a rich set of built-in features.
|
||||
|
||||
## 🚀 v0.3 Massive Update!
|
||||
|
||||
The brand-new v0.3 introduces comprehensive upgrades to both architecture and user experience:
|
||||
- ✨ **Global Real-Time Font Settings** (Thanks to [@ancion](https://github.com/ancion) for [PR #6](https://github.com/rust-kotlin/ashell/pull/6)): Customize your global UI fonts and terminal rendering fonts independently. Adjust text size in real-time with automatic persistence.
|
||||
- ✨ **Concurrent SFTP Transfers**: A dedicated Transfers dialog now supports highly optimized, concurrent file uploads and downloads with precise progress display.
|
||||
- ✨ **Persistent Workspace State**: Your sidebar width adjustments, terminal themes, font settings, and file visibility preferences are completely restored upon your next launch!
|
||||
- ✨ **Intelligent SSH Disconnect/Reconnect**: Greatly enhanced SSH connection resilience. Provides a beautiful and localized overlay for one-click re-connection on unexpected disconnections.
|
||||
- ✨ **Hot-Swappable i18n System**: Deeply optimized internationalization. Switch instantly between English and Simplified Chinese without restarting the application.
|
||||
- ✨ **Smart Right-Click Actions**: Added intelligent right-click copy and paste for the terminal, featuring a dedicated settings toggle and OS-native keyboard shortcut hints.
|
||||
|
||||
## Download
|
||||
|
||||
You can download the latest pre-compiled releases for macOS, Windows, and Linux from the [GitHub Releases page](https://github.com/rust-kotlin/ashell/releases/latest).
|
||||
|
||||
@@ -8,6 +8,16 @@
|
||||
|
||||
该项目旨在通过结合本地和远程环境,并内置丰富的核心功能,提供一个高性能且美观的 Shell 工作区。
|
||||
|
||||
## 🚀 v0.3 版本重磅更新!
|
||||
|
||||
全新发布的 v0.3 迎来了架构和体验的全面升级:
|
||||
- ✨ **全局实时字体设置与字号调节**(感谢 [@ancion](https://github.com/ancion) 提供的 [PR #6](https://github.com/rust-kotlin/ashell/pull/6)):支持自定义全局 UI 字体和终端渲染字体,支持实时字号调节并自动记忆。
|
||||
- ✨ **SFTP 多任务并发传输队列**:引入全新的独立传输对话框,支持多文件并发上传下载,提供精准的进度展示。
|
||||
- ✨ **持久化工作区记忆**:您对侧边栏的尺寸拖拽、终端主题、字体、隐藏文件开关等一切设置都会在下次启动时原样恢复!
|
||||
- ✨ **智能断线重连与状态感知**:全面增强的 SSH 网络异常处理机制,断线后直接提供美观的覆盖层用于一键重连或关闭。
|
||||
- ✨ **多语言热切换增强**:深度优化的 i18n 系统,包含中文与英文在内的所有界面文案现在均支持热切换。
|
||||
- ✨ **智能右键快捷操作**:新增终端右键智能复制与粘贴功能,提供专属设置项自由控制开关,并支持根据不同操作系统自适应显示原生快捷键提示。
|
||||
|
||||
## 下载
|
||||
|
||||
您可以从 [GitHub Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载 macOS、Windows 和 Linux 版本的最新预编译程序。
|
||||
|
||||
+18
-1
@@ -6,12 +6,23 @@ upload_file: "Upload File"
|
||||
upload_folder: "Upload Folder"
|
||||
hidden: "Hidden"
|
||||
download: "Download"
|
||||
download_folder: "Download Folder"
|
||||
download_count: "Download (%{count})"
|
||||
name: "NAME"
|
||||
size: "SIZE"
|
||||
modified: "MODIFIED"
|
||||
new_ssh_connection: "New SSH Connection"
|
||||
create_or_edit_ssh_session: "Create or edit an SSH session"
|
||||
new_folder: "New Folder"
|
||||
create_folder_success: "Created folder: %{name}"
|
||||
create_folder_failed: "Failed to create folder: %{err}"
|
||||
delete_selected: "Delete"
|
||||
confirm_delete: "Confirm Delete"
|
||||
confirm_delete_desc: "Are you sure you want to delete the selected %{count} items? This action cannot be undone."
|
||||
system_path_warning: "WARNING: You are attempting to delete critical system paths! This may cause system corruption!"
|
||||
delete_success: "Successfully deleted %{count} items"
|
||||
delete_failed: "Failed to delete: %{err}"
|
||||
deleting_paths: "Deleting %{count} items..."
|
||||
local_terminal: "Local Terminal"
|
||||
open_local_shell_tab: "Open a new local shell tab"
|
||||
connect: "Connect"
|
||||
@@ -91,4 +102,10 @@ failed: "Failed"
|
||||
cancelled: "Cancelled"
|
||||
session: "Session"
|
||||
|
||||
software_builtin: "Built-in"
|
||||
software_builtin: "Built-in"
|
||||
retry: "Retry"
|
||||
right_click_copy_paste: "Right-click Copy/Paste"
|
||||
copy_paste_hint: "Tip: Regardless of this switch, you can always use %{key} + C/V to copy/paste."
|
||||
edit_file: "Edit File"
|
||||
edit_file_tooltip: "Download to temporary directory, open in default editor, and auto upload on save."
|
||||
auto_saved_and_uploaded: "Auto-saved & uploaded: %{base}"
|
||||
|
||||
+20
-1
@@ -6,12 +6,25 @@ upload_file: "上传文件"
|
||||
upload_folder: "上传文件夹"
|
||||
hidden: "显示隐藏文件"
|
||||
download: "下载"
|
||||
download_folder: "下载文件夹"
|
||||
download_count: "下载 (%{count})"
|
||||
name: "名称"
|
||||
size: "大小"
|
||||
modified: "修改时间"
|
||||
new_ssh_connection: "新建 SSH 连接"
|
||||
create_or_edit_ssh_session: "创建或编辑一个 SSH 会话"
|
||||
new_folder: "新建文件夹"
|
||||
create_folder_success: "成功创建文件夹: %{name}"
|
||||
create_folder_failed: "创建文件夹失败: %{err}"
|
||||
delete_selected: "删除"
|
||||
confirm_delete: "确认删除"
|
||||
confirm_delete_desc: "确定要删除以下选中的 %{count} 个项吗?此操作无法撤销。"
|
||||
system_path_warning: "警告:您即将删除系统关键路径!这可能会导致系统损坏!"
|
||||
delete_success: "成功删除 %{count} 个项"
|
||||
delete_failed: "删除失败: %{err}"
|
||||
deleting_paths: "正在删除 %{count} 个项..."
|
||||
cancel: "取消"
|
||||
confirm: "确认"
|
||||
local_terminal: "本地终端"
|
||||
open_local_shell_tab: "打开一个新的本地终端页面"
|
||||
connect: "连接"
|
||||
@@ -91,4 +104,10 @@ failed: "失败"
|
||||
cancelled: "已取消"
|
||||
session: "会话"
|
||||
|
||||
software_builtin: "软件内置"
|
||||
software_builtin: "软件内置"
|
||||
retry: "重试"
|
||||
right_click_copy_paste: "右键复制/粘贴"
|
||||
copy_paste_hint: "提示:不管是否开启此开关,都可以使用 %{key} + C/V 快捷键进行复制和粘贴。"
|
||||
edit_file: "编辑文件"
|
||||
edit_file_tooltip: "下载到临时目录,使用默认编辑器打开,并在保存后自动上传。"
|
||||
auto_saved_and_uploaded: "已自动保存并上传: %{base}"
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 279 KiB |
@@ -109,6 +109,8 @@ pub struct ConfigFile {
|
||||
pub terminal_font_size: f32,
|
||||
#[serde(default = "default_ui_font_size")]
|
||||
pub ui_font_size: f32,
|
||||
#[serde(default)]
|
||||
pub right_click_copy_paste: bool,
|
||||
#[serde(default = "default_ui_font_family")]
|
||||
pub ui_font_family: String,
|
||||
#[serde(default = "default_terminal_font_family")]
|
||||
@@ -123,6 +125,8 @@ pub struct ConfigFile {
|
||||
pub body_panels: Option<Vec<f32>>,
|
||||
#[serde(default)]
|
||||
pub transfers: Vec<crate::terminal::Transfer>,
|
||||
#[serde(default)]
|
||||
pub show_hidden_files: bool,
|
||||
}
|
||||
|
||||
fn default_locale() -> String {
|
||||
@@ -158,6 +162,9 @@ impl ConfigStore {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create config dir {}", parent.display()))?;
|
||||
let tmp_dir = parent.join("tmp");
|
||||
let _ = fs::remove_dir_all(&tmp_dir);
|
||||
let _ = fs::create_dir_all(&tmp_dir);
|
||||
}
|
||||
|
||||
let cache = if path.exists() {
|
||||
@@ -191,6 +198,10 @@ impl ConfigStore {
|
||||
&self.cache.sessions
|
||||
}
|
||||
|
||||
pub fn tmp_dir(&self) -> Option<PathBuf> {
|
||||
self.path.parent().map(|p| p.join("tmp"))
|
||||
}
|
||||
|
||||
pub fn follow_system_theme(&self) -> bool {
|
||||
self.cache.follow_system_theme
|
||||
}
|
||||
@@ -302,6 +313,14 @@ impl ConfigStore {
|
||||
self.cache.ui_font_family = family.to_string();
|
||||
}
|
||||
|
||||
pub fn right_click_copy_paste(&self) -> bool {
|
||||
self.cache.right_click_copy_paste
|
||||
}
|
||||
|
||||
pub fn set_right_click_copy_paste(&mut self, val: bool) {
|
||||
self.cache.right_click_copy_paste = val;
|
||||
}
|
||||
|
||||
pub fn terminal_font_family(&self) -> &str {
|
||||
if self.cache.terminal_font_family.is_empty() {
|
||||
"Maple Mono NF CN"
|
||||
@@ -314,6 +333,14 @@ impl ConfigStore {
|
||||
self.cache.terminal_font_family = family.to_string();
|
||||
}
|
||||
|
||||
pub fn show_hidden_files(&self) -> bool {
|
||||
self.cache.show_hidden_files
|
||||
}
|
||||
|
||||
pub fn set_show_hidden_files(&mut self, val: bool) {
|
||||
self.cache.show_hidden_files = val;
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<&Session> {
|
||||
self.cache.sessions.iter().find(|s| s.id == id)
|
||||
}
|
||||
|
||||
+621
-137
File diff suppressed because it is too large
Load Diff
+429
-38
@@ -58,6 +58,15 @@ pub enum SftpCommand {
|
||||
remote: String,
|
||||
local_dir: String,
|
||||
},
|
||||
EditFile {
|
||||
remote_path: String,
|
||||
},
|
||||
CreateDir(String),
|
||||
DeletePaths(Vec<String>),
|
||||
UploadEditedFile {
|
||||
local_path: String,
|
||||
remote_path: String,
|
||||
},
|
||||
UploadPaths {
|
||||
locals: Vec<String>,
|
||||
remote_dir: String,
|
||||
@@ -77,11 +86,17 @@ impl TransferStateFlag {
|
||||
pub fn new() -> Self {
|
||||
Self(Arc::new(AtomicU8::new(0)))
|
||||
}
|
||||
|
||||
pub fn pause(&self) { self.0.store(1, Ordering::SeqCst); }
|
||||
pub fn resume(&self) { self.0.store(0, Ordering::SeqCst); }
|
||||
pub fn cancel(&self) { self.0.store(2, Ordering::SeqCst); }
|
||||
|
||||
|
||||
pub fn pause(&self) {
|
||||
self.0.store(1, Ordering::SeqCst);
|
||||
}
|
||||
pub fn resume(&self) {
|
||||
self.0.store(0, Ordering::SeqCst);
|
||||
}
|
||||
pub fn cancel(&self) {
|
||||
self.0.store(2, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub async fn yield_if_paused(
|
||||
&self,
|
||||
events: &std::sync::mpsc::Sender<crate::terminal::BackendEvent>,
|
||||
@@ -161,6 +176,10 @@ impl SftpHandle {
|
||||
.send(SftpCommand::UploadPaths { locals, remote_dir });
|
||||
}
|
||||
|
||||
pub fn edit_file(&self, remote_path: String) {
|
||||
let _ = self.commands.send(SftpCommand::EditFile { remote_path });
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
let _ = self.commands.send(SftpCommand::Close);
|
||||
}
|
||||
@@ -187,7 +206,15 @@ pub fn spawn_sftp(
|
||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
||||
let cmd_tx_clone = cmd_tx.clone();
|
||||
let join = runtime.spawn(async move {
|
||||
if let Err(err) = run_sftp(tab_id.clone(), session, cmd_rx, cmd_tx_clone, events.clone()).await {
|
||||
if let Err(err) = run_sftp(
|
||||
tab_id.clone(),
|
||||
session,
|
||||
cmd_rx,
|
||||
cmd_tx_clone,
|
||||
events.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id,
|
||||
text: format!("sftp error: {err:#}"),
|
||||
@@ -229,9 +256,16 @@ async fn run_sftp(
|
||||
.canonicalize(".")
|
||||
.await
|
||||
.unwrap_or_else(|_| "/".to_string());
|
||||
|
||||
let _ = events.send(BackendEvent::SftpHome {
|
||||
tab_id: tab_id.clone(),
|
||||
home: home.clone(),
|
||||
});
|
||||
|
||||
emit_entries(&events, &tab_id, &sftp, &home).await?;
|
||||
|
||||
let mut active_transfers: std::collections::HashMap<String, TransferStateFlag> = std::collections::HashMap::new();
|
||||
let mut active_transfers: std::collections::HashMap<String, TransferStateFlag> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
while let Some(command) = commands.recv().await {
|
||||
match command {
|
||||
@@ -308,16 +342,33 @@ async fn run_sftp(
|
||||
let commands_tx_clone = commands_tx.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let Ok(channel) = handle_clone.channel_open_session().await else { return };
|
||||
let Ok(_) = channel.request_subsystem(true, "sftp").await else { return };
|
||||
let Ok(sftp_session) = SftpSession::new(channel.into_stream()).await else { return };
|
||||
|
||||
let Ok(channel) = handle_clone.channel_open_session().await else {
|
||||
return;
|
||||
};
|
||||
let Ok(_) = channel.request_subsystem(true, "sftp").await else {
|
||||
return;
|
||||
};
|
||||
let Ok(sftp_session) = SftpSession::new(channel.into_stream()).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let _ = events_clone.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id_clone.clone(),
|
||||
text: t!("downloading_file", base = base_name(&remote)).to_string(),
|
||||
});
|
||||
|
||||
match download_path_impl(&handle_clone, &sftp_session, &remote, Path::new(&local_dir), flag, &events_clone, &tab_id_clone, &id).await {
|
||||
match download_path_impl(
|
||||
&handle_clone,
|
||||
&sftp_session,
|
||||
&remote,
|
||||
Path::new(&local_dir),
|
||||
flag,
|
||||
&events_clone,
|
||||
&tab_id_clone,
|
||||
&id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(summary) => {
|
||||
let _ = events_clone.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id_clone,
|
||||
@@ -364,7 +415,12 @@ async fn run_sftp(
|
||||
} else if file_count == 0 && folder_count > 0 {
|
||||
t!("n_folders", folders = folder_count).to_string()
|
||||
} else {
|
||||
t!("n_files_and_folders", files = file_count, folders = folder_count).to_string()
|
||||
t!(
|
||||
"n_files_and_folders",
|
||||
files = file_count,
|
||||
folders = folder_count
|
||||
)
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -387,16 +443,32 @@ async fn run_sftp(
|
||||
let commands_tx_clone = commands_tx.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let Ok(channel) = handle_clone.channel_open_session().await else { return };
|
||||
let Ok(_) = channel.request_subsystem(true, "sftp").await else { return };
|
||||
let Ok(sftp_session) = SftpSession::new(channel.into_stream()).await else { return };
|
||||
|
||||
let Ok(channel) = handle_clone.channel_open_session().await else {
|
||||
return;
|
||||
};
|
||||
let Ok(_) = channel.request_subsystem(true, "sftp").await else {
|
||||
return;
|
||||
};
|
||||
let Ok(sftp_session) = SftpSession::new(channel.into_stream()).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let _ = events_clone.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id_clone.clone(),
|
||||
text: t!("uploading").to_string(),
|
||||
});
|
||||
|
||||
match upload_paths_impl(&sftp_session, &locals, &remote_dir, flag, &events_clone, &tab_id_clone, &id).await {
|
||||
match upload_paths_impl(
|
||||
&sftp_session,
|
||||
&locals,
|
||||
&remote_dir,
|
||||
flag,
|
||||
&events_clone,
|
||||
&tab_id_clone,
|
||||
&id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(summary) => {
|
||||
let _ = events_clone.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id_clone.clone(),
|
||||
@@ -422,6 +494,223 @@ async fn run_sftp(
|
||||
let _ = commands_tx_clone.send(SftpCommand::TransferFinished(id));
|
||||
});
|
||||
}
|
||||
SftpCommand::EditFile { remote_path } => {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let config = crate::config::ConfigStore::load().unwrap();
|
||||
let tmp_dir = config.tmp_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
|
||||
let base = base_name(&remote_path);
|
||||
let local_path = tmp_dir.join(format!("{}-{}", id, base));
|
||||
|
||||
let handle_clone = handle.clone();
|
||||
let commands_tx_clone = commands_tx.clone();
|
||||
let events_clone = events.clone();
|
||||
let tab_id_clone = tab_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let flag = TransferStateFlag::new();
|
||||
let Ok(channel) = handle_clone.channel_open_session().await else {
|
||||
return;
|
||||
};
|
||||
let Ok(_) = channel.request_subsystem(true, "sftp").await else {
|
||||
return;
|
||||
};
|
||||
let Ok(sftp_session) = SftpSession::new(channel.into_stream()).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let _ = events_clone.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id_clone.clone(),
|
||||
text: t!("downloading_file", base = base).to_string(),
|
||||
});
|
||||
|
||||
if let Err(err) = download_file_impl(
|
||||
&sftp_session,
|
||||
&remote_path,
|
||||
&local_path,
|
||||
&flag,
|
||||
&events_clone,
|
||||
&tab_id_clone,
|
||||
"edit-download",
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = events_clone.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id_clone.clone(),
|
||||
text: format!("Edit download failed: {err:#}"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) = open::that(&local_path) {
|
||||
let _ = events_clone.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id_clone.clone(),
|
||||
text: format!("Failed to open editor: {err:#}"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
use notify::Watcher;
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut watcher = match notify::recommended_watcher(
|
||||
move |res: notify::Result<notify::Event>| {
|
||||
if let Ok(event) = res {
|
||||
if event.kind.is_modify() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Ok(w) => w,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if let Err(_) = watcher.watch(&local_path, notify::RecursiveMode::NonRecursive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while let Some(_) = rx.recv().await {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
while let Ok(_) = rx.try_recv() {} // drain pending
|
||||
|
||||
if commands_tx_clone.send(SftpCommand::UploadEditedFile {
|
||||
local_path: local_path.to_string_lossy().to_string(),
|
||||
remote_path: remote_path.clone(),
|
||||
}).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
SftpCommand::UploadEditedFile {
|
||||
local_path,
|
||||
remote_path,
|
||||
} => {
|
||||
let handle_clone = handle.clone();
|
||||
let events_clone = events.clone();
|
||||
let tab_id_clone = tab_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let flag = TransferStateFlag::new();
|
||||
let Ok(channel) = handle_clone.channel_open_session().await else {
|
||||
return;
|
||||
};
|
||||
let Ok(_) = channel.request_subsystem(true, "sftp").await else {
|
||||
return;
|
||||
};
|
||||
let Ok(sftp_session) = SftpSession::new(channel.into_stream()).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let transferred = Arc::new(AtomicU64::new(0));
|
||||
match upload_file_impl(
|
||||
&sftp_session,
|
||||
Path::new(&local_path),
|
||||
&remote_path,
|
||||
&flag,
|
||||
&events_clone,
|
||||
&tab_id_clone,
|
||||
"edit-upload",
|
||||
transferred,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
let now = chrono::Local::now().format("%H:%M:%S");
|
||||
let _ = events_clone.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id_clone.clone(),
|
||||
text: format!("{} ({})", t!("auto_saved_and_uploaded", base = base_name(&remote_path)), now),
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = events_clone.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id_clone.clone(),
|
||||
text: format!("Auto-upload failed: {err:#}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
SftpCommand::CreateDir(path) => {
|
||||
let actual_path = if path == "~" {
|
||||
home.clone()
|
||||
} else if let Some(rest) = path.strip_prefix("~/") {
|
||||
crate::sftp::join_remote(&home, rest)
|
||||
} else {
|
||||
path.clone()
|
||||
};
|
||||
|
||||
match sftp.create_dir(&actual_path).await {
|
||||
Ok(_) => {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: t!("create_folder_success", name = base_name(&actual_path)).to_string(),
|
||||
});
|
||||
|
||||
// Re-fetch the parent directory to show the newly created folder
|
||||
if let Some(parent) = parent_dir(&actual_path) {
|
||||
let _ = commands_tx.send(SftpCommand::ListDir(parent));
|
||||
} else {
|
||||
let _ = commands_tx.send(SftpCommand::ListDir("/".to_string()));
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: t!("create_folder_failed", err = format!("{err:#}")).to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
SftpCommand::DeletePaths(paths) => {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: t!("deleting_paths", count = paths.len()).to_string(),
|
||||
});
|
||||
|
||||
let mut errors = Vec::new();
|
||||
for path in paths.clone() {
|
||||
let actual_path = if path == "~" {
|
||||
home.clone()
|
||||
} else if let Some(rest) = path.strip_prefix("~/") {
|
||||
crate::sftp::join_remote(&home, rest)
|
||||
} else {
|
||||
path.clone()
|
||||
};
|
||||
|
||||
if let Err(e) = recursive_delete(&sftp, actual_path).await {
|
||||
errors.push(format!("{path}: {e:#}"));
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: t!("delete_success", count = paths.len()).to_string(),
|
||||
});
|
||||
} else {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: t!("delete_failed", err = errors.join(", ")).to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(first) = paths.first() {
|
||||
let actual_path = if first == "~" {
|
||||
home.clone()
|
||||
} else if let Some(rest) = first.strip_prefix("~/") {
|
||||
crate::sftp::join_remote(&home, rest)
|
||||
} else {
|
||||
first.clone()
|
||||
};
|
||||
if let Some(parent) = parent_dir(&actual_path) {
|
||||
let _ = commands_tx.send(SftpCommand::ListDir(parent));
|
||||
} else {
|
||||
let _ = commands_tx.send(SftpCommand::ListDir("/".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,6 +720,43 @@ async fn run_sftp(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
fn recursive_delete<'a>(
|
||||
sftp: &'a SftpSession,
|
||||
path: String,
|
||||
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
match sftp.read_dir(&path).await {
|
||||
Ok(entries) => {
|
||||
for entry in entries {
|
||||
let name = entry.file_name();
|
||||
if name == "." || name == ".." {
|
||||
continue;
|
||||
}
|
||||
let child_path = crate::sftp::join_remote(&path, &name);
|
||||
|
||||
let meta = entry.metadata();
|
||||
let permissions = meta.permissions.unwrap_or(0);
|
||||
let is_dir = (permissions & 0o170_000) == 0o040_000;
|
||||
|
||||
if is_dir {
|
||||
recursive_delete(sftp, child_path).await?;
|
||||
} else {
|
||||
sftp.remove_file(&child_path).await.with_context(|| format!("Failed to delete file {child_path}"))?;
|
||||
}
|
||||
}
|
||||
sftp.remove_dir(&path).await.with_context(|| format!("Failed to delete dir {path}"))?;
|
||||
}
|
||||
Err(_) => {
|
||||
sftp.remove_file(&path).await.with_context(|| format!("Failed to delete {path}"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
async fn emit_entries(
|
||||
events: &std::sync::mpsc::Sender<BackendEvent>,
|
||||
tab_id: &str,
|
||||
@@ -571,7 +897,23 @@ fn base_name(path: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn join_remote(parent: &str, child: &str) -> String {
|
||||
pub(crate) fn parent_dir(path: &str) -> Option<String> {
|
||||
if path == "/" || path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let trimmed = path.trim_end_matches('/');
|
||||
if let Some(idx) = trimmed.rfind('/') {
|
||||
if idx == 0 {
|
||||
Some("/".to_string())
|
||||
} else {
|
||||
Some(trimmed[..idx].to_string())
|
||||
}
|
||||
} else {
|
||||
Some("/".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn join_remote(parent: &str, child: &str) -> String {
|
||||
if parent == "/" {
|
||||
format!("/{child}")
|
||||
} else {
|
||||
@@ -736,8 +1078,17 @@ async fn download_path_impl(
|
||||
base_name(remote),
|
||||
Uuid::new_v4()
|
||||
));
|
||||
let extracted_to =
|
||||
download_remote_directory_archive(handle, sftp, remote, &local_archive, &flag, events, tab_id, id).await?;
|
||||
let extracted_to = download_remote_directory_archive(
|
||||
handle,
|
||||
sftp,
|
||||
remote,
|
||||
&local_archive,
|
||||
&flag,
|
||||
events,
|
||||
tab_id,
|
||||
id,
|
||||
)
|
||||
.await?;
|
||||
return Ok(t!("downloaded_folder", path = extracted_to.display()).to_string());
|
||||
}
|
||||
|
||||
@@ -763,9 +1114,27 @@ async fn download_dir_recursive(
|
||||
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, flag, events, tab_id, id)).await?;
|
||||
Box::pin(download_dir_recursive(
|
||||
sftp,
|
||||
&entry.full_path,
|
||||
&local_path,
|
||||
flag,
|
||||
events,
|
||||
tab_id,
|
||||
id,
|
||||
))
|
||||
.await?;
|
||||
} else {
|
||||
download_file_impl(sftp, &entry.full_path, &local_path, flag, events, tab_id, id).await?;
|
||||
download_file_impl(
|
||||
sftp,
|
||||
&entry.full_path,
|
||||
&local_path,
|
||||
flag,
|
||||
events,
|
||||
tab_id,
|
||||
id,
|
||||
)
|
||||
.await?;
|
||||
let _ = maybe_extract_archive(&local_path).await;
|
||||
}
|
||||
}
|
||||
@@ -794,7 +1163,16 @@ async fn download_remote_directory_archive(
|
||||
.join(base_name(remote_dir));
|
||||
|
||||
let archive_download = async {
|
||||
download_file_impl(sftp, &remote_archive, local_archive, flag, events, tab_id, id).await?;
|
||||
download_file_impl(
|
||||
sftp,
|
||||
&remote_archive,
|
||||
local_archive,
|
||||
flag,
|
||||
events,
|
||||
tab_id,
|
||||
id,
|
||||
)
|
||||
.await?;
|
||||
extract_archive_to(
|
||||
local_archive,
|
||||
local_archive.parent().unwrap_or_else(|| Path::new(".")),
|
||||
@@ -839,7 +1217,8 @@ async fn download_file_impl(
|
||||
|
||||
let mut buffer = vec![0u8; 128 * 1024];
|
||||
loop {
|
||||
flag.yield_if_paused(events, tab_id, id, transferred, total).await?;
|
||||
flag.yield_if_paused(events, tab_id, id, transferred, total)
|
||||
.await?;
|
||||
let read = remote_file
|
||||
.read(&mut buffer)
|
||||
.await
|
||||
@@ -851,7 +1230,7 @@ async fn download_file_impl(
|
||||
.write_all(&buffer[..read])
|
||||
.await
|
||||
.with_context(|| format!("write {}", local.display()))?;
|
||||
|
||||
|
||||
transferred += read as u64;
|
||||
let _ = events.send(BackendEvent::TransferProgress {
|
||||
tab_id: tab_id.to_string(),
|
||||
@@ -862,7 +1241,7 @@ async fn download_file_impl(
|
||||
});
|
||||
}
|
||||
local_file.flush().await.context("flush local file")?;
|
||||
|
||||
|
||||
let _ = events.send(BackendEvent::TransferProgress {
|
||||
tab_id: tab_id.to_string(),
|
||||
id: id.to_string(),
|
||||
@@ -870,7 +1249,7 @@ async fn download_file_impl(
|
||||
total,
|
||||
state: crate::terminal::TransferState::Completed,
|
||||
});
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -886,7 +1265,7 @@ async fn upload_paths_impl(
|
||||
create_remote_dir_all(sftp, remote_dir).await?;
|
||||
let mut file_count = 0usize;
|
||||
let mut folder_count = 0usize;
|
||||
|
||||
|
||||
let mut total_bytes = 0u64;
|
||||
let mut files_to_upload = Vec::new();
|
||||
let mut dirs_to_create = Vec::new();
|
||||
@@ -902,14 +1281,20 @@ async fn upload_paths_impl(
|
||||
for entry in WalkDir::new(&p) {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path == p { continue; }
|
||||
if path == p {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(meta) = tokio::fs::metadata(&path).await {
|
||||
let relative = path.strip_prefix(&p)?;
|
||||
let remote_path = if relative.as_os_str().is_empty() {
|
||||
remote_root.clone()
|
||||
} else {
|
||||
let rel = relative.components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::<Vec<_>>().join("/");
|
||||
let rel = relative
|
||||
.components()
|
||||
.map(|c| c.as_os_str().to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
join_remote(&remote_root, &rel)
|
||||
};
|
||||
|
||||
@@ -955,7 +1340,8 @@ async fn upload_paths_impl(
|
||||
&id_clone,
|
||||
transferred_clone,
|
||||
Some(total_bytes),
|
||||
).await
|
||||
)
|
||||
.await
|
||||
});
|
||||
}
|
||||
|
||||
@@ -964,7 +1350,7 @@ async fn upload_paths_impl(
|
||||
while let Some(res) = stream.next().await {
|
||||
res?;
|
||||
}
|
||||
|
||||
|
||||
let _ = events.send(BackendEvent::TransferProgress {
|
||||
tab_id: tab_id.to_string(),
|
||||
id: id.to_string(),
|
||||
@@ -972,7 +1358,7 @@ async fn upload_paths_impl(
|
||||
total: Some(total_bytes),
|
||||
state: crate::terminal::TransferState::Completed,
|
||||
});
|
||||
|
||||
|
||||
let summary = if file_count == 1 && folder_count == 0 {
|
||||
t!("uploaded_file").to_string()
|
||||
} else if file_count == 0 && folder_count == 1 {
|
||||
@@ -982,14 +1368,19 @@ async fn upload_paths_impl(
|
||||
} else if file_count == 0 && folder_count > 0 {
|
||||
t!("uploaded_n_folders", folders = folder_count).to_string()
|
||||
} else {
|
||||
t!("uploaded_files_and_folders", files = file_count, folders = folder_count).to_string()
|
||||
t!(
|
||||
"uploaded_files_and_folders",
|
||||
files = file_count,
|
||||
folders = folder_count
|
||||
)
|
||||
.to_string()
|
||||
};
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
async fn upload_file_impl(
|
||||
sftp: &SftpSession,
|
||||
local_file: &Path,
|
||||
sftp: &SftpSession,
|
||||
local_file: &Path,
|
||||
remote_path: &str,
|
||||
flag: &TransferStateFlag,
|
||||
events: &std::sync::mpsc::Sender<BackendEvent>,
|
||||
@@ -1018,7 +1409,7 @@ async fn upload_file_impl(
|
||||
.write_all(&buffer[..read])
|
||||
.await
|
||||
.with_context(|| format!("write remote {remote_path}"))?;
|
||||
|
||||
|
||||
let new_cur = transferred.fetch_add(read as u64, Ordering::Relaxed) + read as u64;
|
||||
let _ = events.send(BackendEvent::TransferProgress {
|
||||
tab_id: tab_id.to_string(),
|
||||
|
||||
@@ -61,6 +61,10 @@ pub enum BackendEvent {
|
||||
tab_id: String,
|
||||
reason: String,
|
||||
},
|
||||
SftpHome {
|
||||
tab_id: String,
|
||||
home: String,
|
||||
},
|
||||
TransferProgress {
|
||||
#[allow(dead_code)]
|
||||
tab_id: String,
|
||||
@@ -159,6 +163,7 @@ pub struct SftpUiState {
|
||||
pub selected_path: Option<String>,
|
||||
pub preview: Option<PreviewData>,
|
||||
pub selected_entries: std::collections::HashSet<String>,
|
||||
pub home_dir: String,
|
||||
}
|
||||
|
||||
impl TerminalTab {
|
||||
@@ -204,6 +209,7 @@ impl TerminalTab {
|
||||
selected_path: None,
|
||||
preview: None,
|
||||
selected_entries: std::collections::HashSet::new(),
|
||||
home_dir: "/".into(),
|
||||
});
|
||||
tab
|
||||
}
|
||||
|
||||
@@ -144,6 +144,8 @@ pub struct PrepaintState {
|
||||
struct TerminalInputHandler {
|
||||
view: Entity<Ashell>,
|
||||
element_bounds: Bounds<Pixels>,
|
||||
cell_width: f32,
|
||||
line_height: f32,
|
||||
}
|
||||
|
||||
impl InputHandler for TerminalInputHandler {
|
||||
@@ -219,7 +221,7 @@ impl InputHandler for TerminalInputHandler {
|
||||
) -> Option<Bounds<Pixels>> {
|
||||
self.view
|
||||
.read(cx)
|
||||
.terminal_ime_bounds_for_range(range_utf16, self.element_bounds)
|
||||
.terminal_ime_bounds_for_range(range_utf16, self.element_bounds, self.cell_width, self.line_height)
|
||||
}
|
||||
|
||||
fn character_index_for_point(
|
||||
@@ -492,6 +494,8 @@ impl Element for TerminalElement {
|
||||
TerminalInputHandler {
|
||||
view: self.view.clone(),
|
||||
element_bounds: prepaint.bounds,
|
||||
cell_width: prepaint.metrics.cell_width.as_f32(),
|
||||
line_height: prepaint.metrics.line_height.as_f32(),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user