mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-10-05 08:00:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1186e4c84 | ||
|
|
2595cc4853 | ||
|
|
2ae28df8f8 | ||
|
|
58fd03386a | ||
|
|
890ce85cfc | ||
|
|
d5fb956161 | ||
|
|
4bad75e522 | ||
|
|
9e28e78319 | ||
|
|
d561232d19 | ||
|
|
107fe8942c | ||
|
|
06796031e6 | ||
|
|
0cfd3a2fc2 | ||
|
|
7504bf6448 | ||
|
|
09ecd67ca6 | ||
|
|
6dfa199dcb | ||
|
|
4c5148fccf | ||
|
|
108a9cf95b | ||
|
|
9885b2573a | ||
|
|
cdf4cc3d53 | ||
|
|
5b381045f9 | ||
|
|
f246c952a2 | ||
|
|
2276eef191 | ||
|
|
bb39626173 | ||
|
|
2cb8ab50aa |
@@ -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>
|
||||||
@@ -148,9 +151,20 @@ jobs:
|
|||||||
name: ${{ matrix.name }}
|
name: ${{ matrix.name }}
|
||||||
path: ${{ env.ASSET }}
|
path: ${{ env.ASSET }}
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish Release
|
||||||
|
needs: build
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download all workflow artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: dist
|
||||||
|
|
||||||
- name: Attach to GitHub Release
|
- name: Attach to GitHub Release
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
files: ${{ env.ASSET }}
|
files: dist/**/*
|
||||||
|
generate_release_notes: true
|
||||||
fail_on_unmatched_files: true
|
fail_on_unmatched_files: true
|
||||||
|
|||||||
Generated
+96
-19
@@ -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"
|
||||||
@@ -292,19 +292,23 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"directories",
|
"directories",
|
||||||
"flate2",
|
"flate2",
|
||||||
|
"futures",
|
||||||
"gpui",
|
"gpui",
|
||||||
"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 +317,7 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
|
"winres",
|
||||||
"zip",
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -753,7 +758,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 +1687,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 +1947,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 +2568,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 +2702,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 +3263,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 +4127,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 +4313,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 +5989,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 +6011,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 +6042,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 +6128,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 +6141,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 +6796,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 +6904,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"psm",
|
"psm",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7205,7 +7264,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 +7484,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 +7750,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 +8510,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 +9147,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"
|
||||||
|
|||||||
@@ -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,14 @@ 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"] }
|
||||||
|
futures = "0.3.32"
|
||||||
|
|
||||||
[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"
|
||||||
|
|||||||
+11
-1
@@ -8,9 +8,19 @@
|
|||||||
|
|
||||||
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.
|
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
|
## 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
|
## Mac Installation Guide
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,19 @@
|
|||||||
|
|
||||||
该项目旨在通过结合本地和远程环境,并内置丰富的核心功能,提供一个高性能且美观的 Shell 工作区。
|
该项目旨在通过结合本地和远程环境,并内置丰富的核心功能,提供一个高性能且美观的 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 版本的最新预编译程序。
|
您可以从 [GitHub Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载 macOS、Windows 和 Linux 版本的最新预编译程序。
|
||||||
|
|
||||||
## Mac 安装指南
|
## Mac 安装指南
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 437 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
fn main() {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let mut res = winres::WindowsResource::new();
|
||||||
|
res.set_icon("assets/icons/ashell.ico");
|
||||||
|
res.compile().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
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"
|
||||||
|
ui_font_size: "UI Font Size"
|
||||||
|
terminal_font_size: "Terminal Font Size"
|
||||||
|
system_default: "System Default"
|
||||||
|
ui_font_family: "UI Font"
|
||||||
|
terminal_font_family: "Terminal Font"
|
||||||
|
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"
|
||||||
|
n_files: "%{files} files"
|
||||||
|
n_folders: "%{folders} folders"
|
||||||
|
n_files_and_folders: "%{files} files and %{folders} folders"
|
||||||
|
reset: "Reset"
|
||||||
|
reset_layout: "Reset Default Layout"
|
||||||
|
reset_layout_success: "Layout reset. Window size takes effect on restart."
|
||||||
|
|
||||||
|
transfers: "Transfers"
|
||||||
|
no_transfers_yet: "No transfers yet"
|
||||||
|
downloading: "Downloading"
|
||||||
|
paused: "Paused"
|
||||||
|
completed: "Completed"
|
||||||
|
failed: "Failed"
|
||||||
|
cancelled: "Cancelled"
|
||||||
|
session: "Session"
|
||||||
|
|
||||||
|
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."
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
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: "设置"
|
||||||
|
ui_font_size: "界面字体大小"
|
||||||
|
terminal_font_size: "终端字体大小"
|
||||||
|
system_default: "系统默认"
|
||||||
|
ui_font_family: "界面字体"
|
||||||
|
terminal_font_family: "终端字体"
|
||||||
|
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} 个文件夹"
|
||||||
|
n_files: "%{files} 个文件"
|
||||||
|
n_folders: "%{folders} 个文件夹"
|
||||||
|
n_files_and_folders: "%{files} 个文件及 %{folders} 个文件夹"
|
||||||
|
reset: "重置"
|
||||||
|
reset_layout: "恢复默认布局"
|
||||||
|
reset_layout_success: "布局占比已恢复默认。窗口大小将在下次启动时生效。"
|
||||||
|
|
||||||
|
transfers: "传输记录"
|
||||||
|
no_transfers_yet: "暂无传输记录"
|
||||||
|
downloading: "正在下载"
|
||||||
|
paused: "已暂停"
|
||||||
|
completed: "已完成"
|
||||||
|
failed: "失败"
|
||||||
|
cancelled: "已取消"
|
||||||
|
session: "会话"
|
||||||
|
|
||||||
|
software_builtin: "软件内置"
|
||||||
|
retry: "重试"
|
||||||
|
right_click_copy_paste: "右键复制/粘贴"
|
||||||
|
copy_paste_hint: "提示:不管是否开启此开关,都可以使用 %{key} + C/V 快捷键进行复制和粘贴。"
|
||||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 279 KiB |
@@ -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>
|
||||||
|
|||||||
+159
-1
@@ -70,24 +70,85 @@ 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 = "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")]
|
||||||
|
pub terminal_font_family: String,
|
||||||
#[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>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub transfers: Vec<crate::terminal::Transfer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_locale() -> String {
|
||||||
|
"system".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_terminal_font_size() -> f32 {
|
fn default_terminal_font_size() -> f32 {
|
||||||
13.0
|
13.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_ui_font_size() -> f32 {
|
||||||
|
12.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_ui_font_family() -> String {
|
||||||
|
// ".SystemUIFont" is a GPUI sentinel that resolves to the platform system UI font.
|
||||||
|
// This matches gpui-component's own Theme default.
|
||||||
|
".SystemUIFont".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_terminal_font_family() -> String {
|
||||||
|
"Maple Mono NF CN".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ConfigStore {
|
pub struct ConfigStore {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
cache: ConfigFile,
|
cache: ConfigFile,
|
||||||
@@ -136,6 +197,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 +209,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,18 +232,98 @@ 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 transfers(&self) -> Vec<crate::terminal::Transfer> {
|
||||||
|
self.cache.transfers.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_transfers(&mut self, transfers: Vec<crate::terminal::Transfer>) {
|
||||||
|
self.cache.transfers = transfers;
|
||||||
|
if let Err(err) = self.save() {
|
||||||
|
tracing::error!("failed to save config: {err:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn ui_font_size(&self) -> f32 {
|
||||||
|
if self.cache.ui_font_size <= 0.0 {
|
||||||
|
default_ui_font_size()
|
||||||
|
} else {
|
||||||
|
self.cache.ui_font_size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_ui_font_size(&mut self, ui_font_size: f32) {
|
||||||
|
self.cache.ui_font_size = ui_font_size.max(8.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ui_font_family(&self) -> &str {
|
||||||
|
if self.cache.ui_font_family.is_empty() {
|
||||||
|
".SystemUIFont"
|
||||||
|
} else {
|
||||||
|
&self.cache.ui_font_family
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_ui_font_family(&mut self, family: &str) {
|
||||||
|
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"
|
||||||
|
} else {
|
||||||
|
&self.cache.terminal_font_family
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_terminal_font_family(&mut self, family: &str) {
|
||||||
|
self.cache.terminal_font_family = family.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get(&self, id: &str) -> Option<&Session> {
|
pub fn get(&self, id: &str) -> Option<&Session> {
|
||||||
self.cache.sessions.iter().find(|s| s.id == id)
|
self.cache.sessions.iter().find(|s| s.id == id)
|
||||||
}
|
}
|
||||||
@@ -188,6 +345,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,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()),
|
||||||
|
|||||||
+1463
-303
File diff suppressed because it is too large
Load Diff
+494
-132
@@ -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,82 @@ 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,
|
||||||
|
},
|
||||||
|
PauseTransfer(String),
|
||||||
|
ResumeTransfer(String),
|
||||||
|
CancelTransfer(String),
|
||||||
|
TransferFinished(String),
|
||||||
Close,
|
Close,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
|
||||||
|
|
||||||
|
pub struct TransferStateFlag(pub Arc<AtomicU8>);
|
||||||
|
|
||||||
|
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 async fn yield_if_paused(
|
||||||
|
&self,
|
||||||
|
events: &std::sync::mpsc::Sender<crate::terminal::BackendEvent>,
|
||||||
|
tab_id: &str,
|
||||||
|
id: &str,
|
||||||
|
transferred: u64,
|
||||||
|
total: Option<u64>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let mut was_paused = false;
|
||||||
|
loop {
|
||||||
|
let state = self.0.load(Ordering::SeqCst);
|
||||||
|
if state == 2 {
|
||||||
|
return Err(anyhow::anyhow!("transfer cancelled"));
|
||||||
|
}
|
||||||
|
if state == 1 {
|
||||||
|
if !was_paused {
|
||||||
|
let _ = events.send(crate::terminal::BackendEvent::TransferProgress {
|
||||||
|
tab_id: tab_id.to_string(),
|
||||||
|
id: id.to_string(),
|
||||||
|
transferred,
|
||||||
|
total,
|
||||||
|
state: crate::terminal::TransferState::Paused,
|
||||||
|
});
|
||||||
|
was_paused = true;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
} else {
|
||||||
|
if was_paused {
|
||||||
|
let _ = events.send(crate::terminal::BackendEvent::TransferProgress {
|
||||||
|
tab_id: tab_id.to_string(),
|
||||||
|
id: id.to_string(),
|
||||||
|
transferred,
|
||||||
|
total,
|
||||||
|
state: crate::terminal::TransferState::Running,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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,21 +144,38 @@ 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) {
|
||||||
let _ = self.commands.send(SftpCommand::Close);
|
let _ = self.commands.send(SftpCommand::Close);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn pause_transfer(&self, id: String) {
|
||||||
|
let _ = self.commands.send(SftpCommand::PauseTransfer(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resume_transfer(&self, id: String) {
|
||||||
|
let _ = self.commands.send(SftpCommand::ResumeTransfer(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel_transfer(&self, id: String) {
|
||||||
|
let _ = self.commands.send(SftpCommand::CancelTransfer(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn spawn_sftp(
|
pub fn spawn_sftp(
|
||||||
@@ -99,8 +185,9 @@ pub fn spawn_sftp(
|
|||||||
events: std::sync::mpsc::Sender<BackendEvent>,
|
events: std::sync::mpsc::Sender<BackendEvent>,
|
||||||
) -> SftpHandle {
|
) -> SftpHandle {
|
||||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
||||||
|
let cmd_tx_clone = cmd_tx.clone();
|
||||||
let join = runtime.spawn(async move {
|
let join = runtime.spawn(async move {
|
||||||
if let Err(err) = run_sftp(tab_id.clone(), session, cmd_rx, 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 {
|
let _ = events.send(BackendEvent::SftpStatus {
|
||||||
tab_id,
|
tab_id,
|
||||||
text: format!("sftp error: {err:#}"),
|
text: format!("sftp error: {err:#}"),
|
||||||
@@ -117,11 +204,12 @@ async fn run_sftp(
|
|||||||
tab_id: String,
|
tab_id: String,
|
||||||
session: Session,
|
session: Session,
|
||||||
mut commands: UnboundedReceiver<SftpCommand>,
|
mut commands: UnboundedReceiver<SftpCommand>,
|
||||||
|
commands_tx: UnboundedSender<SftpCommand>,
|
||||||
events: std::sync::mpsc::Sender<BackendEvent>,
|
events: std::sync::mpsc::Sender<BackendEvent>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let _ = events.send(BackendEvent::SftpStatus {
|
let _ = events.send(BackendEvent::SftpStatus {
|
||||||
tab_id: tab_id.clone(),
|
tab_id: tab_id.clone(),
|
||||||
text: "sftp connecting...".into(),
|
text: t!("sftp_connecting").to_string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let handle = connect_and_authenticate(&session).await?;
|
let handle = connect_and_authenticate(&session).await?;
|
||||||
@@ -137,12 +225,35 @@ 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?;
|
||||||
|
|
||||||
|
let mut active_transfers: std::collections::HashMap<String, TransferStateFlag> = std::collections::HashMap::new();
|
||||||
|
|
||||||
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::PauseTransfer(id) => {
|
||||||
|
if let Some(flag) = active_transfers.get(&id) {
|
||||||
|
flag.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SftpCommand::ResumeTransfer(id) => {
|
||||||
|
if let Some(flag) = active_transfers.get(&id) {
|
||||||
|
flag.resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SftpCommand::CancelTransfer(id) => {
|
||||||
|
if let Some(flag) = active_transfers.remove(&id) {
|
||||||
|
flag.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SftpCommand::TransferFinished(id) => {
|
||||||
|
active_transfers.remove(&id);
|
||||||
|
}
|
||||||
SftpCommand::ListDir(path) => {
|
SftpCommand::ListDir(path) => {
|
||||||
let actual_path = if path == "~" {
|
let actual_path = if path == "~" {
|
||||||
home.clone()
|
home.clone()
|
||||||
@@ -169,56 +280,154 @@ 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(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
SftpCommand::Download { remote, local_dir } => {
|
SftpCommand::Download { remote, local_dir } => {
|
||||||
let base = base_name(&remote);
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
let _ = events.send(BackendEvent::SftpStatus {
|
let flag = TransferStateFlag::new();
|
||||||
|
active_transfers.insert(id.clone(), TransferStateFlag(flag.0.clone()));
|
||||||
|
|
||||||
|
let info = crate::terminal::TransferInfo {
|
||||||
|
id: id.clone(),
|
||||||
|
name: base_name(&remote).to_string(),
|
||||||
|
source: remote.clone(),
|
||||||
|
target: local_dir.clone(),
|
||||||
|
kind: crate::terminal::TransferType::Download,
|
||||||
|
total_bytes: None,
|
||||||
|
};
|
||||||
|
let _ = events.send(BackendEvent::TransferStarted {
|
||||||
tab_id: tab_id.clone(),
|
tab_id: tab_id.clone(),
|
||||||
text: format!("downloading {base}..."),
|
info,
|
||||||
});
|
});
|
||||||
match download_path_impl(&handle, &sftp, &remote, Path::new(&local_dir)).await {
|
|
||||||
Ok(summary) => {
|
let handle_clone = handle.clone();
|
||||||
let _ = events.send(BackendEvent::SftpStatus {
|
let events_clone = events.clone();
|
||||||
tab_id: tab_id.clone(),
|
let tab_id_clone = tab_id.clone();
|
||||||
text: summary,
|
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 _ = 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 {
|
||||||
|
Ok(summary) => {
|
||||||
|
let _ = events_clone.send(BackendEvent::SftpStatus {
|
||||||
|
tab_id: tab_id_clone,
|
||||||
|
text: summary,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let err_msg = format!("{err:#}");
|
||||||
|
let _ = events_clone.send(BackendEvent::SftpStatus {
|
||||||
|
tab_id: tab_id_clone.clone(),
|
||||||
|
text: t!("download_failed", err = err_msg.clone()).to_string(),
|
||||||
|
});
|
||||||
|
let _ = events_clone.send(BackendEvent::TransferProgress {
|
||||||
|
tab_id: tab_id_clone,
|
||||||
|
id: id.clone(),
|
||||||
|
transferred: 0,
|
||||||
|
total: None,
|
||||||
|
state: crate::terminal::TransferState::Failed(err_msg),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
let _ = commands_tx_clone.send(SftpCommand::TransferFinished(id));
|
||||||
let _ = events.send(BackendEvent::SftpStatus {
|
});
|
||||||
tab_id: tab_id.clone(),
|
|
||||||
text: format!("download failed: {err:#}"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
SftpCommand::UploadPaths { locals, remote_dir } => {
|
SftpCommand::UploadPaths { locals, remote_dir } => {
|
||||||
let _ = events.send(BackendEvent::SftpStatus {
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let flag = TransferStateFlag::new();
|
||||||
|
active_transfers.insert(id.clone(), TransferStateFlag(flag.0.clone()));
|
||||||
|
|
||||||
|
let name = if locals.len() == 1 {
|
||||||
|
base_name(&locals[0]).to_string()
|
||||||
|
} else {
|
||||||
|
let mut file_count = 0;
|
||||||
|
let mut folder_count = 0;
|
||||||
|
for local in &locals {
|
||||||
|
if std::path::Path::new(local).is_dir() {
|
||||||
|
folder_count += 1;
|
||||||
|
} else {
|
||||||
|
file_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if file_count > 0 && folder_count == 0 {
|
||||||
|
t!("n_files", files = file_count).to_string()
|
||||||
|
} 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()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let info = crate::terminal::TransferInfo {
|
||||||
|
id: id.clone(),
|
||||||
|
name,
|
||||||
|
source: "local".to_string(),
|
||||||
|
target: remote_dir.clone(),
|
||||||
|
kind: crate::terminal::TransferType::Upload,
|
||||||
|
total_bytes: None,
|
||||||
|
};
|
||||||
|
let _ = events.send(BackendEvent::TransferStarted {
|
||||||
tab_id: tab_id.clone(),
|
tab_id: tab_id.clone(),
|
||||||
text: "uploading...".into(),
|
info,
|
||||||
});
|
});
|
||||||
match upload_paths_impl(&sftp, &locals, &remote_dir).await {
|
|
||||||
Ok(summary) => {
|
let handle_clone = handle.clone();
|
||||||
let _ = events.send(BackendEvent::SftpStatus {
|
let events_clone = events.clone();
|
||||||
tab_id: tab_id.clone(),
|
let tab_id_clone = tab_id.clone();
|
||||||
text: summary,
|
let commands_tx_clone = commands_tx.clone();
|
||||||
});
|
|
||||||
let _ = emit_entries(&events, &tab_id, &sftp, &remote_dir).await;
|
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 _ = 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 {
|
||||||
|
Ok(summary) => {
|
||||||
|
let _ = events_clone.send(BackendEvent::SftpStatus {
|
||||||
|
tab_id: tab_id_clone.clone(),
|
||||||
|
text: summary,
|
||||||
|
});
|
||||||
|
let _ = commands_tx_clone.send(SftpCommand::ListDir(remote_dir));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let err_msg = format!("{err:#}");
|
||||||
|
let _ = events_clone.send(BackendEvent::SftpStatus {
|
||||||
|
tab_id: tab_id_clone.clone(),
|
||||||
|
text: t!("upload_failed", err = err_msg.clone()).to_string(),
|
||||||
|
});
|
||||||
|
let _ = events_clone.send(BackendEvent::TransferProgress {
|
||||||
|
tab_id: tab_id_clone,
|
||||||
|
id: id.clone(),
|
||||||
|
transferred: 0,
|
||||||
|
total: None,
|
||||||
|
state: crate::terminal::TransferState::Failed(err_msg),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
let _ = commands_tx_clone.send(SftpCommand::TransferFinished(id));
|
||||||
let _ = events.send(BackendEvent::SftpStatus {
|
});
|
||||||
tab_id: tab_id.clone(),
|
|
||||||
text: format!("upload failed: {err:#}"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = handle.disconnect(Disconnect::ByApplication, "bye", "").await;
|
let _ = handle
|
||||||
|
.disconnect(Disconnect::ByApplication, "bye", "")
|
||||||
|
.await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +450,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<Arc<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()
|
||||||
@@ -282,7 +493,7 @@ async fn connect_and_authenticate(session: &Session) -> Result<russh::client::Ha
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(handle)
|
Ok(Arc::new(handle))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
|
fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
|
||||||
@@ -368,6 +579,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) {
|
||||||
@@ -437,7 +649,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)
|
||||||
@@ -458,9 +673,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() {
|
||||||
@@ -491,12 +712,19 @@ async fn download_path_impl(
|
|||||||
sftp: &SftpSession,
|
sftp: &SftpSession,
|
||||||
remote: &str,
|
remote: &str,
|
||||||
local_dir: &Path,
|
local_dir: &Path,
|
||||||
|
flag: TransferStateFlag,
|
||||||
|
events: &std::sync::mpsc::Sender<BackendEvent>,
|
||||||
|
tab_id: &str,
|
||||||
|
id: &str,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
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()))?;
|
||||||
|
|
||||||
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)
|
||||||
@@ -508,16 +736,26 @@ 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, &flag, events, tab_id, id).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, &flag, events, tab_id, id).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,
|
||||||
|
flag: &TransferStateFlag,
|
||||||
|
events: &std::sync::mpsc::Sender<BackendEvent>,
|
||||||
|
tab_id: &str,
|
||||||
|
id: &str,
|
||||||
|
) -> 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()))?;
|
||||||
@@ -525,9 +763,9 @@ async fn download_dir_recursive(sftp: &SftpSession, remote_dir: &str, local_dir:
|
|||||||
for entry in entries {
|
for entry in entries {
|
||||||
let local_path = local_dir.join(&entry.name);
|
let local_path = local_dir.join(&entry.name);
|
||||||
if entry.is_dir {
|
if entry.is_dir {
|
||||||
Box::pin(download_dir_recursive(sftp, &entry.full_path, &local_path)).await?;
|
Box::pin(download_dir_recursive(sftp, &entry.full_path, &local_path, flag, events, tab_id, id)).await?;
|
||||||
} else {
|
} else {
|
||||||
download_file_impl(sftp, &entry.full_path, &local_path).await?;
|
download_file_impl(sftp, &entry.full_path, &local_path, flag, events, tab_id, id).await?;
|
||||||
let _ = maybe_extract_archive(&local_path).await;
|
let _ = maybe_extract_archive(&local_path).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -539,8 +777,16 @@ async fn download_remote_directory_archive(
|
|||||||
sftp: &SftpSession,
|
sftp: &SftpSession,
|
||||||
remote_dir: &str,
|
remote_dir: &str,
|
||||||
local_archive: &Path,
|
local_archive: &Path,
|
||||||
|
flag: &TransferStateFlag,
|
||||||
|
events: &std::sync::mpsc::Sender<BackendEvent>,
|
||||||
|
tab_id: &str,
|
||||||
|
id: &str,
|
||||||
) -> 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()
|
||||||
@@ -548,8 +794,12 @@ async fn download_remote_directory_archive(
|
|||||||
.join(base_name(remote_dir));
|
.join(base_name(remote_dir));
|
||||||
|
|
||||||
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, flag, events, tab_id, id).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()))?;
|
||||||
@@ -567,15 +817,33 @@ async fn download_remote_directory_archive(
|
|||||||
Ok(extracted_to)
|
Ok(extracted_to)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn download_file_impl(sftp: &SftpSession, remote: &str, local: &Path) -> Result<()> {
|
async fn download_file_impl(
|
||||||
let mut remote_file = sftp.open(remote).await.with_context(|| format!("open remote {remote}"))?;
|
sftp: &SftpSession,
|
||||||
|
remote: &str,
|
||||||
|
local: &Path,
|
||||||
|
flag: &TransferStateFlag,
|
||||||
|
events: &std::sync::mpsc::Sender<BackendEvent>,
|
||||||
|
tab_id: &str,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
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 total = sftp.metadata(remote).await.ok().and_then(|m| m.size);
|
||||||
|
let mut transferred = 0u64;
|
||||||
|
|
||||||
|
let mut buffer = vec![0u8; 128 * 1024];
|
||||||
loop {
|
loop {
|
||||||
let read = remote_file.read(&mut buffer).await.context("read remote file")?;
|
flag.yield_if_paused(events, tab_id, id, transferred, total).await?;
|
||||||
|
let read = remote_file
|
||||||
|
.read(&mut buffer)
|
||||||
|
.await
|
||||||
|
.context("read remote file")?;
|
||||||
if read == 0 {
|
if read == 0 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -583,84 +851,153 @@ async fn download_file_impl(sftp: &SftpSession, remote: &str, local: &Path) -> R
|
|||||||
.write_all(&buffer[..read])
|
.write_all(&buffer[..read])
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("write {}", local.display()))?;
|
.with_context(|| format!("write {}", local.display()))?;
|
||||||
|
|
||||||
|
transferred += read as u64;
|
||||||
|
let _ = events.send(BackendEvent::TransferProgress {
|
||||||
|
tab_id: tab_id.to_string(),
|
||||||
|
id: id.to_string(),
|
||||||
|
transferred,
|
||||||
|
total,
|
||||||
|
state: crate::terminal::TransferState::Running,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
local_file.flush().await.context("flush local file")?;
|
local_file.flush().await.context("flush local file")?;
|
||||||
|
|
||||||
|
let _ = events.send(BackendEvent::TransferProgress {
|
||||||
|
tab_id: tab_id.to_string(),
|
||||||
|
id: id.to_string(),
|
||||||
|
transferred,
|
||||||
|
total,
|
||||||
|
state: crate::terminal::TransferState::Completed,
|
||||||
|
});
|
||||||
|
|
||||||
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,
|
||||||
|
flag: TransferStateFlag,
|
||||||
|
events: &std::sync::mpsc::Sender<BackendEvent>,
|
||||||
|
tab_id: &str,
|
||||||
|
id: &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;
|
||||||
|
|
||||||
|
let mut total_bytes = 0u64;
|
||||||
|
let mut files_to_upload = Vec::new();
|
||||||
|
let mut dirs_to_create = Vec::new();
|
||||||
|
|
||||||
for local in locals {
|
for local in locals {
|
||||||
let path = PathBuf::from(local);
|
let p = PathBuf::from(local);
|
||||||
if path.is_dir() {
|
if p.is_dir() {
|
||||||
upload_directory_impl(sftp, &path, remote_dir).await?;
|
|
||||||
folder_count += 1;
|
folder_count += 1;
|
||||||
} else {
|
let root_name = p.file_name().and_then(|n| n.to_str()).unwrap_or("folder");
|
||||||
upload_file_to_dir_impl(sftp, &path, remote_dir).await?;
|
let remote_root = join_remote(remote_dir, root_name);
|
||||||
|
dirs_to_create.push(remote_root.clone());
|
||||||
|
|
||||||
|
for entry in WalkDir::new(&p) {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
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("/");
|
||||||
|
join_remote(&remote_root, &rel)
|
||||||
|
};
|
||||||
|
|
||||||
|
if path.is_dir() {
|
||||||
|
dirs_to_create.push(remote_path);
|
||||||
|
} else {
|
||||||
|
total_bytes += meta.len();
|
||||||
|
files_to_upload.push((path.to_path_buf(), remote_path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Ok(meta) = tokio::fs::metadata(&p).await {
|
||||||
|
total_bytes += meta.len();
|
||||||
|
let file_name = p.file_name().and_then(|n| n.to_str()).unwrap_or("file");
|
||||||
|
files_to_upload.push((p.clone(), join_remote(remote_dir, file_name)));
|
||||||
file_count += 1;
|
file_count += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let summary = match (file_count, folder_count) {
|
|
||||||
(1, 0) => "uploaded file".to_string(),
|
// Create directories sequentially first
|
||||||
(0, 1) => "uploaded folder".to_string(),
|
for dir in dirs_to_create {
|
||||||
(files, 0) => format!("uploaded {files} files"),
|
create_remote_dir_all(sftp, &dir).await?;
|
||||||
(0, folders) => format!("uploaded {folders} folders"),
|
}
|
||||||
(files, folders) => format!("uploaded {files} files and {folders} folders"),
|
|
||||||
|
let transferred = Arc::new(AtomicU64::new(0));
|
||||||
|
let mut futures = Vec::new();
|
||||||
|
|
||||||
|
for (local_path, remote_path) in files_to_upload {
|
||||||
|
let flag_clone = TransferStateFlag(Arc::clone(&flag.0));
|
||||||
|
let events_clone = events.clone();
|
||||||
|
let tab_id_clone = tab_id.to_string();
|
||||||
|
let id_clone = id.to_string();
|
||||||
|
let transferred_clone = Arc::clone(&transferred);
|
||||||
|
|
||||||
|
futures.push(async move {
|
||||||
|
upload_file_impl(
|
||||||
|
sftp,
|
||||||
|
&local_path,
|
||||||
|
&remote_path,
|
||||||
|
&flag_clone,
|
||||||
|
&events_clone,
|
||||||
|
&tab_id_clone,
|
||||||
|
&id_clone,
|
||||||
|
transferred_clone,
|
||||||
|
Some(total_bytes),
|
||||||
|
).await
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
use futures::StreamExt as _;
|
||||||
|
let mut stream = futures::stream::iter(futures).buffer_unordered(4);
|
||||||
|
while let Some(res) = stream.next().await {
|
||||||
|
res?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = events.send(BackendEvent::TransferProgress {
|
||||||
|
tab_id: tab_id.to_string(),
|
||||||
|
id: id.to_string(),
|
||||||
|
transferred: total_bytes,
|
||||||
|
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 {
|
||||||
|
t!("uploaded_folder").to_string()
|
||||||
|
} else if file_count > 0 && folder_count == 0 {
|
||||||
|
t!("uploaded_n_files", files = file_count).to_string()
|
||||||
|
} 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()
|
||||||
};
|
};
|
||||||
Ok(summary)
|
Ok(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn upload_directory_impl(sftp: &SftpSession, local_dir: &Path, remote_parent: &str) -> Result<()> {
|
async fn upload_file_impl(
|
||||||
let root_name = local_dir
|
sftp: &SftpSession,
|
||||||
.file_name()
|
local_file: &Path,
|
||||||
.and_then(|name| name.to_str())
|
remote_path: &str,
|
||||||
.ok_or_else(|| anyhow!("invalid folder name: {}", local_dir.display()))?;
|
flag: &TransferStateFlag,
|
||||||
let remote_root = join_remote(remote_parent, root_name);
|
events: &std::sync::mpsc::Sender<BackendEvent>,
|
||||||
create_remote_dir_all(sftp, &remote_root).await?;
|
tab_id: &str,
|
||||||
|
id: &str,
|
||||||
for entry in WalkDir::new(local_dir) {
|
transferred: Arc<AtomicU64>,
|
||||||
let entry = entry?;
|
total: Option<u64>,
|
||||||
let path = entry.path();
|
) -> Result<()> {
|
||||||
if path == local_dir {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let relative = path.strip_prefix(local_dir)?;
|
|
||||||
let remote_path = if relative.as_os_str().is_empty() {
|
|
||||||
remote_root.clone()
|
|
||||||
} else {
|
|
||||||
let rel = relative
|
|
||||||
.components()
|
|
||||||
.map(|component| component.as_os_str().to_string_lossy().to_string())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("/");
|
|
||||||
join_remote(&remote_root, &rel)
|
|
||||||
};
|
|
||||||
if path.is_dir() {
|
|
||||||
create_remote_dir_all(sftp, &remote_path).await?;
|
|
||||||
} else {
|
|
||||||
if let Some(parent) = Path::new(&remote_path).parent() {
|
|
||||||
let parent_remote = parent.to_string_lossy().replace('\\', "/");
|
|
||||||
create_remote_dir_all(sftp, &parent_remote).await?;
|
|
||||||
}
|
|
||||||
upload_file_impl(sftp, path, &remote_path).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn upload_file_to_dir_impl(sftp: &SftpSession, local_file: &Path, remote_dir: &str) -> Result<()> {
|
|
||||||
let file_name = local_file
|
|
||||||
.file_name()
|
|
||||||
.and_then(|name| name.to_str())
|
|
||||||
.ok_or_else(|| anyhow!("invalid file name: {}", local_file.display()))?;
|
|
||||||
let remote_path = join_remote(remote_dir, file_name);
|
|
||||||
upload_file_impl(sftp, local_file, &remote_path).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn upload_file_impl(sftp: &SftpSession, local_file: &Path, remote_path: &str) -> Result<()> {
|
|
||||||
let mut local = tokio::fs::File::open(local_file)
|
let mut local = tokio::fs::File::open(local_file)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("open local {}", local_file.display()))?;
|
.with_context(|| format!("open local {}", local_file.display()))?;
|
||||||
@@ -669,8 +1006,10 @@ async fn upload_file_impl(sftp: &SftpSession, local_file: &Path, remote_path: &s
|
|||||||
.await
|
.await
|
||||||
.with_context(|| format!("create remote {remote_path}"))?;
|
.with_context(|| format!("create remote {remote_path}"))?;
|
||||||
|
|
||||||
let mut buffer = vec![0u8; 64 * 1024];
|
let mut buffer = vec![0u8; 128 * 1024];
|
||||||
loop {
|
loop {
|
||||||
|
let cur = transferred.load(Ordering::Relaxed);
|
||||||
|
flag.yield_if_paused(events, tab_id, id, cur, total).await?;
|
||||||
let read = local.read(&mut buffer).await.context("read local file")?;
|
let read = local.read(&mut buffer).await.context("read local file")?;
|
||||||
if read == 0 {
|
if read == 0 {
|
||||||
break;
|
break;
|
||||||
@@ -679,6 +1018,15 @@ async fn upload_file_impl(sftp: &SftpSession, local_file: &Path, remote_path: &s
|
|||||||
.write_all(&buffer[..read])
|
.write_all(&buffer[..read])
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("write remote {remote_path}"))?;
|
.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(),
|
||||||
|
id: id.to_string(),
|
||||||
|
transferred: new_cur,
|
||||||
|
total,
|
||||||
|
state: crate::terminal::TransferState::Running,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
remote.flush().await.context("flush remote file")?;
|
remote.flush().await.context("flush remote file")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -787,8 +1135,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"]
|
||||||
@@ -834,7 +1187,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()))?;
|
||||||
@@ -851,7 +1206,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();
|
||||||
@@ -886,7 +1245,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()))?;
|
||||||
@@ -902,6 +1263,7 @@ async fn extract_archive_to(path: &Path, target_dir: &Path) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
struct SftpClientHandler;
|
struct SftpClientHandler;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
+4
-4
@@ -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
@@ -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,
|
||||||
|
|||||||
+160
-29
@@ -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,16 +29,58 @@ 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 {
|
||||||
TerminalTitleChanged { tab_id: String, title: String },
|
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,
|
||||||
|
},
|
||||||
|
TransferProgress {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
tab_id: String,
|
||||||
|
id: String,
|
||||||
|
transferred: u64,
|
||||||
|
total: Option<u64>,
|
||||||
|
state: TransferState,
|
||||||
|
},
|
||||||
|
TransferStarted {
|
||||||
|
tab_id: String,
|
||||||
|
info: TransferInfo,
|
||||||
|
},
|
||||||
|
Closed {
|
||||||
|
tab_id: String,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
TerminalTitleChanged {
|
||||||
|
tab_id: String,
|
||||||
|
title: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -119,19 +158,40 @@ 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, events: std::sync::mpsc::Sender<BackendEvent>) -> Self {
|
pub fn new_local(
|
||||||
Self::new(id, title, TabKind::Local, "local shell".into(), backend, events)
|
id: String,
|
||||||
|
title: String,
|
||||||
|
backend: BackendTx,
|
||||||
|
events: std::sync::mpsc::Sender<BackendEvent>,
|
||||||
|
) -> Self {
|
||||||
|
Self::new(
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
TabKind::Local,
|
||||||
|
"local shell".into(),
|
||||||
|
backend,
|
||||||
|
events,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_ssh(id: String, session: &Session, backend: BackendTx, events: std::sync::mpsc::Sender<BackendEvent>) -> Self {
|
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,
|
events,
|
||||||
);
|
);
|
||||||
@@ -139,15 +199,23 @@ impl TerminalTab {
|
|||||||
tab.connected = false;
|
tab.connected = false;
|
||||||
tab.sftp = Some(SftpUiState {
|
tab.sftp = Some(SftpUiState {
|
||||||
current_path: "/".into(),
|
current_path: "/".into(),
|
||||||
status: "sftp connecting...".into(),
|
status: t!("sftp_connecting").to_string(),
|
||||||
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, events: std::sync::mpsc::Sender<BackendEvent>) -> Self {
|
fn new(
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
kind: TabKind,
|
||||||
|
status: String,
|
||||||
|
backend: BackendTx,
|
||||||
|
events: std::sync::mpsc::Sender<BackendEvent>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
title,
|
title,
|
||||||
@@ -262,8 +330,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) {
|
||||||
@@ -271,7 +342,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(
|
||||||
@@ -299,19 +372,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)?;
|
||||||
|
|
||||||
@@ -334,7 +413,9 @@ struct TerminalListener {
|
|||||||
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,
|
||||||
@@ -356,14 +437,25 @@ impl EventListener for TerminalListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new_term(cols: u16, rows: u16, backend: BackendTx, tab_id: String, events: std::sync::mpsc::Sender<BackendEvent>) -> Term<TerminalListener> {
|
use rust_i18n::t;
|
||||||
|
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 { tab_id, backend, events },
|
TerminalListener {
|
||||||
|
tab_id,
|
||||||
|
backend,
|
||||||
|
events,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,7 +487,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())
|
||||||
}
|
}
|
||||||
@@ -565,3 +661,38 @@ fn modifier_code(keystroke: &Keystroke) -> u32 {
|
|||||||
}
|
}
|
||||||
modifier_code + 1
|
modifier_code + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub enum TransferType {
|
||||||
|
Upload,
|
||||||
|
Download,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub enum TransferState {
|
||||||
|
Running,
|
||||||
|
Paused,
|
||||||
|
Completed,
|
||||||
|
Failed(String),
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct TransferInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub source: String,
|
||||||
|
pub target: String,
|
||||||
|
pub kind: TransferType,
|
||||||
|
pub total_bytes: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct Transfer {
|
||||||
|
pub tab_id: String,
|
||||||
|
pub tab_title: String,
|
||||||
|
pub info: TransferInfo,
|
||||||
|
pub transferred: u64,
|
||||||
|
pub total: Option<u64>,
|
||||||
|
pub state: TransferState,
|
||||||
|
}
|
||||||
|
|||||||
+61
-32
@@ -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, SharedString,
|
||||||
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,
|
||||||
@@ -120,7 +127,7 @@ pub struct TerminalElement {
|
|||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
snapshot: RenderSnapshot,
|
snapshot: RenderSnapshot,
|
||||||
marked_text: Option<String>,
|
marked_text: Option<String>,
|
||||||
font_family: &'static str,
|
font_family: SharedString,
|
||||||
font_size: Pixels,
|
font_size: Pixels,
|
||||||
line_height: Pixels,
|
line_height: Pixels,
|
||||||
cell_width: Pixels,
|
cell_width: Pixels,
|
||||||
@@ -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(
|
||||||
@@ -240,7 +250,7 @@ impl TerminalElement {
|
|||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
snapshot: RenderSnapshot,
|
snapshot: RenderSnapshot,
|
||||||
marked_text: Option<String>,
|
marked_text: Option<String>,
|
||||||
font_family: &'static str,
|
font_family: SharedString,
|
||||||
font_size: Pixels,
|
font_size: Pixels,
|
||||||
line_height: Pixels,
|
line_height: Pixels,
|
||||||
cell_width: Pixels,
|
cell_width: Pixels,
|
||||||
@@ -260,7 +270,7 @@ impl TerminalElement {
|
|||||||
fn base_text_style(&self, cx: &App) -> TextStyle {
|
fn base_text_style(&self, cx: &App) -> TextStyle {
|
||||||
TextStyle {
|
TextStyle {
|
||||||
color: cx.theme().foreground,
|
color: cx.theme().foreground,
|
||||||
font_family: self.font_family.into(),
|
font_family: self.font_family.clone(),
|
||||||
font_size: self.font_size.into(),
|
font_size: self.font_size.into(),
|
||||||
line_height: self.line_height.into(),
|
line_height: self.line_height.into(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -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
|
||||||
@@ -303,7 +319,7 @@ impl TerminalElement {
|
|||||||
color: fg,
|
color: fg,
|
||||||
background_color: None,
|
background_color: None,
|
||||||
font: Font {
|
font: Font {
|
||||||
family: self.font_family.into(),
|
family: self.font_family.clone(),
|
||||||
weight,
|
weight,
|
||||||
style,
|
style,
|
||||||
..Font::default()
|
..Font::default()
|
||||||
@@ -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 {
|
||||||
@@ -499,7 +514,7 @@ impl Element for TerminalElement {
|
|||||||
&[TextRun {
|
&[TextRun {
|
||||||
len: marked_text.len(),
|
len: marked_text.len(),
|
||||||
font: Font {
|
font: Font {
|
||||||
family: self.font_family.into(),
|
family: self.font_family.clone(),
|
||||||
..Font::default()
|
..Font::default()
|
||||||
},
|
},
|
||||||
color: base_style.color,
|
color: base_style.color,
|
||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user