Compare commits

...
14 Commits
Author SHA1 Message Date
TomZz 7504bf6448 feat: persist window bounds and layout sizes across restarts 2026-06-11 17:27:37 +08:00
TomZz 09ecd67ca6 feat: localize sftp status messages 2026-06-11 17:02:46 +08:00
TomZz 4c5148fccf ci: auto-generate release notes with commits since last release
- Enabled `generate_release_notes: true` in the release workflow. This will automatically include the changelog (commits and PRs) between the current and previous release tags on GitHub.
2026-06-11 02:36:11 +08:00
TomZz 108a9cf95b chore: fix compiler warnings and format code
- Added #[allow(dead_code)] to unused fields, variants, and functions in sftp.rs and terminal.rs
- Renamed unused window variable to _window in main.rs
- Ran cargo fmt to format the codebase
2026-06-11 02:22:49 +08:00
TomZz 9885b2573a fix: persist manual theme mode selection
- Added `theme_mode` field to config to save the manually selected theme mode (Light/Dark).
- The application now correctly remembers and restores the user's manual selection across restarts instead of always defaulting to Light when `follow_system_theme` is false.
2026-06-11 02:19:46 +08:00
TomZz cdf4cc3d53 fix: app fails to exit on windows/linux when last window is closed
- Set QuitMode::Explicit only on macOS to allow the app to stay alive without windows.
- Use the default QuitMode (CloseAll) on Windows and Linux so the application correctly exits when the main window is closed.
2026-06-11 02:15:19 +08:00
TomZz 5b381045f9 feat: generate application icons and integrate into binaries
- Added generated ashell.png, ashell.icns, and ashell.ico to assets/icons/
- Configured gpui WindowOptions to embed and display the icon for Linux
- Added winres build dependency and build.rs to embed Windows .ico into the executable
- Updated package-macos-app.sh and release.yml to bundle macOS .icns into the .app bundle
- Removed manual copying of icons into release zip/tarballs since they are now embedded
2026-06-11 02:12:00 +08:00
TomZz f246c952a2 docs: update README for Linux support and refactor i18n usage
- Updated README.md and README.en.md to include Linux download instructions
- Refactored rust_i18n macro usage by importing t directly (use rust_i18n::t)
2026-06-09 21:44:50 +08:00
TomZz 2276eef191 feat: complete internationalization and ui adjustments
- Add full i18n support for UI components including settings and sidebar
- Add connection progress tracking strings (Connecting, Failed, etc.)
- Fix 'No session' display issue when no SSH/Local tab is active
- Align SFTP 'show hidden files' checkbox text size with adjacent buttons
- Add border to language settings dropdown for better visibility
2026-06-09 19:36:18 +08:00
TomZz bb39626173 feat: add sftp multi-selection, batch download, and drag-to-upload support 2026-06-09 18:44:25 +08:00
TomZz 2cb8ab50aa style: optimize initial window size and positioning on scaled displays 2026-06-09 18:16:11 +08:00
TomZz 785670c428 feat: sync sftp directory with terminal directory 2026-06-09 15:21:36 +08:00
TomZz d33babe552 docs: add Mac installation guide regarding ad-hoc signing 2026-06-09 13:18:51 +08:00
TomZz 985f88fb1a ci: use macos-14 runner for Intel macOS to avoid queue delays 2026-06-09 13:07:40 +08:00
20 changed files with 1238 additions and 225 deletions
+5 -1
View File
@@ -34,7 +34,7 @@ jobs:
target: aarch64-apple-darwin target: aarch64-apple-darwin
name: macos-aarch64 name: macos-aarch64
bin: ashell bin: ashell
- os: macos-13 # Intel - os: macos-14 # Intel (cross-compiled from Apple Silicon to avoid queue delays)
target: x86_64-apple-darwin target: x86_64-apple-darwin
name: macos-x86_64 name: macos-x86_64
bin: ashell bin: ashell
@@ -86,6 +86,7 @@ jobs:
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources" mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
cp "target/${{ matrix.target }}/release/ashell" "$CONTENTS/MacOS/" cp "target/${{ matrix.target }}/release/ashell" "$CONTENTS/MacOS/"
cp "assets/icons/ashell.icns" "$CONTENTS/Resources/ashell.icns"
VERSION_NUM="${VERSION#v}" VERSION_NUM="${VERSION#v}"
cat > "$CONTENTS/Info.plist" <<EOF cat > "$CONTENTS/Info.plist" <<EOF
@@ -98,6 +99,8 @@ jobs:
<string>en</string> <string>en</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>ashell</string> <string>ashell</string>
<key>CFBundleIconFile</key>
<string>ashell.icns</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>dev.ashell.app</string> <string>dev.ashell.app</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
@@ -153,4 +156,5 @@ jobs:
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
files: ${{ env.ASSET }} files: ${{ env.ASSET }}
generate_release_notes: true
fail_on_unmatched_files: true fail_on_unmatched_files: true
Generated
+95 -19
View File
@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 4
[[package]] [[package]]
name = "accesskit" name = "accesskit"
@@ -296,15 +296,18 @@ dependencies = [
"gpui-component", "gpui-component",
"gpui-component-assets", "gpui-component-assets",
"gpui_platform", "gpui_platform",
"image",
"menu", "menu",
"portable-pty", "portable-pty",
"rfd", "rfd",
"russh", "russh",
"russh-keys", "russh-keys",
"russh-sftp", "russh-sftp",
"rust-i18n 3.1.5",
"serde", "serde",
"serde_json", "serde_json",
"ssh-key", "ssh-key",
"sys-locale",
"sysinfo 0.33.1", "sysinfo 0.33.1",
"tar", "tar",
"thiserror 1.0.69", "thiserror 1.0.69",
@@ -313,6 +316,7 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
"uuid", "uuid",
"walkdir", "walkdir",
"winres",
"zip", "zip",
] ]
@@ -753,7 +757,7 @@ dependencies = [
"bitflags 2.12.1", "bitflags 2.12.1",
"cexpr", "cexpr",
"clang-sys", "clang-sys",
"itertools 0.11.0", "itertools 0.13.0",
"log", "log",
"prettyplease", "prettyplease",
"proc-macro2", "proc-macro2",
@@ -1682,7 +1686,7 @@ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users 0.5.2", "redox_users 0.5.2",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -1942,7 +1946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -2563,7 +2567,7 @@ dependencies = [
"log", "log",
"presser", "presser",
"thiserror 2.0.18", "thiserror 2.0.18",
"windows 0.58.0", "windows 0.62.2",
] ]
[[package]] [[package]]
@@ -2697,7 +2701,7 @@ dependencies = [
"paste", "paste",
"regex", "regex",
"ropey", "ropey",
"rust-i18n", "rust-i18n 4.1.0",
"schemars", "schemars",
"serde", "serde",
"serde_json", "serde_json",
@@ -3258,7 +3262,7 @@ dependencies = [
"js-sys", "js-sys",
"log", "log",
"wasm-bindgen", "wasm-bindgen",
"windows-core 0.58.0", "windows-core 0.62.2",
] ]
[[package]] [[package]]
@@ -4122,7 +4126,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
dependencies = [ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -4308,7 +4312,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -5984,6 +5988,20 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "rust-i18n"
version = "3.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fda2551fdfaf6cc5ee283adc15e157047b92ae6535cf80f6d4962d05717dc332"
dependencies = [
"globwalk",
"once_cell",
"regex",
"rust-i18n-macro 3.1.5",
"rust-i18n-support 3.1.5",
"smallvec",
]
[[package]] [[package]]
name = "rust-i18n" name = "rust-i18n"
version = "4.1.0" version = "4.1.0"
@@ -5992,11 +6010,28 @@ checksum = "55691a65892c33ee2de49c15ea5600c6f4a70e8eeb8e6c3cd96d2a231d230c40"
dependencies = [ dependencies = [
"globwalk", "globwalk",
"regex", "regex",
"rust-i18n-macro", "rust-i18n-macro 4.1.0",
"rust-i18n-support", "rust-i18n-support 4.1.0",
"smallvec", "smallvec",
] ]
[[package]]
name = "rust-i18n-macro"
version = "3.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22baf7d7f56656d23ebe24f6bb57a5d40d2bce2a5f1c503e692b5b2fa450f965"
dependencies = [
"glob",
"once_cell",
"proc-macro2",
"quote",
"rust-i18n-support 3.1.5",
"serde",
"serde_json",
"serde_yaml",
"syn",
]
[[package]] [[package]]
name = "rust-i18n-macro" name = "rust-i18n-macro"
version = "4.1.0" version = "4.1.0"
@@ -6006,13 +6041,36 @@ dependencies = [
"glob", "glob",
"proc-macro2", "proc-macro2",
"quote", "quote",
"rust-i18n-support", "rust-i18n-support 4.1.0",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"syn", "syn",
] ]
[[package]]
name = "rust-i18n-support"
version = "3.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940ed4f52bba4c0152056d771e563b7133ad9607d4384af016a134b58d758f19"
dependencies = [
"arc-swap",
"base62",
"globwalk",
"itertools 0.11.0",
"lazy_static",
"normpath",
"once_cell",
"proc-macro2",
"regex",
"serde",
"serde_json",
"serde_yaml",
"siphasher",
"toml 0.8.23",
"triomphe",
]
[[package]] [[package]]
name = "rust-i18n-support" name = "rust-i18n-support"
version = "4.1.0" version = "4.1.0"
@@ -6069,7 +6127,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.4.15", "linux-raw-sys 0.4.15",
"windows-sys 0.52.0", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -6082,7 +6140,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.12.1", "linux-raw-sys 0.12.1",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -6737,7 +6795,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -6845,7 +6903,7 @@ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"psm", "psm",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -7205,7 +7263,7 @@ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.2",
"once_cell", "once_cell",
"rustix 1.1.4", "rustix 1.1.4",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -7425,6 +7483,15 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "toml"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "toml" name = "toml"
version = "0.8.23" version = "0.8.23"
@@ -7682,7 +7749,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [ dependencies = [
"memoffset 0.9.1", "memoffset 0.9.1",
"tempfile", "tempfile",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -8442,7 +8509,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -9079,6 +9146,15 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "winres"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c"
dependencies = [
"toml 0.5.11",
]
[[package]] [[package]]
name = "winsafe" name = "winsafe"
version = "0.0.19" version = "0.0.19"
+6
View File
@@ -24,6 +24,8 @@ rfd = "0.15"
russh = "0.49" russh = "0.49"
russh-keys = "0.49" russh-keys = "0.49"
russh-sftp = "2" russh-sftp = "2"
rust-i18n = "3"
sys-locale = "0.3"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
ssh-key = "0.6" ssh-key = "0.6"
@@ -36,9 +38,13 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
walkdir = "2" walkdir = "2"
zip = { version = "2", default-features = false, features = ["deflate"] } zip = { version = "2", default-features = false, features = ["deflate"] }
image = { version = "0.25.9", default-features = false, features = ["png"] }
[profile.release] [profile.release]
opt-level = 3 opt-level = 3
lto = "thin" lto = "thin"
codegen-units = 1 codegen-units = 1
strip = true strip = true
[build-dependencies]
winres = "0.1.12"
+10 -1
View File
@@ -10,7 +10,16 @@ This project focuses on providing a high-performance and visually appealing shel
## Download ## Download
You can download the latest pre-compiled releases for macOS and Windows from the [GitHub Releases page](https://github.com/rust-kotlin/ashell/releases/latest). 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).
## Mac Installation Guide
After downloading, please unzip the file and move `ashell.app` to your **Applications** folder.
Since the app uses ad-hoc signing, macOS may warn that the app is "damaged" upon first launch. If this happens, open Terminal and run the following command:
```bash
sudo xattr -cr /Applications/ashell.app
```
## Features ## Features
+10 -1
View File
@@ -10,7 +10,16 @@
## 下载 ## 下载
您可以从 [GitHub Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载 macOS 和 Windows 版本的最新预编译程序。 您可以从 [GitHub Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载 macOS、Windows 和 Linux 版本的最新预编译程序。
## Mac 安装指南
下载并解压后,请先将 `ashell.app` 拖入或移动到 **应用程序 (Applications)** 目录。
由于应用采用本地签名,初次启动时如果系统提示“App 已损坏,无法打开”,请打开终端(Terminal)并执行以下命令:
```bash
sudo xattr -cr /Applications/ashell.app
```
## 功能特性 ## 功能特性
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

+8
View File
@@ -0,0 +1,8 @@
fn main() {
#[cfg(windows)]
{
let mut res = winres::WindowsResource::new();
res.set_icon("assets/icons/ashell.ico");
res.compile().unwrap();
}
}
+76
View File
@@ -0,0 +1,76 @@
remote_files: "REMOTE FILES"
open_ssh_tab_sftp: "Open an SSH tab to browse SFTP files."
sftp_connecting: "sftp connecting..."
refresh: "Refresh"
upload_file: "Upload File"
upload_folder: "Upload Folder"
hidden: "Hidden"
download: "Download"
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"
local_terminal: "Local Terminal"
open_local_shell_tab: "Open a new local shell tab"
connect: "Connect"
edit: "Edit"
delete: "Delete"
host: "Host"
port: "Port"
user: "User"
password: "Password"
key: "Key"
private_key_path: "Private Key Path"
private_key_data: "Private Key Data"
save_and_connect: "Save & Connect"
save: "Save"
cancel: "Cancel"
host_and_user_required: "Host and user are required"
language: "Language"
follow_system: "Follow System"
light_theme: "Light Theme"
dark_theme: "Dark Theme"
use_light_mode: "Use Light Mode"
use_dark_mode: "Use Dark Mode"
english: "English"
chinese: "简体中文"
idle_no_session: "idle / No session"
system: "SYSTEM"
cpu: "CPU"
mem: "MEM"
swap: "SWAP"
net: "NET"
disk: "DISK"
global_load: "global load"
live: "Live"
idle: "idle"
saved: "SAVED"
add_ssh: "+ ssh"
open_local_or_ssh: "Open a local terminal or pick an SSH session."
open_session: "Open Session"
settings: "Settings"
terminal_font_size: "Terminal Font Size"
theme_management_hint: "Theme mode and light/dark theme families are managed from the top-bar theme button."
local_host: "local host"
local_terminal_opened: "local terminal opened"
connecting: "Connecting"
starting_connection: "starting connection..."
connection_failed: "Connection Failed"
preview_failed: "Preview failed: %{err}"
downloading_file: "Downloading %{base}..."
download_failed: "Download failed: %{err}"
uploading: "Uploading..."
upload_failed: "Upload failed: %{err}"
downloaded_folder: "Downloaded folder to %{path}"
downloaded_file: "Downloaded file to %{path}"
uploaded_file: "Uploaded file"
uploaded_folder: "Uploaded folder"
uploaded_n_files: "Uploaded %{files} files"
uploaded_n_folders: "Uploaded %{folders} folders"
uploaded_files_and_folders: "Uploaded %{files} files and %{folders} folders"
reset: "Reset"
reset_layout: "Reset Default Layout"
reset_layout_success: "Layout reset. Window size takes effect on restart."
+76
View File
@@ -0,0 +1,76 @@
remote_files: "远程文件"
open_ssh_tab_sftp: "打开一个 SSH 会话面板以浏览 SFTP 文件。"
sftp_connecting: "正在连接 SFTP..."
refresh: "刷新"
upload_file: "上传文件"
upload_folder: "上传文件夹"
hidden: "显示隐藏文件"
download: "下载"
download_count: "下载 (%{count})"
name: "名称"
size: "大小"
modified: "修改时间"
new_ssh_connection: "新建 SSH 连接"
create_or_edit_ssh_session: "创建或编辑一个 SSH 会话"
local_terminal: "本地终端"
open_local_shell_tab: "打开一个新的本地终端页面"
connect: "连接"
edit: "编辑"
delete: "删除"
host: "主机 (Host)"
port: "端口 (Port)"
user: "用户 (User)"
password: "密码 (Password)"
key: "密钥 (Key)"
private_key_path: "私钥路径"
private_key_data: "私钥内容"
save_and_connect: "保存并连接"
save: "保存"
cancel: "取消"
host_and_user_required: "主机名和用户名不能为空"
language: "显示语言"
follow_system: "跟随系统"
light_theme: "浅色主题"
dark_theme: "深色主题"
use_light_mode: "使用浅色模式"
use_dark_mode: "使用深色模式"
english: "English"
chinese: "简体中文"
idle_no_session: "空闲 / 未连接会话"
system: "系统"
cpu: "CPU"
mem: "内存"
swap: "虚拟内存"
net: "网络"
disk: "磁盘"
global_load: "全局负载"
live: "实时"
idle: "空闲"
saved: "已保存会话"
add_ssh: "+ SSH"
open_local_or_ssh: "打开本地终端或选择一个 SSH 会话。"
open_session: "打开会话"
settings: "设置"
terminal_font_size: "终端字体大小"
theme_management_hint: "主题模式和亮/暗主题系列可通过顶栏的主题按钮管理。"
local_host: "本地主机"
local_terminal_opened: "已打开本地终端"
connecting: "正在连接"
starting_connection: "开始连接..."
connection_failed: "连接失败"
preview_failed: "预览失败: %{err}"
downloading_file: "正在下载 %{base}..."
download_failed: "下载失败: %{err}"
uploading: "正在上传..."
upload_failed: "上传失败: %{err}"
downloaded_folder: "已下载文件夹至 %{path}"
downloaded_file: "已下载文件至 %{path}"
uploaded_file: "已上传文件"
uploaded_folder: "已上传文件夹"
uploaded_n_files: "已上传 %{files} 个文件"
uploaded_n_folders: "已上传 %{folders} 个文件夹"
uploaded_files_and_folders: "已上传 %{files} 个文件及 %{folders} 个文件夹"
reset: "重置"
reset_layout: "恢复默认布局"
reset_layout_success: "布局占比已恢复默认。窗口大小将在下次启动时生效。"
+4
View File
@@ -17,6 +17,8 @@ rm -rf "$APP_DIR"
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR" mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
cp "$ROOT_DIR/target/release/$APP_NAME" "$MACOS_DIR/$APP_NAME" cp "$ROOT_DIR/target/release/$APP_NAME" "$MACOS_DIR/$APP_NAME"
cp "$ROOT_DIR/assets/icons/ashell.icns" "$RESOURCES_DIR/ashell.icns"
cat > "$CONTENTS_DIR/Info.plist" <<EOF cat > "$CONTENTS_DIR/Info.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
@@ -27,6 +29,8 @@ cat > "$CONTENTS_DIR/Info.plist" <<EOF
<string>en</string> <string>en</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$APP_NAME</string> <string>$APP_NAME</string>
<key>CFBundleIconFile</key>
<string>ashell.icns</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>$BUNDLE_ID</string> <string>$BUNDLE_ID</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
+80 -1
View File
@@ -70,18 +70,55 @@ impl Session {
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum SavedWindowBounds {
Fullscreen {
x: f32,
y: f32,
width: f32,
height: f32,
},
Maximized {
x: f32,
y: f32,
width: f32,
height: f32,
},
Windowed {
x: f32,
y: f32,
width: f32,
height: f32,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConfigFile { pub struct ConfigFile {
#[serde(default)] #[serde(default)]
pub follow_system_theme: bool, pub follow_system_theme: bool,
#[serde(default)] #[serde(default)]
pub theme_mode: String,
#[serde(default)]
pub light_theme_name: String, pub light_theme_name: String,
#[serde(default)] #[serde(default)]
pub dark_theme_name: String, pub dark_theme_name: String,
#[serde(default = "default_locale")]
pub locale: String,
#[serde(default = "default_terminal_font_size")] #[serde(default = "default_terminal_font_size")]
pub terminal_font_size: f32, pub terminal_font_size: f32,
#[serde(default)] #[serde(default)]
pub sessions: Vec<Session>, pub sessions: Vec<Session>,
#[serde(default)]
pub window_bounds: Option<SavedWindowBounds>,
#[serde(default)]
pub workspace_panels: Option<Vec<f32>>,
#[serde(default)]
pub body_panels: Option<Vec<f32>>,
}
fn default_locale() -> String {
"system".to_string()
} }
fn default_terminal_font_size() -> f32 { fn default_terminal_font_size() -> f32 {
@@ -136,6 +173,10 @@ impl ConfigStore {
self.cache.follow_system_theme self.cache.follow_system_theme
} }
pub fn theme_mode(&self) -> &str {
&self.cache.theme_mode
}
pub fn light_theme_name(&self) -> &str { pub fn light_theme_name(&self) -> &str {
&self.cache.light_theme_name &self.cache.light_theme_name
} }
@@ -144,6 +185,18 @@ impl ConfigStore {
&self.cache.dark_theme_name &self.cache.dark_theme_name
} }
pub fn locale(&self) -> &str {
if self.cache.locale.is_empty() {
"system"
} else {
&self.cache.locale
}
}
pub fn set_locale(&mut self, locale: &str) {
self.cache.locale = locale.to_string();
}
pub fn terminal_font_size(&self) -> f32 { pub fn terminal_font_size(&self) -> f32 {
if self.cache.terminal_font_size <= 0.0 { if self.cache.terminal_font_size <= 0.0 {
default_terminal_font_size() default_terminal_font_size()
@@ -155,14 +208,39 @@ impl ConfigStore {
pub fn set_theme_preferences( pub fn set_theme_preferences(
&mut self, &mut self,
follow_system_theme: bool, follow_system_theme: bool,
theme_mode: impl Into<String>,
light_theme_name: impl Into<String>, light_theme_name: impl Into<String>,
dark_theme_name: impl Into<String>, dark_theme_name: impl Into<String>,
) { ) {
self.cache.follow_system_theme = follow_system_theme; self.cache.follow_system_theme = follow_system_theme;
self.cache.theme_mode = theme_mode.into();
self.cache.light_theme_name = light_theme_name.into(); self.cache.light_theme_name = light_theme_name.into();
self.cache.dark_theme_name = dark_theme_name.into(); self.cache.dark_theme_name = dark_theme_name.into();
} }
pub fn window_bounds(&self) -> Option<&SavedWindowBounds> {
self.cache.window_bounds.as_ref()
}
pub fn workspace_panels(&self) -> Option<&Vec<f32>> {
self.cache.workspace_panels.as_ref()
}
pub fn body_panels(&self) -> Option<&Vec<f32>> {
self.cache.body_panels.as_ref()
}
pub fn set_layout_state(
&mut self,
window_bounds: Option<SavedWindowBounds>,
workspace_panels: Option<Vec<f32>>,
body_panels: Option<Vec<f32>>,
) {
self.cache.window_bounds = window_bounds;
self.cache.workspace_panels = workspace_panels;
self.cache.body_panels = body_panels;
}
pub fn set_terminal_font_size(&mut self, terminal_font_size: f32) { pub fn set_terminal_font_size(&mut self, terminal_font_size: f32) {
self.cache.terminal_font_size = terminal_font_size.max(10.0); self.cache.terminal_font_size = terminal_font_size.max(10.0);
} }
@@ -188,6 +266,7 @@ impl ConfigStore {
return Ok(()); return Ok(());
} }
let raw = serde_json::to_string_pretty(&self.cache)?; let raw = serde_json::to_string_pretty(&self.cache)?;
fs::write(&self.path, raw).with_context(|| format!("failed to write {}", self.path.display())) fs::write(&self.path, raw)
.with_context(|| format!("failed to write {}", self.path.display()))
} }
} }
+5 -2
View File
@@ -5,7 +5,7 @@ use std::{
}; };
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use portable_pty::{native_pty_system, CommandBuilder, PtySize}; use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use crate::terminal::{BackendCommand, BackendEvent, BackendTx}; use crate::terminal::{BackendCommand, BackendEvent, BackendTx};
@@ -34,7 +34,10 @@ pub fn spawn_local_terminal(
}); });
let mut cmd = CommandBuilder::new(&shell); let mut cmd = CommandBuilder::new(&shell);
cmd.env("TERM", std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".into())); cmd.env(
"TERM",
std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".into()),
);
cmd.env( cmd.env(
"COLORTERM", "COLORTERM",
std::env::var("COLORTERM").unwrap_or_else(|_| "truecolor".into()), std::env::var("COLORTERM").unwrap_or_else(|_| "truecolor".into()),
+533 -83
View File
File diff suppressed because it is too large Load Diff
+137 -44
View File
@@ -4,15 +4,15 @@ use std::{
sync::Arc, sync::Arc,
}; };
use anyhow::{anyhow, Context, Result}; use anyhow::{Context, Result, anyhow};
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, TimeZone, Utc}; use chrono::{DateTime, TimeZone, Utc};
use directories::BaseDirs; use directories::BaseDirs;
use flate2::read::GzDecoder; use flate2::read::GzDecoder;
use russh::{ use russh::{
client::{self, Handler},
keys::{decode_secret_key, key::PrivateKeyWithHashAlg, load_secret_key, HashAlg, PrivateKey},
Disconnect, Disconnect,
client::{self, Handler},
keys::{HashAlg, PrivateKey, decode_secret_key, key::PrivateKeyWithHashAlg, load_secret_key},
}; };
use russh_sftp::client::SftpSession; use russh_sftp::client::SftpSession;
use tokio::{ use tokio::{
@@ -24,6 +24,8 @@ use uuid::Uuid;
use walkdir::WalkDir; use walkdir::WalkDir;
use zip::read::ZipArchive; use zip::read::ZipArchive;
use rust_i18n::t;
use crate::{ use crate::{
config::{AuthMethod, Session}, config::{AuthMethod, Session},
terminal::BackendEvent, terminal::BackendEvent,
@@ -38,6 +40,7 @@ pub struct RemoteEntry {
pub modified: u32, pub modified: u32,
} }
#[allow(dead_code)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PreviewData { pub struct PreviewData {
pub path: String, pub path: String,
@@ -47,16 +50,23 @@ pub struct PreviewData {
} }
#[derive(Debug)] #[derive(Debug)]
enum SftpCommand { pub enum SftpCommand {
ListDir(String), ListDir(String),
#[allow(dead_code)]
Preview(String), Preview(String),
Download { remote: String, local_dir: String }, Download {
UploadPaths { locals: Vec<String>, remote_dir: String }, remote: String,
local_dir: String,
},
UploadPaths {
locals: Vec<String>,
remote_dir: String,
},
Close, Close,
} }
pub struct SftpHandle { pub struct SftpHandle {
commands: UnboundedSender<SftpCommand>, pub commands: UnboundedSender<SftpCommand>,
#[allow(dead_code)] #[allow(dead_code)]
join: Option<JoinHandle<()>>, join: Option<JoinHandle<()>>,
} }
@@ -75,16 +85,21 @@ impl SftpHandle {
let _ = self.commands.send(SftpCommand::ListDir(path)); let _ = self.commands.send(SftpCommand::ListDir(path));
} }
#[allow(dead_code)]
pub fn preview(&self, path: String) { pub fn preview(&self, path: String) {
let _ = self.commands.send(SftpCommand::Preview(path)); let _ = self.commands.send(SftpCommand::Preview(path));
} }
pub fn download(&self, remote: String, local_dir: String) { pub fn download(&self, remote: String, local_dir: String) {
let _ = self.commands.send(SftpCommand::Download { remote, local_dir }); let _ = self
.commands
.send(SftpCommand::Download { remote, local_dir });
} }
pub fn upload_paths(&self, locals: Vec<String>, remote_dir: String) { pub fn upload_paths(&self, locals: Vec<String>, remote_dir: String) {
let _ = self.commands.send(SftpCommand::UploadPaths { locals, remote_dir }); let _ = self
.commands
.send(SftpCommand::UploadPaths { locals, remote_dir });
} }
pub fn close(&self) { pub fn close(&self) {
@@ -137,14 +152,25 @@ async fn run_sftp(
.await .await
.context("sftp handshake")?; .context("sftp handshake")?;
let home = sftp.canonicalize(".").await.unwrap_or_else(|_| "/".to_string()); let home = sftp
.canonicalize(".")
.await
.unwrap_or_else(|_| "/".to_string());
emit_entries(&events, &tab_id, &sftp, &home).await?; emit_entries(&events, &tab_id, &sftp, &home).await?;
while let Some(command) = commands.recv().await { while let Some(command) = commands.recv().await {
match command { match command {
SftpCommand::Close => break, SftpCommand::Close => break,
SftpCommand::ListDir(path) => { SftpCommand::ListDir(path) => {
if let Err(err) = emit_entries(&events, &tab_id, &sftp, &path).await { let actual_path = if path == "~" {
home.clone()
} else if let Some(rest) = path.strip_prefix("~/") {
crate::sftp::join_remote(&home, rest)
} else {
path
};
if let Err(err) = emit_entries(&events, &tab_id, &sftp, &actual_path).await {
let _ = events.send(BackendEvent::SftpStatus { let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(), tab_id: tab_id.clone(),
text: format!("list failed: {err:#}"), text: format!("list failed: {err:#}"),
@@ -161,7 +187,7 @@ async fn run_sftp(
Err(err) => { Err(err) => {
let _ = events.send(BackendEvent::SftpStatus { let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(), tab_id: tab_id.clone(),
text: format!("preview failed: {err:#}"), text: t!("preview_failed", err = format!("{err:#}")).into(),
}); });
} }
}, },
@@ -169,19 +195,19 @@ async fn run_sftp(
let base = base_name(&remote); let base = base_name(&remote);
let _ = events.send(BackendEvent::SftpStatus { let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(), tab_id: tab_id.clone(),
text: format!("downloading {base}..."), text: t!("downloading_file", base = base).into(),
}); });
match download_path_impl(&handle, &sftp, &remote, Path::new(&local_dir)).await { match download_path_impl(&handle, &sftp, &remote, Path::new(&local_dir)).await {
Ok(summary) => { Ok(summary) => {
let _ = events.send(BackendEvent::SftpStatus { let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(), tab_id: tab_id.clone(),
text: summary, text: summary.into(),
}); });
} }
Err(err) => { Err(err) => {
let _ = events.send(BackendEvent::SftpStatus { let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(), tab_id: tab_id.clone(),
text: format!("download failed: {err:#}"), text: t!("download_failed", err = format!("{err:#}")).into(),
}); });
} }
} }
@@ -189,20 +215,20 @@ async fn run_sftp(
SftpCommand::UploadPaths { locals, remote_dir } => { SftpCommand::UploadPaths { locals, remote_dir } => {
let _ = events.send(BackendEvent::SftpStatus { let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(), tab_id: tab_id.clone(),
text: "uploading...".into(), text: t!("uploading").into(),
}); });
match upload_paths_impl(&sftp, &locals, &remote_dir).await { match upload_paths_impl(&sftp, &locals, &remote_dir).await {
Ok(summary) => { Ok(summary) => {
let _ = events.send(BackendEvent::SftpStatus { let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(), tab_id: tab_id.clone(),
text: summary, text: summary.into(),
}); });
let _ = emit_entries(&events, &tab_id, &sftp, &remote_dir).await; let _ = emit_entries(&events, &tab_id, &sftp, &remote_dir).await;
} }
Err(err) => { Err(err) => {
let _ = events.send(BackendEvent::SftpStatus { let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(), tab_id: tab_id.clone(),
text: format!("upload failed: {err:#}"), text: t!("upload_failed", err = format!("{err:#}")).into(),
}); });
} }
} }
@@ -210,7 +236,9 @@ async fn run_sftp(
} }
} }
let _ = handle.disconnect(Disconnect::ByApplication, "bye", "").await; let _ = handle
.disconnect(Disconnect::ByApplication, "bye", "")
.await;
Ok(()) Ok(())
} }
@@ -233,7 +261,9 @@ async fn emit_entries(
Ok(()) Ok(())
} }
async fn connect_and_authenticate(session: &Session) -> Result<russh::client::Handle<SftpClientHandler>> { async fn connect_and_authenticate(
session: &Session,
) -> Result<russh::client::Handle<SftpClientHandler>> {
let config = Arc::new(client::Config { let config = Arc::new(client::Config {
inactivity_timeout: Some(std::time::Duration::from_secs(600)), inactivity_timeout: Some(std::time::Duration::from_secs(600)),
..Default::default() ..Default::default()
@@ -360,6 +390,7 @@ fn join_remote(parent: &str, child: &str) -> String {
} }
} }
#[allow(dead_code)]
fn strip_archive_suffix(name: &str) -> &str { fn strip_archive_suffix(name: &str) -> &str {
for suffix in [".tar.gz", ".tgz", ".zip", ".tar"] { for suffix in [".tar.gz", ".tgz", ".zip", ".tar"] {
if let Some(stripped) = name.strip_suffix(suffix) { if let Some(stripped) = name.strip_suffix(suffix) {
@@ -429,7 +460,10 @@ async fn list_dir_impl(sftp: &SftpSession, path: &str) -> Result<Vec<RemoteEntry
} }
async fn preview_impl(sftp: &SftpSession, path: &str) -> Result<PreviewData> { async fn preview_impl(sftp: &SftpSession, path: &str) -> Result<PreviewData> {
let metadata = sftp.metadata(path).await.with_context(|| format!("metadata {path}"))?; let metadata = sftp
.metadata(path)
.await
.with_context(|| format!("metadata {path}"))?;
let is_dir = metadata let is_dir = metadata
.permissions .permissions
.map(|mode| (mode & 0o170_000) == 0o040_000) .map(|mode| (mode & 0o170_000) == 0o040_000)
@@ -450,9 +484,15 @@ async fn preview_impl(sftp: &SftpSession, path: &str) -> Result<PreviewData> {
}); });
} }
let mut remote_file = sftp.open(path).await.with_context(|| format!("open remote {path}"))?; let mut remote_file = sftp
.open(path)
.await
.with_context(|| format!("open remote {path}"))?;
let mut buffer = vec![0u8; 128 * 1024]; let mut buffer = vec![0u8; 128 * 1024];
let read = remote_file.read(&mut buffer).await.context("read preview bytes")?; let read = remote_file
.read(&mut buffer)
.await
.context("read preview bytes")?;
buffer.truncate(read); buffer.truncate(read);
let nul_ratio = if buffer.is_empty() { let nul_ratio = if buffer.is_empty() {
@@ -488,7 +528,10 @@ async fn download_path_impl(
.await .await
.with_context(|| format!("create {}", local_dir.display()))?; .with_context(|| format!("create {}", local_dir.display()))?;
let metadata = sftp.metadata(remote).await.with_context(|| format!("metadata {remote}"))?; let metadata = sftp
.metadata(remote)
.await
.with_context(|| format!("metadata {remote}"))?;
let is_dir = metadata let is_dir = metadata
.permissions .permissions
.map(|mode| (mode & 0o170_000) == 0o040_000) .map(|mode| (mode & 0o170_000) == 0o040_000)
@@ -500,16 +543,22 @@ async fn download_path_impl(
base_name(remote), base_name(remote),
Uuid::new_v4() Uuid::new_v4()
)); ));
let extracted_to = download_remote_directory_archive(handle, sftp, remote, &local_archive).await?; let extracted_to =
return Ok(format!("downloaded folder to {}", extracted_to.display())); download_remote_directory_archive(handle, sftp, remote, &local_archive).await?;
return Ok(t!("downloaded_folder", path = extracted_to.display()).to_string());
} }
let local_path = local_dir.join(base_name(remote)); let local_path = local_dir.join(base_name(remote));
download_file_impl(sftp, remote, &local_path).await?; download_file_impl(sftp, remote, &local_path).await?;
Ok(format!("downloaded file to {}", local_path.display())) Ok(t!("downloaded_file", path = local_path.display()).to_string())
} }
async fn download_dir_recursive(sftp: &SftpSession, remote_dir: &str, local_dir: &Path) -> Result<()> { #[allow(dead_code)]
async fn download_dir_recursive(
sftp: &SftpSession,
remote_dir: &str,
local_dir: &Path,
) -> Result<()> {
tokio::fs::create_dir_all(local_dir) tokio::fs::create_dir_all(local_dir)
.await .await
.with_context(|| format!("create {}", local_dir.display()))?; .with_context(|| format!("create {}", local_dir.display()))?;
@@ -532,7 +581,11 @@ async fn download_remote_directory_archive(
remote_dir: &str, remote_dir: &str,
local_archive: &Path, local_archive: &Path,
) -> Result<PathBuf> { ) -> Result<PathBuf> {
let remote_archive = format!("/tmp/ashell-{}-{}.tar.gz", base_name(remote_dir), Uuid::new_v4()); let remote_archive = format!(
"/tmp/ashell-{}-{}.tar.gz",
base_name(remote_dir),
Uuid::new_v4()
);
create_remote_archive(handle, remote_dir, &remote_archive).await?; create_remote_archive(handle, remote_dir, &remote_archive).await?;
let local_extract_root = local_archive let local_extract_root = local_archive
.parent() .parent()
@@ -541,7 +594,11 @@ async fn download_remote_directory_archive(
let archive_download = async { let archive_download = async {
download_file_impl(sftp, &remote_archive, local_archive).await?; download_file_impl(sftp, &remote_archive, local_archive).await?;
extract_archive_to(local_archive, local_archive.parent().unwrap_or_else(|| Path::new("."))).await?; extract_archive_to(
local_archive,
local_archive.parent().unwrap_or_else(|| Path::new(".")),
)
.await?;
tokio::fs::remove_file(local_archive) tokio::fs::remove_file(local_archive)
.await .await
.with_context(|| format!("remove {}", local_archive.display()))?; .with_context(|| format!("remove {}", local_archive.display()))?;
@@ -560,14 +617,20 @@ async fn download_remote_directory_archive(
} }
async fn download_file_impl(sftp: &SftpSession, remote: &str, local: &Path) -> Result<()> { 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 remote_file = sftp
.open(remote)
.await
.with_context(|| format!("open remote {remote}"))?;
let mut local_file = tokio::fs::File::create(local) let mut local_file = tokio::fs::File::create(local)
.await .await
.with_context(|| format!("create local {}", local.display()))?; .with_context(|| format!("create local {}", local.display()))?;
let mut buffer = vec![0u8; 64 * 1024]; let mut buffer = vec![0u8; 64 * 1024];
loop { loop {
let read = remote_file.read(&mut buffer).await.context("read remote file")?; let read = remote_file
.read(&mut buffer)
.await
.context("read remote file")?;
if read == 0 { if read == 0 {
break; break;
} }
@@ -580,7 +643,11 @@ async fn download_file_impl(sftp: &SftpSession, remote: &str, local: &Path) -> R
Ok(()) Ok(())
} }
async fn upload_paths_impl(sftp: &SftpSession, locals: &[String], remote_dir: &str) -> Result<String> { async fn upload_paths_impl(
sftp: &SftpSession,
locals: &[String],
remote_dir: &str,
) -> Result<String> {
create_remote_dir_all(sftp, remote_dir).await?; create_remote_dir_all(sftp, remote_dir).await?;
let mut file_count = 0usize; let mut file_count = 0usize;
let mut folder_count = 0usize; let mut folder_count = 0usize;
@@ -595,16 +662,25 @@ async fn upload_paths_impl(sftp: &SftpSession, locals: &[String], remote_dir: &s
} }
} }
let summary = match (file_count, folder_count) { let summary = match (file_count, folder_count) {
(1, 0) => "uploaded file".to_string(), (1, 0) => t!("uploaded_file").to_string(),
(0, 1) => "uploaded folder".to_string(), (0, 1) => t!("uploaded_folder").to_string(),
(files, 0) => format!("uploaded {files} files"), (files, 0) => t!("uploaded_n_files", files = files).to_string(),
(0, folders) => format!("uploaded {folders} folders"), (0, folders) => t!("uploaded_n_folders", folders = folders).to_string(),
(files, folders) => format!("uploaded {files} files and {folders} folders"), (files, folders) => t!(
"uploaded_files_and_folders",
files = files,
folders = folders
)
.to_string(),
}; };
Ok(summary) Ok(summary)
} }
async fn upload_directory_impl(sftp: &SftpSession, local_dir: &Path, remote_parent: &str) -> Result<()> { async fn upload_directory_impl(
sftp: &SftpSession,
local_dir: &Path,
remote_parent: &str,
) -> Result<()> {
let root_name = local_dir let root_name = local_dir
.file_name() .file_name()
.and_then(|name| name.to_str()) .and_then(|name| name.to_str())
@@ -643,7 +719,11 @@ async fn upload_directory_impl(sftp: &SftpSession, local_dir: &Path, remote_pare
Ok(()) Ok(())
} }
async fn upload_file_to_dir_impl(sftp: &SftpSession, local_file: &Path, remote_dir: &str) -> Result<()> { async fn upload_file_to_dir_impl(
sftp: &SftpSession,
local_file: &Path,
remote_dir: &str,
) -> Result<()> {
let file_name = local_file let file_name = local_file
.file_name() .file_name()
.and_then(|name| name.to_str()) .and_then(|name| name.to_str())
@@ -779,8 +859,13 @@ fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'")) format!("'{}'", value.replace('\'', "'\"'\"'"))
} }
#[allow(dead_code)]
async fn maybe_extract_archive(path: &Path) -> Result<Option<PathBuf>> { 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 { let Some(file_name) = path
.file_name()
.and_then(|name| name.to_str())
.map(|name| name.to_string())
else {
return Ok(None); return Ok(None);
}; };
let is_archive = [".zip", ".tar", ".tar.gz", ".tgz"] let is_archive = [".zip", ".tar", ".tar.gz", ".tgz"]
@@ -826,7 +911,9 @@ async fn maybe_extract_archive(path: &Path) -> Result<Option<PathBuf>> {
.with_context(|| format!("open {}", archive_path.display()))?; .with_context(|| format!("open {}", archive_path.display()))?;
let decoder = GzDecoder::new(file); let decoder = GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder); let mut archive = tar::Archive::new(decoder);
archive.unpack(&target_dir).context("unpack tar.gz archive")?; archive
.unpack(&target_dir)
.context("unpack tar.gz archive")?;
} else if file_name.ends_with(".tar") { } else if file_name.ends_with(".tar") {
let file = fs::File::open(&archive_path) let file = fs::File::open(&archive_path)
.with_context(|| format!("open {}", archive_path.display()))?; .with_context(|| format!("open {}", archive_path.display()))?;
@@ -843,7 +930,11 @@ async fn maybe_extract_archive(path: &Path) -> Result<Option<PathBuf>> {
} }
async fn extract_archive_to(path: &Path, target_dir: &Path) -> Result<()> { 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 { let Some(file_name) = path
.file_name()
.and_then(|name| name.to_str())
.map(|name| name.to_string())
else {
return Ok(()); return Ok(());
}; };
let archive_path = path.to_path_buf(); let archive_path = path.to_path_buf();
@@ -878,7 +969,9 @@ async fn extract_archive_to(path: &Path, target_dir: &Path) -> Result<()> {
.with_context(|| format!("open {}", archive_path.display()))?; .with_context(|| format!("open {}", archive_path.display()))?;
let decoder = GzDecoder::new(file); let decoder = GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder); let mut archive = tar::Archive::new(decoder);
archive.unpack(&target_dir).context("unpack tar.gz archive")?; archive
.unpack(&target_dir)
.context("unpack tar.gz archive")?;
} else if file_name.ends_with(".tar") { } else if file_name.ends_with(".tar") {
let file = fs::File::open(&archive_path) let file = fs::File::open(&archive_path)
.with_context(|| format!("open {}", archive_path.display()))?; .with_context(|| format!("open {}", archive_path.display()))?;
+4 -4
View File
@@ -3,19 +3,19 @@ use std::{
sync::Arc, sync::Arc,
}; };
use anyhow::{anyhow, Context, Result}; use anyhow::{Context, Result, anyhow};
use async_trait::async_trait; use async_trait::async_trait;
use directories::BaseDirs; use directories::BaseDirs;
use russh::{ use russh::{
client::{self, Handler},
keys::{decode_secret_key, key::PrivateKeyWithHashAlg, load_secret_key, HashAlg, PrivateKey},
ChannelMsg, Disconnect, ChannelMsg, Disconnect,
client::{self, Handler},
keys::{HashAlg, PrivateKey, decode_secret_key, key::PrivateKeyWithHashAlg, load_secret_key},
}; };
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::{ use crate::{
config::{AuthMethod, Session}, config::{AuthMethod, Session},
system::{remote_snapshot_from_kv, SystemSnapshot}, system::{SystemSnapshot, remote_snapshot_from_kv},
terminal::{BackendCommand, BackendEvent, BackendTx}, terminal::{BackendCommand, BackendEvent, BackendTx},
}; };
+11 -13
View File
@@ -1,6 +1,9 @@
use std::{collections::BTreeMap, time::{Duration, Instant}}; use std::{
collections::BTreeMap,
time::{Duration, Instant},
};
use anyhow::{anyhow, Result}; use anyhow::{Result, anyhow};
use sysinfo::{Disks, Networks, System}; use sysinfo::{Disks, Networks, System};
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
@@ -69,7 +72,10 @@ impl SystemSampler {
let rx_total: u64 = self.nets.iter().map(|(_, d)| d.total_received()).sum(); 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 tx_total: u64 = self.nets.iter().map(|(_, d)| d.total_transmitted()).sum();
let now = Instant::now(); let now = Instant::now();
let elapsed = now.duration_since(self.last_instant).as_secs_f64().max(0.001); 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 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; let tx_rate = (tx_total.saturating_sub(self.last_tx_total) as f64 / elapsed) as u64;
self.last_rx_total = rx_total; self.last_rx_total = rx_total;
@@ -92,16 +98,8 @@ impl SystemSampler {
cpu_percent, cpu_percent,
mem_percent: ratio(mem_used, mem_total), mem_percent: ratio(mem_used, mem_total),
swap_percent: ratio(swap_used, swap_total), swap_percent: ratio(swap_used, swap_total),
mem_detail: format!( mem_detail: format!("{}/{}", format_bytes(mem_used), format_bytes(mem_total)),
"{}/{}", swap_detail: format!("{}/{}", format_bytes(swap_used), format_bytes(swap_total)),
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_rx: format!("{}/s", format_bytes(rx_rate)),
net_tx: format!("{}/s", format_bytes(tx_rate)), net_tx: format!("{}/s", format_bytes(tx_rate)),
disks, disks,
+122 -29
View File
@@ -5,10 +5,7 @@ use alacritty_terminal::{
grid::{Dimensions, Scroll}, grid::{Dimensions, Scroll},
index::{Column, Point, Side}, index::{Column, Point, Side},
selection::{Selection, SelectionRange, SelectionType}, selection::{Selection, SelectionRange, SelectionType},
term::{ term::{Config, Term, TermMode, cell::Cell, point_to_viewport, viewport_to_point},
cell::Cell,
point_to_viewport, viewport_to_point, Config, Term, TermMode,
},
vte::ansi::{CursorShape, Processor}, vte::ansi::{CursorShape, Processor},
}; };
use gpui::Keystroke; use gpui::Keystroke;
@@ -32,15 +29,46 @@ pub enum BackendCommand {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum BackendEvent { pub enum BackendEvent {
Output { tab_id: String, bytes: Vec<u8> }, Output {
Status { tab_id: String, text: String }, tab_id: String,
Connected { tab_id: String }, bytes: Vec<u8>,
SftpEntries { tab_id: String, path: String, entries: Vec<RemoteEntry> }, },
SftpPreview { tab_id: String, preview: PreviewData }, Status {
SftpStatus { tab_id: String, text: String }, tab_id: String,
RemoteSystem { tab_id: String, snapshot: SystemSnapshot }, text: String,
RemoteSystemUnavailable { tab_id: String, reason: String }, },
Closed { tab_id: String, reason: 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,
},
TerminalTitleChanged {
tab_id: String,
title: String,
},
} }
#[derive(Clone)] #[derive(Clone)]
@@ -118,20 +146,42 @@ pub struct SftpUiState {
pub entries: Vec<RemoteEntry>, pub entries: Vec<RemoteEntry>,
pub selected_path: Option<String>, pub selected_path: Option<String>,
pub preview: Option<PreviewData>, pub preview: Option<PreviewData>,
pub selected_entries: std::collections::HashSet<String>,
} }
impl TerminalTab { impl TerminalTab {
pub fn new_local(id: String, title: String, backend: BackendTx) -> Self { pub fn new_local(
Self::new(id, title, TabKind::Local, "local shell".into(), backend) id: String,
title: String,
backend: BackendTx,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Self {
Self::new(
id,
title,
TabKind::Local,
"local shell".into(),
backend,
events,
)
} }
pub fn new_ssh(id: String, session: &Session, backend: BackendTx) -> Self { pub fn new_ssh(
id: String,
session: &Session,
backend: BackendTx,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Self {
let mut tab = Self::new( let mut tab = Self::new(
id, id,
session.name.clone(), session.name.clone(),
TabKind::Ssh, TabKind::Ssh,
format!("connecting {}@{}:{}", session.user, session.host, session.port), format!(
"connecting {}@{}:{}",
session.user, session.host, session.port
),
backend, backend,
events,
); );
tab.session = Some(session.clone()); tab.session = Some(session.clone());
tab.connected = false; tab.connected = false;
@@ -141,13 +191,21 @@ impl TerminalTab {
entries: Vec::new(), entries: Vec::new(),
selected_path: None, selected_path: None,
preview: None, preview: None,
selected_entries: std::collections::HashSet::new(),
}); });
tab tab
} }
fn new(id: String, title: String, kind: TabKind, status: String, backend: BackendTx) -> Self { fn new(
id: String,
title: String,
kind: TabKind,
status: String,
backend: BackendTx,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Self {
Self { Self {
id, id: id.clone(),
title, title,
kind, kind,
status, status,
@@ -155,7 +213,7 @@ impl TerminalTab {
session: None, session: None,
sftp: None, sftp: None,
processor: Processor::new(), processor: Processor::new(),
term: new_term(100, 30, backend.clone()), term: new_term(100, 30, backend.clone(), id, events),
cols: 100, cols: 100,
rows: 30, rows: 30,
backend, backend,
@@ -260,8 +318,11 @@ impl TerminalTab {
self.term.scroll_display(Scroll::Bottom); self.term.scroll_display(Scroll::Bottom);
} }
#[allow(dead_code)]
pub fn has_selection(&self) -> bool { pub fn has_selection(&self) -> bool {
self.term.selection_to_string().is_some_and(|text| !text.is_empty()) self.term
.selection_to_string()
.is_some_and(|text| !text.is_empty())
} }
pub fn clear_selection(&mut self) { pub fn clear_selection(&mut self) {
@@ -269,7 +330,9 @@ impl TerminalTab {
} }
pub fn selection_text(&self) -> Option<String> { pub fn selection_text(&self) -> Option<String> {
self.term.selection_to_string().filter(|text| !text.is_empty()) self.term
.selection_to_string()
.filter(|text| !text.is_empty())
} }
pub fn begin_selection( pub fn begin_selection(
@@ -297,19 +360,25 @@ impl TerminalTab {
} }
pub fn paste_text(&mut self, text: &str) { pub fn paste_text(&mut self, text: &str) {
let paste_text = text.replace('\x1b', "").replace("\r\n", "\r").replace('\n', "\r"); let paste_text = text
.replace('\x1b', "")
.replace("\r\n", "\r")
.replace('\n', "\r");
self.backend self.backend
.send(BackendCommand::Input(paste_text.into_bytes())); .send(BackendCommand::Input(paste_text.into_bytes()));
} }
} }
fn viewport_selection_from_range( fn viewport_selection_from_range(
display_offset: usize, display_offset: usize,
selection: &Option<SelectionRange>, selection: &Option<SelectionRange>,
) -> Option<ViewportSelection> { ) -> Option<ViewportSelection> {
let SelectionRange { start, end, is_block } = selection.as_ref().copied()?; let SelectionRange {
start,
end,
is_block,
} = selection.as_ref().copied()?;
let start = point_to_viewport(display_offset, start)?; let start = point_to_viewport(display_offset, start)?;
let end = point_to_viewport(display_offset, end)?; let end = point_to_viewport(display_offset, end)?;
@@ -324,13 +393,17 @@ fn viewport_selection_from_range(
#[derive(Clone)] #[derive(Clone)]
struct TerminalListener { struct TerminalListener {
tab_id: String,
backend: BackendTx, backend: BackendTx,
events: std::sync::mpsc::Sender<BackendEvent>,
} }
impl EventListener for TerminalListener { impl EventListener for TerminalListener {
fn send_event(&self, event: Event) { fn send_event(&self, event: Event) {
match event { match event {
Event::PtyWrite(output) => self.backend.send(BackendCommand::Input(output.into_bytes())), Event::PtyWrite(output) => self
.backend
.send(BackendCommand::Input(output.into_bytes())),
Event::TextAreaSizeRequest(format) => { Event::TextAreaSizeRequest(format) => {
let size = alacritty_terminal::event::WindowSize { let size = alacritty_terminal::event::WindowSize {
num_lines: 30, num_lines: 30,
@@ -341,19 +414,35 @@ impl EventListener for TerminalListener {
self.backend self.backend
.send(BackendCommand::Input(format(size).into_bytes())); .send(BackendCommand::Input(format(size).into_bytes()));
} }
Event::Title(title) => {
let _ = self.events.send(BackendEvent::TerminalTitleChanged {
tab_id: self.tab_id.clone(),
title,
});
}
_ => {} _ => {}
} }
} }
} }
fn new_term(cols: u16, rows: u16, backend: BackendTx) -> Term<TerminalListener> { fn new_term(
cols: u16,
rows: u16,
backend: BackendTx,
tab_id: String,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Term<TerminalListener> {
Term::new( Term::new(
Config { Config {
scrolling_history: 2000, scrolling_history: 2000,
..Config::default() ..Config::default()
}, },
&TerminalSize::new(cols, rows), &TerminalSize::new(cols, rows),
TerminalListener { backend }, TerminalListener {
tab_id,
backend,
events,
},
) )
} }
@@ -385,7 +474,11 @@ impl Dimensions for TerminalSize {
} }
} }
pub fn encode_key(keystroke: &Keystroke, app_cursor_mode: bool, option_as_meta: bool) -> Option<Vec<u8>> { 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) zed_like_to_esc_str(keystroke, app_cursor_mode, option_as_meta)
.map(|text| text.into_owned().into_bytes()) .map(|text| text.into_owned().into_bytes())
} }
+56 -27
View File
@@ -3,9 +3,10 @@ use alacritty_terminal::{
vte::ansi::{Color as AnsiColor, CursorShape, NamedColor}, vte::ansi::{Color as AnsiColor, CursorShape, NamedColor},
}; };
use gpui::{ use gpui::{
App, Bounds, Element, ElementId, Entity, FocusHandle, Font, FontStyle, FontWeight, GlobalElementId, Hsla, InputHandler, IntoElement, App, Bounds, Element, ElementId, Entity, FocusHandle, Font, FontStyle, FontWeight,
LayoutId, Pixels, Point, Rgba, StrikethroughStyle, TextRun, TextStyle, UTF16Selection, UnderlineStyle, Window, fill, point, px, GlobalElementId, Hsla, InputHandler, IntoElement, LayoutId, Pixels, Point, Rgba,
relative, rgb, StrikethroughStyle, TextRun, TextStyle, UTF16Selection, UnderlineStyle, Window, fill, point,
px, relative, rgb,
}; };
use gpui_component::ActiveTheme as _; use gpui_component::ActiveTheme as _;
@@ -81,7 +82,13 @@ impl BatchedTextRun {
} }
} }
fn paint(&self, origin: Point<Pixels>, metrics: TerminalMetrics, window: &mut Window, cx: &mut App) { fn paint(
&self,
origin: Point<Pixels>,
metrics: TerminalMetrics,
window: &mut Window,
cx: &mut App,
) {
let pos = point( let pos = point(
origin.x + metrics.cell_width * self.col as f32, origin.x + metrics.cell_width * self.col as f32,
origin.y + metrics.line_height * self.row as f32, origin.y + metrics.line_height * self.row as f32,
@@ -146,10 +153,13 @@ impl InputHandler for TerminalInputHandler {
_window: &mut Window, _window: &mut Window,
cx: &mut App, cx: &mut App,
) -> Option<UTF16Selection> { ) -> Option<UTF16Selection> {
self.view.read(cx).terminal_accepts_text_input().then_some(UTF16Selection { self.view
range: 0..0, .read(cx)
reversed: false, .terminal_accepts_text_input()
}) .then_some(UTF16Selection {
range: 0..0,
reversed: false,
})
} }
fn marked_text_range( fn marked_text_range(
@@ -277,15 +287,21 @@ impl TerminalElement {
fg.a *= 0.7; fg.a *= 0.7;
} }
let underline = cell.flags.intersects(Flags::ALL_UNDERLINES).then(|| UnderlineStyle { let underline = cell
color: Some(fg), .flags
thickness: px(1.0), .intersects(Flags::ALL_UNDERLINES)
wavy: cell.flags.contains(Flags::UNDERCURL), .then(|| UnderlineStyle {
}); color: Some(fg),
let strikethrough = cell.flags.contains(Flags::STRIKEOUT).then(|| StrikethroughStyle { thickness: px(1.0),
color: Some(fg), wavy: cell.flags.contains(Flags::UNDERCURL),
thickness: px(1.0), });
}); 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) { let weight = if cell.flags.intersects(Flags::BOLD | Flags::DIM_BOLD) {
FontWeight::BOLD FontWeight::BOLD
@@ -326,10 +342,9 @@ impl TerminalElement {
continue; continue;
} }
let selected = self let selected = self.snapshot.selection.is_some_and(|selection| {
.snapshot selection_contains(selection, render_cell.row, render_cell.col)
.selection });
.is_some_and(|selection| selection_contains(selection, render_cell.row, render_cell.col));
let bg = color_to_hsla(cell.bg, false, cx); let bg = color_to_hsla(cell.bg, false, cx);
if selected || !is_default_bg(cell.bg) { if selected || !is_default_bg(cell.bg) {
rects.push(LayoutRect { rects.push(LayoutRect {
@@ -525,7 +540,11 @@ impl Element for TerminalElement {
} }
if let Some(cursor) = prepaint.cursor { if let Some(cursor) = prepaint.cursor {
if self.marked_text.as_ref().is_some_and(|text| !text.is_empty()) { if self
.marked_text
.as_ref()
.is_some_and(|text| !text.is_empty())
{
return; return;
} }
let x = prepaint.bounds.origin.x + prepaint.metrics.cell_width * cursor.col as f32; let x = prepaint.bounds.origin.x + prepaint.metrics.cell_width * cursor.col as f32;
@@ -534,7 +553,10 @@ impl Element for TerminalElement {
CursorShape::Hidden => {} CursorShape::Hidden => {}
CursorShape::Beam => { CursorShape::Beam => {
window.paint_quad(fill( window.paint_quad(fill(
Bounds::new(point(x, y), gpui::size(px(2.), prepaint.metrics.line_height)), Bounds::new(
point(x, y),
gpui::size(px(2.), prepaint.metrics.line_height),
),
cursor.color, cursor.color,
)); ));
} }
@@ -572,7 +594,10 @@ fn merge_rects(mut rects: Vec<LayoutRect>) -> Vec<LayoutRect> {
for rect in rects { for rect in rects {
if let Some(last) = merged.last_mut() { if let Some(last) = merged.last_mut() {
if last.row == rect.row && last.color == rect.color && last.col + last.cells as i32 == rect.col { if last.row == rect.row
&& last.color == rect.color
&& last.col + last.cells as i32 == rect.col
{
last.cells += rect.cells; last.cells += rect.cells;
continue; continue;
} }
@@ -601,7 +626,11 @@ fn selection_contains(selection: ViewportSelection, row: i32, col: i32) -> bool
} }
fn is_blank(cell: &alacritty_terminal::term::cell::Cell) -> bool { fn is_blank(cell: &alacritty_terminal::term::cell::Cell) -> bool {
cell.c == ' ' && cell.zerowidth().is_none() && !cell.flags.intersects(Flags::ALL_UNDERLINES | Flags::STRIKEOUT) cell.c == ' '
&& cell.zerowidth().is_none()
&& !cell
.flags
.intersects(Flags::ALL_UNDERLINES | Flags::STRIKEOUT)
} }
fn is_default_bg(color: AnsiColor) -> bool { fn is_default_bg(color: AnsiColor) -> bool {
@@ -623,8 +652,8 @@ fn color_to_hsla(color: AnsiColor, foreground: bool, cx: &App) -> Hsla {
fn ansi_index_color(index: u8, _cx: &App) -> Hsla { fn ansi_index_color(index: u8, _cx: &App) -> Hsla {
const ANSI_16: [u32; 16] = [ const ANSI_16: [u32; 16] = [
0x1f2430, 0xff5c57, 0x5af78e, 0xf3f99d, 0x57c7ff, 0xff6ac1, 0x9aedfe, 0xf1f1f0, 0x1f2430, 0xff5c57, 0x5af78e, 0xf3f99d, 0x57c7ff, 0xff6ac1, 0x9aedfe, 0xf1f1f0, 0x686868,
0x686868, 0xff5c57, 0x5af78e, 0xf3f99d, 0x57c7ff, 0xff6ac1, 0x9aedfe, 0xffffff, 0xff5c57, 0x5af78e, 0xf3f99d, 0x57c7ff, 0xff6ac1, 0x9aedfe, 0xffffff,
]; ];
if (index as usize) < ANSI_16.len() { if (index as usize) < ANSI_16.len() {