Compare commits

...
74 Commits
Author SHA1 Message Date
TomZz caa8a58561 fix: correctly reset active_dialog in session selector and ssh dialog 2026-06-18 00:41:53 +08:00
TomZz 6150bd410b fix: correctly reset active_dialog when manually closing transfers dialog 2026-06-17 07:05:09 +08:00
TomZz f55c3a8470 chore: bump version to v0.4.5 and update documentation 2026-06-17 06:23:07 +08:00
TomZz 7ae44ab9a7 ci: automate homebrew cask release for macos 2026-06-17 05:59:05 +08:00
TomZz 9c43b2dde7 chore: release v0.4.4 2026-06-17 05:00:51 +08:00
TomZz e6134bc0d1 fix: sftp panel status bar layout bug, update localized strings and add Open Transfer History shortcut 2026-06-17 04:58:27 +08:00
TomZz 96cd85a8d3 feat: persist sidebar and sftp panel visibility states 2026-06-17 03:00:44 +08:00
TomZz f45c2ed60f chore: release 2026-06-17 02:46:36 +08:00
TomZz 8a8f280401 feat: group keybindings and set keybindings page as default in settings 2026-06-17 02:43:10 +08:00
TomZz dc006f7144 fix: explicitly set mac quit mode to LastWindowClosed 2026-06-17 02:16:05 +08:00
TomZz daaebe4c2b feat: add sidebar collapsible feature 2026-06-17 00:43:39 +08:00
TomZz 2a37f45ba2 Merge pull request #35 from ancion/main
fix splitter resize bug and sftp status bar show file transfer progress
2026-06-16 22:54:07 +08:00
ancion ee8c18844c fix: sftp status not show sometimes and file transfer progress bar 2026-06-16 18:49:48 +08:00
ancion c5b09628b3 fix: panel splitter can drag to resize 2026-06-16 18:49:48 +08:00
TomZz b45364331d fix: restore full terminal width in split panes
Use the outer terminal panel bounds for PTY resizing while keeping per-pane bounds for focus and input handling. This prevents split panes from being resized a second time to half width.
2026-06-16 04:38:01 +08:00
TomZz 2d92f0ceee chore(release): v0.4.2
Bump the project version to v0.4.2 and include the updated README release notes in both Chinese and English.
2026-06-16 04:27:50 +08:00
TomZz 162a245a8f feat: improve theme defaults, config compatibility, and SSH key passphrase support
- default follow_system_theme to true and remove empty theme-name fallback override\n- add backward compatibility for v0.3.11 transfer state values during config load\n- preserve old config files by backing up parse failures instead of silently overwriting them\n- add optional SSH key passphrase input to the new SSH form and persist it in sessions\n- use stored passphrases when loading private keys for SSH and SFTP connections\n- make terminal size sync prefer measured bounds to reduce unused space at the bottom
2026-06-16 04:16:13 +08:00
TomZz 68bf152792 chore(release): v0.4.1 2026-06-16 01:54:35 +08:00
TomZz 13b9433c90 fix: fix settings layout collapse caused by keybind wrapper 2026-06-16 01:53:55 +08:00
TomZz 445e474fe8 chore: bump version to v0.4.0 2026-06-16 01:06:43 +08:00
TomZz 4c7c06f391 fix: fix interrupted message position, handle zombie transfers on restart, fix upload status text 2026-06-16 01:06:43 +08:00
TomZz e4fbd65a31 fix: show tab title in transfer history 2026-06-15 20:50:12 +08:00
TomZz bd376935f4 feat: add keybinding management
解决了 #18。

验证:

- cargo fmt

- cargo check

- git diff --check
2026-06-15 20:32:05 +08:00
TomZz 16761c1bf5 ci: add CI workflow to pre-populate rust cache on main branch 2026-06-15 01:29:03 +08:00
TomZz c3201e7c30 chore: bump version to v0.3.11 2026-06-15 01:14:48 +08:00
TomZz 2d5062ed75 feat: implement pixel-perfect custom rendering for block elements, powerline and box drawing
- Render block elements (U+2580-U+259F), box drawing lines, and powerline characters using precise GPUI vector paths instead of fonts.

- Snap logical coordinates to exact physical pixels using window.scale_factor() to perfectly eliminate SDF anti-aliasing gaps and bleeding.

- Fixes visual seams in terminal UI layouts.
2026-06-15 01:14:42 +08:00
TomZz d7d6faeeb1 feat: optimize transfer history dialog UI and logic
- Add an independent scrollbar track to prevent it from overlapping with the content list
- Add a "Clear History" button with text label, placed seamlessly next to the close button in the custom header
- Implement a 100-record limit for transfer history and display this limit in the header
- Persist the cleared transfer history into the session config immediately after clearing
- Disable default dialog close button and reconstruct header to avoid state update collision and panic

Resolves #25
2026-06-14 23:58:46 +08:00
TomZz 19a4274473 feat: enhance UI for active tabs and settings dialog
- Active Tab: Now uses `FontWeight::BOLD`, `text_base()` font size, and the primary theme color. This makes it far more distinct from inactive tabs, especially in themes where background differences are subtle.
- Settings Dialog: Overrode the default sidebar style to use the main dialog background (`cx.theme().background`), seamlessly blending the sidebar with the content pane.
- Refactor: Renamed unused `_cx` parameter to `cx` in the settings dialog render closure since it is now being utilized to access the theme.
2026-06-14 23:34:01 +08:00
TomZz 4bb3bee6c8 fix: ensure selection background fully covers wide characters (#24)
Rationale:
Previously, the terminal renderer generated background highlight rectangles (`LayoutRect`) with a hardcoded width of 1 cell (`cells: 1`) for every character. For full-width CJK characters (which carry the `Flags::WIDE_CHAR` flag and visually span 2 columns), this caused the selection and custom background colors to render across only half of the character's visual width.

This fix correctly interprets the terminal cell flags during layout generation: if a cell possesses `Flags::WIDE_CHAR`, it generates a background rectangle spanning 2 cells (`cells: 2`). As a result, the highlight now matches the exact bounds that the text occupies in the terminal grid, cleanly fixing the partial highlighting of Chinese text.
2026-06-14 23:19:33 +08:00
TomZz 8ab4b43825 style: update settings button icon and size 2026-06-14 19:41:55 +08:00
TomZz bbf843dde6 feat: settings dialog using Settings component #19 2026-06-14 19:34:06 +08:00
TomZz bc6af2e213 fix: close local terminal tab reliably when process exits on Windows 2026-06-14 16:31:30 +08:00
TomZz d224506fd5 fix: align sftp list item checkbox with header checkbox 2026-06-14 16:09:31 +08:00
TomZz d175135529 Merge branch 'fix-tmux' 2026-06-14 16:00:29 +08:00
TomZz c769b982b4 fix: resolve tmux mouse scroll mode recognition and alternate scroll fallback 2026-06-14 15:56:22 +08:00
TomZz ee68835972 fix: default monitoring area position when sessions.json is absent 2026-06-14 15:45:08 +08:00
TomZz 8a535fad5a fix: fallback to ssh-rsa signature for legacy OpenWrt dropbear
Resolves issue #20 where RSA 4096-bit keys failed to authenticate on OpenWrt
Dropbear servers. The previous logic exclusively tried rsa-sha2-512 and rsa-sha2-256,
which caused authentication aborts on servers only supporting legacy ssh-rsa (SHA-1)
signatures.

- Modified `private_key_with_alg` to `private_keys_with_algs` to return a list of
  supported algorithms including SHA-1 fallback for RSA keys.
- Implemented an iterative algorithm fallback loop in both SSH and SFTP connection
  authentication phases.
2026-06-14 14:22:24 +08:00
TomZz 90c07e16e1 Fix: change log timestamps and rolling filename suffix from UTC to local time 2026-06-14 14:06:57 +08:00
TomZz 33a3d624b7 Refactor: integrate comprehensive tracing logs and SSH multiplexing
This commit introduces a robust logging system and optimizes remote metric collection over SSH:

- Logging Infrastructure:
  - Replaced ad-hoc `eprintln!` and `println!` with the `tracing` crate.
  - Implemented a rolling file appender in `~/.config/ashell/log/` keeping the last 6 minutes of logs.
  - Restricted stdout logging to debug builds only, keeping release builds clean.

- SSH Multiplexing & Performance:
  - Shared `russh::client::Handle` across tasks using `Arc<Mutex<>>`.
  - Replaced the 5-second polling of new SSH connections with `SampleMetrics` over the existing channel.
  - Eliminated authentication log spam and vastly improved polling latency.
  - Optimized terminal `resize` events to only trigger when dimensions actually change, further reducing UI layout spam and backend load.

- Application & Network Auditing:
  - Log window lifecycles (open, close).
  - Log UI actions such as session connects and tab closures.
  - Detailed connection logs distinguishing between graceful EOFs, network drops, and abrupt closures.
  - Granular SFTP tracking including directory navigation, creation, batch uploads/downloads, and online file editing.
2026-06-14 13:57:50 +08:00
TomZz 78f8626217 feat: add native file picker and clear button for SSH key path 2026-06-14 11:26:23 +08:00
TomZz 743a5f9fe7 feat: add clone session context menu item and localize edit/delete items 2026-06-14 11:10:28 +08:00
TomZz 99a954b0b5 fix: add wayland and x11 features to gpui_platform for Linux support 2026-06-14 00:30:24 +08:00
TomZz 0bd04a98f0 fix: restrict config directory and file permissions to prevent credential leakage 2026-06-14 00:26:54 +08:00
TomZz df44d17ade fix: only show IME marked text in the focused terminal tab 2026-06-14 00:21:43 +08:00
TomZz 0e4c4cdc69 fix: correctly handle PTY exit as graceful and add SSH keepalives for fast disconnect detection 2026-06-14 00:16:56 +08:00
TomZz 227966319c fix: retry connection in existing tab and perfectly detect graceful ssh exit 2026-06-13 23:38:55 +08:00
TomZz 205ceba20c fix: correctly track ssh network drop and emit proper closed reason 2026-06-13 23:31:50 +08:00
TomZz 0ccbf9573b feat: enhance split pane focus UI and fix graceful exit tab cleanup
- Implement 4-sided inner absolute border for active pane focus

- Fix 'exit' gracefully closing tabs not removing empty tab groups

- Sync system monitoring automatically on active group switches
2026-06-13 23:25:54 +08:00
TomZz 8193223dc2 Fix: synchronize system monitoring with active tab group 2026-06-13 22:59:32 +08:00
TomZz 805cec5000 UI: make connection progress scrollable with auto-scroll to bottom 2026-06-13 22:51:09 +08:00
TomZz 3151757c4f Refactor SFTP to be scoped to TabGroup instead of TerminalTab and fix tab close behavior 2026-06-13 22:38:03 +08:00
TomZz 57c55e358c feat: improve sidebar disk monitoring panel with scroll support 2026-06-13 22:08:29 +08:00
ancion 81b160285d refactor: restructure project 2026-06-13 19:21:33 +08:00
ancion 77206bd126 feature: add split window feature 2026-06-13 18:08:40 +08:00
TomZz bb22718d3d style: tweak sidebar layout and monitor metrics
- Adjust monitor panel height logic when hidden to prevent layout dead space
- Change network upload/download text alignment to match v0.3.5 exact layout
- Move '+ SSH' button below the sidebar monitor panel
2026-06-13 03:39:34 +08:00
TomZz 4ba3000538 feat: add customizable system monitoring position
- Add settings to position the system monitoring panel at the Bottom, Sidebar, or Hidden.
- Support dynamically reallocating UI space when the monitor panel is moved.
- Implement a simplified, compact card-style system monitoring view for the sidebar (similar to v0.3.5 design).
- Add full localization for monitoring setting and sidebar metrics.
2026-06-13 03:34:29 +08:00
TomZz e7e2c67393 fix: restore body_panels layout sizes on app startup
- Ensure the size of the monitoring panel (the second child of body_panels) is explicitly loaded from configuration rather than falling back to the hardcoded default of 328px.
2026-06-13 03:01:40 +08:00
TomZz 47b03510b2 feat: optimize monitoring card layout for memory and disks
- Hide swap memory progress bar entirely if total swap is 0.
- Add temporary overlay scrollbar for the disk list using ScrollbarShow::Scrolling, allowing users to scroll through long disk lists without displaying a persistent scrollbar UI.
- Fix unused variable and dead code cargo warnings.
2026-06-13 02:56:11 +08:00
TomZz 54c50c871a feat: fix SFTP monitoring panel minimize behavior and layout
- Isolated the monitoring panel from ResizableState when minimized to prevent the GPUI flex layout and proportional resize engine from forcing it out of the window bounds.
- Wrapped the terminal's resizable container with flex_1 to constrain it to available space, preventing the fixed-height 114px panel from overflowing the bottom of the screen.
- Saved the user's dragged panel size (prev_monitoring_size) immediately before minimizing.

**Double-Frame Delay Update Reason**: When expanding the panel from 1 child back to 2 children, ResizableState needs one full render frame to recognize the child count change, append the new panel, and initialize its zero-size defaults. If we update the sizes synchronously on the first frame, ResizableState would overwrite our manual sizes during its layout initialization. Waiting for a double-frame delay guarantees that ResizableState has fully populated the second panel in its internal state, allowing us to accurately restore the user's previously dragged height without being overridden by the layout engine.
2026-06-13 02:42:04 +08:00
TomZz 6f887712e6 style: Simplify terminal title bar and remove redundant path display 2026-06-12 21:26:18 +08:00
TomZz 99071997d5 Merge PR 9, refactor codebase and manually resolve conflicts while retaining all recent main branch features 2026-06-12 20:38:11 +08:00
TomZz e35a2458bb Merge branch 'pr-9' into resolve-pr-9 2026-06-12 20:34:34 +08:00
TomZz 690d7418f4 feat: Save 'show hidden files' toggle state to settings 2026-06-12 20:25:29 +08:00
TomZz aca03c924f fix: Cache text metrics for terminal IME bounds to prevent blocking during composition 2026-06-12 20:20:49 +08:00
ancion 1847736d66 merge fix 2026-06-12 19:59:50 +08:00
ancion e163d05683 refactor: avoid main to big, split to different module and fix layout 2026-06-12 19:54:24 +08:00
ancion c3b1707014 refactor: change ui layout, make space for ai feture 2026-06-12 19:52:42 +08:00
TomZz 8a82288c5e feat: persist window bounds and layout sizes across restarts 2026-06-12 19:50:32 +08:00
TomZz 8c24eb885c fix: Optimize terminal rendering cell width calculation and line height 2026-06-12 12:44:35 +08:00
TomZz 2afcd1ea90 feat: Add SFTP directory batch delete and create folder features, fix auto refresh bug 2026-06-12 07:54:10 +08:00
TomZz 68439bc599 feat: support editing remote text files with local system editor
- Add right-click 'Edit' context menu option for common text formats.

- Automatically download file to a temporary directory and open in default editor.

- Background daemon listens for save events and auto-uploads changes quietly.

- Isolate auto-sync from transfer history UI.

- Clear legacy session config tmp files on application startup.
2026-06-12 07:14:55 +08:00
TomZz f1186e4c84 fix: UI/UX improvements for tabs, sidebar layout, and scrolling
- Redesign TabBar: remove bottom border and adjust height to a compact 40px.
- Fix UI auto-scrolling: TabBar, Open Session dialog, and Saved Sessions sidebar now accurately scroll selected items into view.
- Fix layout bounds for scrollbars so they render continuously and always stay visible.
- Pin sidebar headers (logo, stats) to the top while only the session list scrolls.
2026-06-12 05:32:54 +08:00
TomZz 2595cc4853 fix: prevent IME composition interrupt by only syncing theme when system appearance changes 2026-06-12 04:50:54 +08:00
TomZz 2ae28df8f8 Fix: Prevent auto-dismiss of newly opened dialogs when using keyboard navigation in Open Session selector 2026-06-12 03:38:41 +08:00
28 changed files with 10529 additions and 4550 deletions
+58
View File
@@ -0,0 +1,58 @@
name: CI
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
build:
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
target: x86_64-pc-windows-msvc
name: windows-x86_64
- os: ubuntu-22.04 # build on the oldest supported runner for broader glibc compatibility
target: x86_64-unknown-linux-gnu
name: linux-x86_64
- os: macos-14 # Apple Silicon
target: aarch64-apple-darwin
name: macos-aarch64
- os: macos-14 # Intel (cross-compiled from Apple Silicon to avoid queue delays)
target: x86_64-apple-darwin
name: macos-x86_64
steps:
- uses: actions/checkout@v4
# GPUI needs these system libs on Linux to compile.
- name: Install Linux GUI dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential pkg-config cmake \
libfontconfig1-dev libfreetype6-dev \
libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \
libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev \
libgl1-mesa-dev libegl1-mesa-dev libgtk-3-dev \
libudev-dev
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Build (release)
# We build in release mode so that the release workflow can reuse the exact artifacts
run: cargo build --release --target ${{ matrix.target }}
+86 -5
View File
@@ -12,7 +12,7 @@ on:
workflow_dispatch:
permissions:
contents: write # needed to create / update the GitHub Release
contents: write # needed to create / update the GitHub Release
jobs:
build:
@@ -26,15 +26,15 @@ jobs:
target: x86_64-pc-windows-msvc
name: windows-x86_64
bin: ashell.exe
- os: ubuntu-22.04 # build on the oldest supported runner for broader glibc compatibility
- os: ubuntu-22.04 # build on the oldest supported runner for broader glibc compatibility
target: x86_64-unknown-linux-gnu
name: linux-x86_64
bin: ashell
- os: macos-14 # Apple Silicon
- os: macos-14 # Apple Silicon
target: aarch64-apple-darwin
name: macos-aarch64
bin: ashell
- os: macos-14 # Intel (cross-compiled from Apple Silicon to avoid queue delays)
- os: macos-14 # Intel (cross-compiled from Apple Silicon to avoid queue delays)
target: x86_64-apple-darwin
name: macos-x86_64
bin: ashell
@@ -80,7 +80,7 @@ jobs:
# Note: We build the bundle manually here rather than running
# scripts/package-macos-app.sh, because that script runs `cargo build`
# without respecting the `--target` flag from the matrix.
APP="ashell.app"
CONTENTS="$APP/Contents"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
@@ -168,3 +168,84 @@ jobs:
files: dist/**/*
generate_release_notes: true
fail_on_unmatched_files: true
cask:
name: Update Homebrew Cask
needs: publish
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- name: Checkout Homebrew Tap
uses: actions/checkout@v4
with:
repository: rust-kotlin/homebrew-taps
token: ${{ secrets.TAP_GITHUB_TOKEN }}
path: tap
- name: Download all macOS artifacts
uses: actions/download-artifact@v4
with:
pattern: macos-*
path: dist
- name: Update Cask
run: |
VERSION=${GITHUB_REF#refs/tags/v}
SHA_ARM=$(sha256sum dist/macos-aarch64/*.zip | cut -d' ' -f1)
SHA_INTEL=$(sha256sum dist/macos-x86_64/*.zip | cut -d' ' -f1)
mkdir -p tap/Casks
CASK_FILE="tap/Casks/ashell.rb"
cat > "$CASK_FILE" <<EOF
cask "ashell" do
version "${VERSION}"
on_arm do
sha256 "${SHA_ARM}"
url "https://github.com/rust-kotlin/ashell/releases/download/v#{version}/ashell-v#{version}-macos-aarch64.zip"
end
on_intel do
sha256 "${SHA_INTEL}"
url "https://github.com/rust-kotlin/ashell/releases/download/v#{version}/ashell-v#{version}-macos-x86_64.zip"
end
name "ashell"
desc "ashell is a modern, fast, GPUI Component-based desktop terminal client written in Rust."
homepage "https://github.com/rust-kotlin/ashell"
app "ashell.app"
postflight do
puts "Removing quarantine flag from ashell.app..."
system_command "/usr/bin/xattr",
args: ["-rd", "com.apple.quarantine", "#{appdir}/ashell.app"],
sudo: true
end
caveats <<~EOS
ashell uses ad-hoc signing. The installation includes a postflight script
that removes the 'com.apple.quarantine' attribute from the application
to allow it to run. This will require your administrator password.
ashell 采用本地签名。安装程序包含一个后置脚本,用于移除应用程序的
'com.apple.quarantine' 属性以允许其运行。这需要您的管理员密码。
EOS
end
EOF
# Remove the 10 leading spaces added for YAML formatting
sed -i 's/^ //' "$CASK_FILE"
- name: Commit and Push
run: |
VERSION=${GITHUB_REF#refs/tags/v}
cd tap
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if [[ -n $(git status -s) ]]; then
git add Casks/ashell.rb
git commit -m "chore: bump ashell to v${VERSION}"
git push
else
echo "No changes to commit"
fi
Generated
+393 -5
View File
@@ -142,6 +142,19 @@ dependencies = [
"subtle",
]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"const-random",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -264,6 +277,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "as-raw-xcb-connection"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
[[package]]
name = "as-slice"
version = "0.2.1"
@@ -284,7 +303,7 @@ dependencies = [
[[package]]
name = "ashell"
version = "0.1.0"
version = "0.4.5"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -299,6 +318,8 @@ dependencies = [
"gpui_platform",
"image",
"menu",
"notify 6.1.1",
"open",
"portable-pty",
"rfd",
"russh",
@@ -312,8 +333,10 @@ dependencies = [
"sysinfo 0.33.1",
"tar",
"thiserror 1.0.69",
"time",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
"uuid",
"walkdir",
@@ -350,9 +373,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "340e0f6bf7f9ee78549c61454f1460a3ed97c011902ee76b58301bbc6d502a32"
dependencies = [
"enumflags2",
"futures-channel",
"futures-util",
"getrandom 0.4.2",
"serde",
"serde_repr",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"zbus",
]
@@ -971,6 +999,18 @@ dependencies = [
"tracing",
]
[[package]]
name = "calloop-wayland-source"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
dependencies = [
"calloop",
"rustix 1.1.4",
"wayland-backend",
"wayland-client",
]
[[package]]
name = "cbc"
version = "0.1.2"
@@ -1228,6 +1268,26 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "convert_case"
version = "0.10.0"
@@ -1414,6 +1474,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
@@ -1583,6 +1652,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
@@ -2395,6 +2473,16 @@ dependencies = [
"zeroize",
]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix 1.1.4",
"windows-link 0.2.1",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -2696,7 +2784,7 @@ dependencies = [
"lsp-types",
"markdown",
"markup5ever_rcdom",
"notify",
"notify 7.0.0",
"num-traits",
"once_cell",
"paste",
@@ -2749,17 +2837,24 @@ dependencies = [
"accesskit",
"accesskit_unix",
"anyhow",
"as-raw-xcb-connection",
"ashpd 0.13.11",
"bitflags 2.12.1",
"bytemuck",
"calloop",
"calloop-wayland-source",
"collections",
"filedescriptor",
"futures",
"gpui",
"gpui_wgpu",
"http_client",
"image",
"itertools 0.14.0",
"libc",
"log",
"oo7",
"open",
"parking_lot",
"pathfinder_geometry",
"pollster 0.4.0",
@@ -2772,6 +2867,17 @@ dependencies = [
"url",
"util",
"uuid",
"wayland-backend",
"wayland-client",
"wayland-cursor",
"wayland-protocols",
"wayland-protocols-plasma",
"wayland-protocols-wlr",
"x11-clipboard",
"x11rb",
"xkbcommon",
"zed-scap",
"zed-xim",
]
[[package]]
@@ -2912,6 +3018,7 @@ dependencies = [
"wasm-bindgen-futures",
"web-sys",
"wgpu",
"zed-font-kit",
]
[[package]]
@@ -3458,6 +3565,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inotify"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify"
version = "0.10.2"
@@ -3547,6 +3665,25 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itertools"
version = "0.11.0"
@@ -4109,6 +4246,18 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.48.0",
]
[[package]]
name = "mio"
version = "1.2.1"
@@ -4270,6 +4419,25 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "notify"
version = "6.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
dependencies = [
"bitflags 2.12.1",
"crossbeam-channel",
"filetime",
"fsevent-sys",
"inotify 0.9.6",
"kqueue",
"libc",
"log",
"mio 0.8.11",
"walkdir",
"windows-sys 0.48.0",
]
[[package]]
name = "notify"
version = "7.0.0"
@@ -4279,11 +4447,11 @@ dependencies = [
"bitflags 2.12.1",
"filetime",
"fsevent-sys",
"inotify",
"inotify 0.10.2",
"kqueue",
"libc",
"log",
"mio",
"mio 1.2.1",
"notify-types",
"walkdir",
"windows-sys 0.52.0",
@@ -4383,6 +4551,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-derive"
version = "0.4.2"
@@ -4445,6 +4619,15 @@ dependencies = [
"libc",
]
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]]
name = "objc"
version = "0.2.7"
@@ -4707,6 +4890,17 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
dependencies = [
"is-wsl",
"libc",
"pathdiff",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
@@ -4832,6 +5026,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pathfinder_geometry"
version = "0.5.1"
@@ -5197,6 +5397,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -7124,6 +7330,12 @@ dependencies = [
"zeno",
]
[[package]]
name = "symlink"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]]
name = "syn"
version = "2.0.117"
@@ -7359,6 +7571,48 @@ dependencies = [
"zune-jpeg 0.5.15",
]
[[package]]
name = "time"
version = "0.3.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd"
dependencies = [
"deranged",
"itoa",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca"
[[package]]
name = "time-macros"
version = "0.2.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]]
name = "tiny-skia"
version = "0.11.4"
@@ -7418,7 +7672,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
"mio",
"mio 1.2.1",
"pin-project-lite",
"socket2",
"tokio-macros",
@@ -7624,6 +7878,19 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-appender"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
dependencies = [
"crossbeam-channel",
"symlink",
"thiserror 2.0.18",
"time",
"tracing-subscriber",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
@@ -7669,6 +7936,7 @@ dependencies = [
"sharded-slab",
"smallvec",
"thread_local",
"time",
"tracing",
"tracing-core",
"tracing-log",
@@ -8255,6 +8523,17 @@ dependencies = [
"wayland-scanner",
]
[[package]]
name = "wayland-cursor"
version = "0.31.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d"
dependencies = [
"rustix 1.1.4",
"wayland-client",
"xcursor",
]
[[package]]
name = "wayland-protocols"
version = "0.32.12"
@@ -8267,6 +8546,32 @@ dependencies = [
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-plasma"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91"
dependencies = [
"bitflags 2.12.1",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-wlr"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
dependencies = [
"bitflags 2.12.1",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.10"
@@ -9287,6 +9592,36 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "x11-clipboard"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3"
dependencies = [
"libc",
"x11rb",
]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"as-raw-xcb-connection",
"gethostname",
"libc",
"rustix 1.1.4",
"x11rb-protocol",
"xcursor",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xattr"
version = "0.2.3"
@@ -9318,6 +9653,46 @@ dependencies = [
"x11",
]
[[package]]
name = "xcursor"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
[[package]]
name = "xim-ctext"
version = "0.3.0"
source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8"
dependencies = [
"encoding_rs",
]
[[package]]
name = "xim-parser"
version = "0.2.1"
source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8"
dependencies = [
"bitflags 2.12.1",
]
[[package]]
name = "xkbcommon"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9"
dependencies = [
"as-raw-xcb-connection",
"libc",
"memmap2",
"xkeysym",
]
[[package]]
name = "xkeysym"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xml-rs"
version = "0.8.28"
@@ -9590,6 +9965,19 @@ dependencies = [
"workspace-hack",
]
[[package]]
name = "zed-xim"
version = "0.4.0-zed"
source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8"
dependencies = [
"ahash",
"hashbrown 0.14.5",
"log",
"x11rb",
"xim-ctext",
"xim-parser",
]
[[package]]
name = "zeno"
version = "0.3.3"
+7 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "ashell"
version = "0.1.0"
version = "0.4.5"
edition = "2024"
authors = ["ashell contributors"]
description = "A GPUI Component based SSH and local terminal client"
@@ -15,7 +15,7 @@ chrono = { version = "0.4", features = ["serde"] }
directories = "5"
flate2 = "1"
gpui = { git = "https://github.com/zed-industries/zed" }
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "runtime_shaders"] }
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "runtime_shaders", "wayland", "x11"] }
gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "ca85b5588f5feea021f1ee86cb13703dc6f30ec4" }
gpui-component-assets = { git = "https://github.com/longbridge/gpui-component", rev = "ca85b5588f5feea021f1ee86cb13703dc6f30ec4" }
menu = { git = "https://github.com/zed-industries/zed" }
@@ -34,12 +34,16 @@ tar = "0.4"
thiserror = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "net", "time", "fs"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-subscriber = { version = "0.3", features = ["env-filter", "local-time", "time"] }
uuid = { version = "1", features = ["v4", "serde"] }
walkdir = "2"
zip = { version = "2", default-features = false, features = ["deflate"] }
image = { version = "0.25.9", default-features = false, features = ["png"] }
futures = "0.3.32"
notify = "6.1.1"
open = "5.1"
time = { version = "0.3.45", features = ["formatting", "local-offset"] }
tracing-appender = "0.2.5"
[profile.release]
opt-level = 3
+31 -10
View File
@@ -8,15 +8,15 @@
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!
## 🚀 v0.4 Major Upgrades
The brand-new v0.3 introduces comprehensive upgrades to both architecture and user experience:
- ✨ **Global Real-Time Font Sizing** (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 with real-time scaling and automatic persistence.
- ✨ **Concurrent SFTP Transfers**: A dedicated Transfers dialog now supports highly optimized, concurrent file uploads and downloads with precise progress, speed, and ETA calculations.
- ✨ **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.
v0.4 builds on the v0.3 foundation and focuses on more capable workspace operations and a smoother daily workflow:
- ✨ **Keybinding Management**: View and edit common shortcuts from the settings UI with conflict hints.
- ✨ **Settings Page Polish**: A more compact and clearer settings experience with improved layout and interaction flow.
- ✨ **Multi-Pane Tabs with a tmux-like Workflow**: A single tab can now host multiple panes, with split, focus, and switching actions for a tmux-inspired experience.
- ✨ **Transfer History Improvements**: The transfer history panel now presents richer task details, making upload and download activity easier to review.
- ✨ **SSH Passphrase Support**: Private keys can now store a passphrase, and SSH connections will use it automatically.
- ✨ **Terminal Rendering Enhancements**: Terminal rendering now handles Block Elements and similar custom glyphs more completely.
## Download
@@ -24,8 +24,28 @@ You can download the latest pre-compiled releases for macOS, Windows, and Linux
## Mac Installation Guide
After downloading, please unzip the file and move `ashell.app` to your **Applications** folder.
Since the app uses ad-hoc signing, macOS may warn that the app is "damaged" upon first launch. If this happens, open Terminal and run the following command:
### Method 1: Homebrew (Recommended)
If you use [Homebrew](https://brew.sh/), you can install it quickly with:
```bash
brew install rust-kotlin/taps/ashell --cask
```
To update the app:
```bash
brew update
brew upgrade ashell --cask
```
> **Note**: Since the app uses ad-hoc signing, the Homebrew installation or update includes a postflight script to automatically handle the quarantine flag. This will require you to enter your administrator password for authorization.
### Method 2: Manual Download
1. Download and unzip from the [Releases page](https://github.com/rust-kotlin/ashell/releases/latest).
2. Move `ashell.app` to your **Applications** folder.
3. Since the app uses ad-hoc signing, macOS may warn that the app is "damaged" upon first launch. If this happens, open Terminal and run:
```bash
sudo xattr -cr /Applications/ashell.app
@@ -43,6 +63,7 @@ The current version provides a fully-featured GPUI-native workspace:
- **System Telemetry:** Real-time visualization of CPU, memory, swap, network, and disk metrics in the left cockpit sidebar.
- **Theming System:** Switch between multiple GPUI Component themes directly from the top toolbar.
- **Embedded Fonts:** Uses embedded Maple Mono NF CN fonts out-of-the-box for excellent CJK character and Nerd Font icon support.
- **v0.3 Core Enhancements:** Global font and font-size controls, concurrent SFTP transfers, persistent layout state, disconnect awareness, hot-swappable i18n, and smart terminal right-click copy/paste.
## Run
+31 -10
View File
@@ -8,15 +8,15 @@
该项目旨在通过结合本地和远程环境,并内置丰富的核心功能,提供一个高性能且美观的 Shell 工作区。
## 🚀 v0.3 版本重磅更新!
## 🚀 v0.4 版本重要升级
全新发布的 v0.3 迎来了架构和体验的全面升级:
- ✨ **全局实时字体设置与无级缩放**(感谢 [@ancion](https://github.com/ancion) 提供的 [PR #6](https://github.com/rust-kotlin/ashell/pull/6)):支持自定义全局 UI 字体和终端渲染字体,支持实时字号调节缩放并自动记忆。
- ✨ **SFTP 多任务并发传输队列**:引入全新的独立传输对话框,支持多文件并发上传下载,提供精准的进度条、速率和剩余时间显示。
- ✨ **持久化工作区记忆**:您对侧边栏的尺寸拖拽、终端主题、字体、隐藏文件开关等一切设置都会在下次启动时原样恢复!
- ✨ **智能断线重连与状态感知**:全面增强的 SSH 网络异常处理机制,断线后直接提供美观的覆盖层用于一键重连或关闭。
- ✨ **多语言热切换增强**:深度优化的 i18n 系统,包含中文与英文在内的所有界面文案现在均支持热切换。
- ✨ **智能右键快捷操作**:新增终端右键智能复制与粘贴功能,提供专属设置项自由控制开关,并支持根据不同操作系统自适应显示原生快捷键提示。
v0.4 在 v0.3 打下的基础上,重点带来了更完整的工作区操作能力和更顺手的日常体验:
- ✨ **键位管理功能**:支持在设置中可视化查看和修改常用快捷键,并提供冲突提示。
- ✨ **设置页优化**:设置页的布局和交互更加紧凑顺手,整体信息层级更清晰。
- ✨ **Tab 内多 Pane 与类 tmux 体验**:一个 tab 内现在可以管理多个 pane,支持分屏、聚焦和切换,提供更接近 tmux 的工作流。
- ✨ **传输历史增强**:传输历史面板展示更完整的任务信息,便于回溯上传和下载过程。
- ✨ **SSH passphrase 支持**:现在可以为私钥填写 passphrase,并在连接时自动使用保存的口令。
- ✨ **终端显示优化**:终端渲染进一步增强,支持 Block Elements 等自定义图形字符的更完整显示。
## 下载
@@ -24,8 +24,28 @@
## Mac 安装指南
下载并解压后,请先将 `ashell.app` 拖入或移动到 **应用程序 (Applications)** 目录。
由于应用采用本地签名,初次启动时如果系统提示“App 已损坏,无法打开”,请打开终端(Terminal)并执行以下命令:
### 方法 1: Homebrew 安装 (推荐)
如果您使用 [Homebrew](https://brew.sh/),可以通过以下命令快速安装:
```bash
brew install rust-kotlin/taps/ashell --cask
```
更新应用:
```bash
brew update
brew upgrade ashell --cask
```
> **注意**: 由于应用采用本地签名,Homebrew 在安装或更新过程中会执行一个后置脚本来自动处理隔离属性(quarantine flag),这需要您输入管理员密码以授权。
### 方法 2: 手动下载
1. 从 [Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载并解压。
2. 将 `ashell.app` 拖入或移动到 **应用程序 (Applications)** 目录。
3. 由于应用采用本地签名,初次启动时如果系统提示“App 已损坏,无法打开”,请打开终端(Terminal)并执行以下命令:
```bash
sudo xattr -cr /Applications/ashell.app
@@ -43,6 +63,7 @@ sudo xattr -cr /Applications/ashell.app
- **系统遥测**:在左侧边栏实时可视化显示 CPU、内存、Swap、网络和磁盘的使用指标。
- **主题系统**:支持直接从顶部工具栏切换多种 GPUI Component 颜色主题。
- **内置字体**:开箱即用,内置 Maple Mono NF CN 字体,提供卓越的中日韩(CJK)字符与 Nerd Font 图标支持。
- **v0.3 核心增强**:全局字体与字号可调,支持 SFTP 并发传输、布局记忆、断线感知、多语言热切换,以及终端右键复制粘贴等增强能力。
## 运行
+62 -3
View File
@@ -6,12 +6,23 @@ upload_file: "Upload File"
upload_folder: "Upload Folder"
hidden: "Hidden"
download: "Download"
download_folder: "Download Folder"
download_count: "Download (%{count})"
name: "NAME"
size: "SIZE"
modified: "MODIFIED"
new_ssh_connection: "New SSH Connection"
create_or_edit_ssh_session: "Create or edit an SSH session"
new_folder: "New Folder"
create_folder_success: "Created folder: %{name}"
create_folder_failed: "Failed to create folder: %{err}"
delete_selected: "Delete"
confirm_delete: "Confirm Delete"
confirm_delete_desc: "Are you sure you want to delete the selected %{count} items? This action cannot be undone."
system_path_warning: "WARNING: You are attempting to delete critical system paths! This may cause system corruption!"
delete_success: "Successfully deleted %{count} items"
delete_failed: "Failed to delete: %{err}"
deleting_paths: "Deleting %{count} items..."
local_terminal: "Local Terminal"
open_local_shell_tab: "Open a new local shell tab"
connect: "Connect"
@@ -56,7 +67,10 @@ 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."
monitoring_position: "Monitoring Position"
position_bottom: "Bottom"
position_sidebar: "Sidebar"
position_hidden: "Hidden"
local_host: "local host"
local_terminal_opened: "local terminal opened"
connecting: "Connecting"
@@ -80,7 +94,7 @@ 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."
reset_layout_hint: "Reset panel layout proportion. Window size will take effect on next startup."
transfers: "Transfers"
no_transfers_yet: "No transfers yet"
@@ -89,9 +103,54 @@ paused: "Paused"
completed: "Completed"
failed: "Failed"
cancelled: "Cancelled"
interrupted: "Interrupted"
session: "Session"
zombie: "Zombie"
zombie_reason: "This transfer was interrupted because the application exited. The transfer task no longer exists."
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."
copy_paste_hint: "Regardless of this switch, you can always use %{key} + C/V to copy/paste."
edit_file: "Edit File"
edit_file_tooltip: "Download to temporary directory, open in default editor, and auto upload on save."
auto_saved_and_uploaded: "Auto-saved & uploaded: %{base}"
scroll: "Scroll ↓"
clone: "Clone"
settings_general: "General"
settings_key_bindings: "Key Bindings"
settings_help: "Help"
settings_about: "About"
settings_group_appearance: "Appearance"
settings_group_font: "Font"
settings_group_other: "Other"
settings_group_keybind_general: "General"
settings_group_keybind_zoom: "Zoom"
settings_group_keybind_focus: "Focus"
settings_group_keybind_panel: "Panel"
theme: "Theme"
theme_mode: "Theme Mode"
clear_transfers: "Clear History"
transfers_limit: " (Max 100 records)"
press_new_key: "Press new key..."
settings_open_settings: "Open Settings"
settings_open_session: "Open Session"
settings_open_transfers: "Open Transfer History"
files_downloading: "%{count} files downloading"
files_uploading: "%{count} files uploading"
panel_minimize: "Minimize Panel"
panel_expand: "Expand Panel"
settings_new_ssh: "New SSH"
settings_toggle_sidebar: "Toggle Sidebar"
settings_toggle_sftp_zoom: "Toggle SFTP Zoom"
settings_focus_pane_left: "Focus Pane Left"
settings_focus_pane_right: "Focus Pane Right"
settings_focus_pane_up: "Focus Pane Up"
settings_focus_pane_down: "Focus Pane Down"
settings_split_pane_left: "Split Pane Left"
settings_split_pane_right: "Split Pane Right"
settings_split_pane_up: "Split Pane Up"
settings_split_pane_down: "Split Pane Down"
settings_close_pane: "Close Pane"
keybind_conflict: '"%{key}" is already used by "%{action}", please choose another key'
+64 -3
View File
@@ -6,12 +6,24 @@ upload_file: "上传文件"
upload_folder: "上传文件夹"
hidden: "显示隐藏文件"
download: "下载"
download_folder: "下载文件夹"
download_count: "下载 (%{count})"
name: "名称"
size: "大小"
modified: "修改时间"
new_ssh_connection: "新建 SSH 连接"
create_or_edit_ssh_session: "创建或编辑一个 SSH 会话"
new_folder: "新建文件夹"
create_folder_success: "成功创建文件夹: %{name}"
create_folder_failed: "创建文件夹失败: %{err}"
delete_selected: "删除"
confirm_delete: "确认删除"
confirm_delete_desc: "确定要删除以下选中的 %{count} 个项吗?此操作无法撤销。"
system_path_warning: "警告:您即将删除系统关键路径!这可能会导致系统损坏!"
delete_success: "成功删除 %{count} 个项"
delete_failed: "删除失败: %{err}"
deleting_paths: "正在删除 %{count} 个项..."
confirm: "确认"
local_terminal: "本地终端"
open_local_shell_tab: "打开一个新的本地终端页面"
connect: "连接"
@@ -56,7 +68,10 @@ terminal_font_size: "终端字体大小"
system_default: "系统默认"
ui_font_family: "界面字体"
terminal_font_family: "终端字体"
theme_management_hint: "主题模式和亮/暗主题系列可通过顶栏的主题按钮管理。"
monitoring_position: "系统监控显示位置"
position_bottom: "底部"
position_sidebar: "侧边栏"
position_hidden: "不显示"
local_host: "本地主机"
local_terminal_opened: "已打开本地终端"
connecting: "正在连接"
@@ -80,7 +95,7 @@ n_folders: "%{folders} 个文件夹"
n_files_and_folders: "%{files} 个文件及 %{folders} 个文件夹"
reset: "重置"
reset_layout: "恢复默认布局"
reset_layout_success: "布局占比已恢复默认。窗口大小将在下次启动时生效。"
reset_layout_hint: "重置面板布局占比,窗口大小将在下次启动时生效"
transfers: "传输记录"
no_transfers_yet: "暂无传输记录"
@@ -89,9 +104,55 @@ paused: "已暂停"
completed: "已完成"
failed: "失败"
cancelled: "已取消"
interrupted: "已中断"
session: "会话"
zombie: "僵尸任务"
zombie_reason: "此传输任务因程序退出而中断,传输进程已不存在。"
software_builtin: "软件内置"
retry: "重试"
right_click_copy_paste: "右键复制/粘贴"
copy_paste_hint: "提示:不管是否开启此开关,都可以使用 %{key} + C/V 快捷键进行复制和粘贴。"
copy_paste_hint: "不管是否开启此开关,都可以使用 %{key} + C/V 快捷键进行复制和粘贴。"
edit_file: "编辑文件"
edit_file_tooltip: "下载到临时目录,使用默认编辑器打开,并在保存后自动上传。"
auto_saved_and_uploaded: "已自动保存并上传: %{base}"
scroll: "滑动查看 ↓"
clone: "克隆"
about_feedback_hint: "如果遇到问题或者有什么建议,请前往我们的 GitHub 项目中反馈。"
settings_general: "常规"
settings_key_bindings: "快捷键"
settings_help: "帮助"
settings_about: "关于"
settings_group_appearance: "外观"
settings_group_font: "字体"
settings_group_other: "其它"
settings_group_keybind_general: "常规"
settings_group_keybind_zoom: "缩放"
settings_group_keybind_focus: "焦点"
settings_group_keybind_panel: "面板"
theme: "主题"
theme_mode: "主题模式"
clear_transfers: "清空记录"
transfers_limit: " (最多保留 100 条)"
press_new_key: "请按下新的快捷键..."
settings_open_settings: "打开设置"
settings_open_session: "打开会话"
settings_open_transfers: "打开传输历史"
files_downloading: "%{count} 个文件正在下载"
files_uploading: "%{count} 个文件正在上传"
panel_minimize: "折叠面板"
panel_expand: "展开面板"
settings_new_ssh: "新建 SSH"
settings_toggle_sidebar: "缩放侧边栏"
settings_toggle_sftp_zoom: "缩放 SFTP 面板"
settings_focus_pane_left: "向左切换焦点"
settings_focus_pane_right: "向右切换焦点"
settings_focus_pane_up: "向上切换焦点"
settings_focus_pane_down: "向下切换焦点"
settings_split_pane_left: "向左拆分面板"
settings_split_pane_right: "向右拆分面板"
settings_split_pane_up: "向上拆分面板"
settings_split_pane_down: "向下拆分面板"
settings_close_pane: "关闭当前面板"
keybind_conflict: '"%{key}" 已被「%{action}」使用,请选择其他快捷键'
+8
View File
@@ -0,0 +1,8 @@
pub(crate) const DEFAULT_COLS: u16 = 100;
pub(crate) const DEFAULT_ROWS: u16 = 30;
pub(crate) const SIDEBAR_WIDTH: f32 = 306.0;
pub(crate) const COLLAPSED_SIDEBAR_WIDTH: f32 = 52.0;
pub(crate) const TAB_BAR_HEIGHT: f32 = 52.0;
pub(crate) const TERMINAL_PADDING_X: f32 = 32.0;
pub(crate) const TERMINAL_PADDING_Y: f32 = 32.0;
pub(crate) const TERMINAL_KEY_CONTEXT: &str = "AshellTerminal";
+1660
View File
File diff suppressed because it is too large Load Diff
+391
View File
@@ -0,0 +1,391 @@
use gpui::{
Action as _, App, Entity, IntoElement, KeyBinding, KeyDownEvent, Keystroke, Unbind, prelude::*,
};
use gpui_component::{
Sizable,
button::{Button, ButtonVariants},
kbd::Kbd,
setting::{SettingField, SettingGroup, SettingItem},
};
use rust_i18n::t;
use crate::{Ashell, session::config::ConfigStore};
gpui::actions!(
ashell_workspace,
[
OpenSettings,
OpenSession,
OpenTransfers,
NewSsh,
ToggleSidebar,
ToggleSftpZoom,
FocusPaneLeft,
FocusPaneRight,
FocusPaneUp,
FocusPaneDown,
SplitPaneLeft,
SplitPaneRight,
SplitPaneUp,
SplitPaneDown,
ClosePane
]
);
pub struct KeybindingsPage;
#[derive(Clone, Copy)]
pub(crate) struct WorkspaceAction {
id: &'static str,
label_key: &'static str,
default_suffix: &'static str,
}
pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[
WorkspaceAction {
id: "OpenSettings",
label_key: "settings_open_settings",
default_suffix: ",",
},
WorkspaceAction {
id: "OpenSession",
label_key: "settings_open_session",
default_suffix: "o",
},
WorkspaceAction {
id: "OpenTransfers",
label_key: "settings_open_transfers",
default_suffix: "t",
},
WorkspaceAction {
id: "NewSsh",
label_key: "settings_new_ssh",
default_suffix: "n",
},
WorkspaceAction {
id: "ToggleSidebar",
label_key: "settings_toggle_sidebar",
default_suffix: "s",
},
WorkspaceAction {
id: "ToggleSftpZoom",
label_key: "settings_toggle_sftp_zoom",
default_suffix: "m",
},
WorkspaceAction {
id: "FocusPaneLeft",
label_key: "settings_focus_pane_left",
default_suffix: "h",
},
WorkspaceAction {
id: "FocusPaneRight",
label_key: "settings_focus_pane_right",
default_suffix: "l",
},
WorkspaceAction {
id: "FocusPaneUp",
label_key: "settings_focus_pane_up",
default_suffix: "k",
},
WorkspaceAction {
id: "FocusPaneDown",
label_key: "settings_focus_pane_down",
default_suffix: "j",
},
WorkspaceAction {
id: "SplitPaneLeft",
label_key: "settings_split_pane_left",
default_suffix: "shift-h",
},
WorkspaceAction {
id: "SplitPaneRight",
label_key: "settings_split_pane_right",
default_suffix: "shift-l",
},
WorkspaceAction {
id: "SplitPaneUp",
label_key: "settings_split_pane_up",
default_suffix: "shift-k",
},
WorkspaceAction {
id: "SplitPaneDown",
label_key: "settings_split_pane_down",
default_suffix: "shift-j",
},
WorkspaceAction {
id: "ClosePane",
label_key: "settings_close_pane",
default_suffix: "w",
},
];
pub(crate) fn default_modifier() -> &'static str {
if cfg!(target_os = "macos") {
"cmd"
} else {
"ctrl"
}
}
pub(crate) fn default_keystroke(action_id: &str) -> Option<String> {
WORKSPACE_ACTIONS
.iter()
.find(|action| action.id == action_id)
.map(|action| format!("{}-{}", default_modifier(), action.default_suffix))
}
pub(crate) fn configured_keystroke(config: &ConfigStore, action_id: &str) -> Option<String> {
config
.key_bindings()
.get(action_id)
.cloned()
.or_else(|| default_keystroke(action_id))
}
pub(crate) fn normalize_recorded_keystroke(event: &KeyDownEvent) -> Option<String> {
let key = event.keystroke.key.trim();
if key.is_empty() {
return None;
}
let mut parts = Vec::new();
if event.keystroke.modifiers.control {
parts.push("ctrl".to_string());
}
if event.keystroke.modifiers.alt {
parts.push("alt".to_string());
}
if event.keystroke.modifiers.shift {
parts.push("shift".to_string());
}
if event.keystroke.modifiers.platform {
parts.push("cmd".to_string());
}
if event.keystroke.modifiers.function {
parts.push("fn".to_string());
}
parts.push(key.to_ascii_lowercase());
let keystroke = parts.join("-");
Keystroke::parse(&keystroke).ok().map(|_| keystroke)
}
pub(crate) fn format_keystroke(keystroke: &str) -> String {
Keystroke::parse(keystroke)
.map(|stroke| Kbd::format(&stroke))
.unwrap_or_else(|_| keystroke.to_string())
}
pub(crate) fn bind_workspace_keys_from_config(cx: &mut App, config: &ConfigStore) {
bind_workspace_actions(cx, config);
}
/// Unbind all workspace keybindings (used when entering keybinding settings to prevent interference).
pub(crate) fn unbind_all_workspace_keys(cx: &mut App, config: &ConfigStore) {
let mut bindings = Vec::new();
macro_rules! unbind_action {
($id:literal, $action:expr) => {
let default = default_keystroke($id).expect("workspace action has default key");
let configured = configured_keystroke(config, $id).unwrap_or_else(|| default.clone());
let action_name = $action.name();
// Unbind both the default and configured keystroke
bindings.push(KeyBinding::new(&default, Unbind(action_name.into()), None));
if configured != default {
bindings.push(KeyBinding::new(
&configured,
Unbind(action_name.into()),
None,
));
}
};
}
unbind_action!("OpenSettings", crate::OpenSettings);
unbind_action!("OpenSession", crate::OpenSession);
unbind_action!("OpenTransfers", crate::OpenTransfers);
unbind_action!("NewSsh", crate::NewSsh);
unbind_action!("ToggleSidebar", crate::ToggleSidebar);
unbind_action!("ToggleSftpZoom", crate::ToggleSftpZoom);
unbind_action!("FocusPaneLeft", crate::FocusPaneLeft);
unbind_action!("FocusPaneRight", crate::FocusPaneRight);
unbind_action!("FocusPaneUp", crate::FocusPaneUp);
unbind_action!("FocusPaneDown", crate::FocusPaneDown);
unbind_action!("SplitPaneLeft", crate::SplitPaneLeft);
unbind_action!("SplitPaneRight", crate::SplitPaneRight);
unbind_action!("SplitPaneUp", crate::SplitPaneUp);
unbind_action!("SplitPaneDown", crate::SplitPaneDown);
unbind_action!("ClosePane", crate::ClosePane);
cx.bind_keys(bindings);
}
/// Check if a keystroke conflicts with any other action's binding.
/// Returns Some((conflicting_action_id, label)) if there is a conflict.
pub(crate) fn find_conflict(
config: &ConfigStore,
current_action_id: &str,
new_keystroke: &str,
) -> Option<(String, String)> {
for action in WORKSPACE_ACTIONS {
if action.id == current_action_id {
continue;
}
let existing = configured_keystroke(config, action.id).unwrap_or_default();
if !existing.is_empty() && existing == new_keystroke {
return Some((action.id.to_string(), t!(action.label_key).to_string()));
}
}
None
}
fn bind_workspace_actions(cx: &mut App, config: &ConfigStore) {
let mut bindings = Vec::new();
macro_rules! bind_action {
($id:literal, $action:expr) => {
let default = default_keystroke($id).expect("workspace action has default key");
let configured = configured_keystroke(config, $id).unwrap_or_else(|| default.clone());
let action_name = $action.name();
if configured != default {
bindings.push(KeyBinding::new(&default, Unbind(action_name.into()), None));
}
bindings.push(KeyBinding::new(&configured, $action, None));
};
}
bind_action!("OpenSettings", crate::OpenSettings);
bind_action!("OpenSession", crate::OpenSession);
bind_action!("OpenTransfers", crate::OpenTransfers);
bind_action!("NewSsh", crate::NewSsh);
bind_action!("ToggleSidebar", crate::ToggleSidebar);
bind_action!("ToggleSftpZoom", crate::ToggleSftpZoom);
bind_action!("FocusPaneLeft", crate::FocusPaneLeft);
bind_action!("FocusPaneRight", crate::FocusPaneRight);
bind_action!("FocusPaneUp", crate::FocusPaneUp);
bind_action!("FocusPaneDown", crate::FocusPaneDown);
bind_action!("SplitPaneLeft", crate::SplitPaneLeft);
bind_action!("SplitPaneRight", crate::SplitPaneRight);
bind_action!("SplitPaneUp", crate::SplitPaneUp);
bind_action!("SplitPaneDown", crate::SplitPaneDown);
bind_action!("ClosePane", crate::ClosePane);
cx.bind_keys(bindings);
}
impl KeybindingsPage {
pub fn render_groups(view: &Entity<Ashell>, cx: &mut App) -> Vec<SettingGroup> {
let groups = [
(
"settings_group_keybind_general",
vec!["OpenSettings", "OpenSession", "OpenTransfers", "NewSsh"],
),
(
"settings_group_keybind_zoom",
vec!["ToggleSidebar", "ToggleSftpZoom"],
),
(
"settings_group_keybind_focus",
vec![
"FocusPaneLeft",
"FocusPaneRight",
"FocusPaneUp",
"FocusPaneDown",
],
),
(
"settings_group_keybind_panel",
vec![
"SplitPaneLeft",
"SplitPaneRight",
"SplitPaneUp",
"SplitPaneDown",
"ClosePane",
],
),
];
let mut result = Vec::new();
for (group_label, action_ids) in groups {
let mut group = SettingGroup::new().title(t!(group_label).to_string());
for action_id in action_ids {
let action = WORKSPACE_ACTIONS
.iter()
.find(|a| a.id == action_id)
.expect("action exists");
let recording = view.read(cx).recording_action.as_deref() == Some(action.id);
let has_error = view
.read(cx)
.keybind_error
.as_ref()
.is_some_and(|(id, _)| id == action.id);
let error_msg = if has_error {
view.read(cx)
.keybind_error
.as_ref()
.map(|(_, msg)| msg.clone())
} else {
None
};
let keystroke = {
let config = &view.read(cx).config;
configured_keystroke(config, action.id).unwrap_or_default()
};
let btn_label = if recording {
t!("press_new_key").to_string()
} else if keystroke.is_empty() {
t!("none").to_string()
} else {
format_keystroke(&keystroke)
};
let mut item = SettingItem::new(
t!(action.label_key).to_string(),
SettingField::render({
let view = view.clone();
let action_id = action.id.to_string();
move |_, _window, _cx| {
Button::new(gpui::SharedString::from(format!("keybind-{action_id}")))
.label(btn_label.clone())
.small()
.when(recording, |this| this.primary())
.when(has_error, |this| this.danger())
.on_click({
let view = view.clone();
let action_id = action_id.clone();
move |_event, window, cx| {
view.update(cx, |this, cx| {
// Clear any previous error when starting new recording
this.keybind_error = None;
this.recording_action = Some(action_id.clone());
this.focus_handle.focus(window, cx);
cx.notify();
});
}
})
.into_any_element()
}
}),
);
if let Some(msg) = error_msg {
item = item.description(msg);
}
group = group.item(item);
}
result.push(group);
}
result
}
}
+895
View File
@@ -0,0 +1,895 @@
pub mod constants;
pub mod dialogs;
pub mod keybinding_recorder;
pub mod startup;
pub mod theme;
pub mod ui;
use std::{
cell::{Cell, RefCell},
collections::HashMap,
ops::Range,
rc::Rc,
sync::mpsc,
time::{Duration, Instant},
};
use gpui::{
AppContext as _, Bounds, Context, Entity, FocusHandle, Pixels, Point, SharedString, Size,
UniformListScrollHandle, Window, point, px, size,
};
use gpui_component::{
Theme, ThemeMode, ThemeRegistry,
input::{InputEvent, InputState},
resizable::ResizableState,
scroll::ScrollbarHandle,
};
use rust_i18n::t;
use tokio::runtime::Runtime;
use crate::{
session::config::{AuthMethod, ConfigStore},
system::{SystemSampler, SystemSnapshot},
terminal::{self, BackendEvent, TabKind, TerminalTab},
};
#[derive(Clone, Debug)]
pub(crate) enum PaneLayout {
Single(String),
Horizontal(Vec<PaneLayout>, f32), // children, split_ratio (0.0-1.0)
Vertical(Vec<PaneLayout>, f32), // children, split_ratio (0.0-1.0)
}
#[derive(Clone)]
pub(crate) struct TabGroup {
pub(crate) id: String,
pub(crate) title: String,
pub(crate) pane_root: PaneLayout,
pub(crate) sftp: Option<crate::terminal::SftpUiState>,
}
impl PaneLayout {
pub fn tab_ids(&self) -> Vec<&str> {
match self {
PaneLayout::Single(id) => vec![id.as_str()],
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
children.iter().flat_map(|c| c.tab_ids()).collect()
}
}
}
pub fn contains(&self, tab_id: &str) -> bool {
match self {
PaneLayout::Single(id) => id == tab_id,
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
children.iter().any(|c| c.contains(tab_id))
}
}
}
pub fn focused_tab_id(&self, path: &[usize]) -> Option<&str> {
match self {
PaneLayout::Single(id) if path.is_empty() => Some(id.as_str()),
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
let (&first, rest) = path.split_first()?;
children.get(first).and_then(|c| c.focused_tab_id(rest))
}
_ => None,
}
}
pub fn replace_at(&mut self, path: &[usize], replacement: PaneLayout) {
match (self, path) {
(this @ PaneLayout::Single(_), []) => *this = replacement,
(
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _),
[first, rest @ ..],
) => {
if let Some(child) = children.get_mut(*first) {
child.replace_at(rest, replacement);
}
}
_ => {}
}
}
pub fn remove_tab(&mut self, tab_id: &str) -> bool {
match self {
PaneLayout::Single(id) if id == tab_id => {
*self = PaneLayout::Single(String::new());
true
}
PaneLayout::Single(_) => false,
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
for child in children.iter_mut() {
child.remove_tab(tab_id);
}
children.retain(|c| !matches!(c, PaneLayout::Single(id) if id.is_empty()));
if children.is_empty() {
*self = PaneLayout::Single(String::new());
} else if children.len() == 1 {
if let Some(replacement) = children.pop() {
*self = replacement;
}
}
true
}
}
}
#[allow(dead_code)]
pub fn total_panes(&self) -> usize {
match self {
PaneLayout::Single(_) => 1,
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
children.iter().map(|c| c.total_panes()).sum()
}
}
}
}
pub(crate) struct TerminalScrollbarState {
line_height: Pixels,
total_lines: usize,
viewport_lines: usize,
display_offset: usize,
}
#[derive(Clone, Default)]
pub(crate) struct TerminalScrollbarHandle {
state: Rc<RefCell<Option<TerminalScrollbarState>>>,
pub(crate) future_display_offset: Rc<Cell<Option<usize>>>,
}
impl TerminalScrollbarHandle {
pub(crate) fn update(&self, snapshot: &terminal::RenderSnapshot, line_height: Pixels) {
self.state.replace(Some(TerminalScrollbarState {
line_height,
total_lines: snapshot.history_size + snapshot.rows,
viewport_lines: snapshot.rows,
display_offset: snapshot.display_offset,
}));
}
}
impl ScrollbarHandle for TerminalScrollbarHandle {
fn offset(&self) -> Point<Pixels> {
let state_ref = self.state.borrow();
let Some(state) = state_ref.as_ref() else {
return point(px(0.), px(0.));
};
let scroll_offset = state
.total_lines
.saturating_sub(state.viewport_lines)
.saturating_sub(state.display_offset);
point(px(0.), -(scroll_offset as f32 * state.line_height))
}
fn set_offset(&self, offset: Point<Pixels>) {
let state_ref = self.state.borrow();
let Some(state) = state_ref.as_ref() else {
return;
};
let offset_delta = (offset.y / state.line_height).round() as i32;
let max_offset = state.total_lines.saturating_sub(state.viewport_lines);
let display_offset = (max_offset as i32 + offset_delta).clamp(0, max_offset as i32);
self.future_display_offset
.set(Some(display_offset as usize));
}
fn content_size(&self) -> Size<Pixels> {
let state_ref = self.state.borrow();
let Some(state) = state_ref.as_ref() else {
return size(px(0.), px(0.));
};
size(
px(0.),
state.total_lines.max(state.viewport_lines) as f32 * state.line_height,
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DialogKind {
Settings,
SessionSelector,
Transfers,
NewSsh,
}
pub(crate) struct Ashell {
pub(crate) focus_handle: FocusHandle,
pub(crate) selector_focus_handle: FocusHandle,
pub(crate) host_input: Entity<InputState>,
pub(crate) session_name_input: Entity<InputState>,
pub(crate) port_input: Entity<InputState>,
pub(crate) user_input: Entity<InputState>,
pub(crate) password_input: Entity<InputState>,
pub(crate) key_path_input: Entity<InputState>,
pub(crate) key_inline_input: Entity<InputState>,
pub(crate) passphrase_input: Entity<InputState>,
pub(crate) sftp_path_input: Entity<InputState>,
pub(crate) ssh_auth_method: AuthMethod,
pub(crate) editing_session_id: Option<String>,
pub(crate) follow_system_theme: bool,
pub(crate) theme_mode: ThemeMode,
pub(crate) light_theme_name: SharedString,
pub(crate) dark_theme_name: SharedString,
pub(crate) ui_font_size: f32,
pub(crate) terminal_font_size: f32,
pub(crate) ui_font_family: SharedString,
pub(crate) terminal_font_family: SharedString,
pub(crate) tabs: Vec<TerminalTab>,
pub(crate) active_tab: Option<String>,
pub(crate) tab_groups: Vec<TabGroup>,
pub(crate) active_group: Option<String>,
pub(crate) selector_selection: usize,
pub(crate) workspace_panels: Entity<ResizableState>,
pub(crate) body_panels: Entity<ResizableState>,
pub(crate) is_layout_reset: bool,
pub(crate) terminal_scrollbars: HashMap<String, TerminalScrollbarHandle>,
pub(crate) remote_files_scroll_handle: UniformListScrollHandle,
pub(crate) disk_scroll_handle: gpui::ScrollHandle,
pub(crate) tabs_scroll_handle: gpui::ScrollHandle,
pub(crate) selector_scroll_handle: gpui::ScrollHandle,
pub(crate) saved_scroll_handle: gpui::ScrollHandle,
pub(crate) connection_scroll_handle: gpui::ScrollHandle,
pub(crate) connection_progress: Option<ConnectionProgress>,
pub(crate) pending_sftp_path_sync: Option<String>,
pub(crate) sftp_context_menu: Option<SftpContextMenuState>,
pub(crate) sftp_creating_folder: bool,
pub(crate) sftp_new_folder_input: Entity<InputState>,
pub(crate) sftp_delete_scroll_handle: gpui::ScrollHandle,
pub(crate) show_hidden_files: bool,
pub(crate) transfers: Vec<crate::terminal::Transfer>,
pub(crate) show_transfers_dialog: bool,
pub(crate) system_status: Option<SharedString>,
pub(crate) pane_root: PaneLayout,
pub(crate) focused_pane_path: Vec<usize>,
pub(crate) terminal_panel_bounds: Option<Bounds<Pixels>>,
pub(crate) terminal_bounds: HashMap<String, Bounds<Pixels>>,
pub(crate) terminal_selecting: bool,
pub(crate) dragging_splitter: Option<(Vec<usize>, usize)>, // (parent_path, child_index)
pub(crate) drag_split_origin: Option<gpui::Point<Pixels>>,
pub(crate) terminal_marked_text: Option<String>,
pub(crate) sftp_panel_minimized: bool,
pub(crate) sidebar_collapsed: bool,
pub(crate) collapsed_saved_scroll_handle: gpui::ScrollHandle,
pub(crate) prev_monitoring_size: Option<Pixels>,
pub(crate) status: SharedString,
pub(crate) config: ConfigStore,
pub(crate) system_sampler: SystemSampler,
pub(crate) recording_action: Option<String>,
pub(crate) active_dialog: Option<DialogKind>,
/// Error message when a recorded keybinding conflicts with another
pub(crate) keybind_error: Option<(String, String)>, // (action_id, error_message)
/// Whether workspace keybindings are currently suspended (during settings)
pub(crate) keybinds_suspended: bool,
pub(crate) system: SystemSnapshot,
pub(crate) cpu_history: Vec<f32>,
pub(crate) net_rx_history: Vec<f32>,
pub(crate) net_tx_history: Vec<f32>,
pub(crate) last_system_sample: Instant,
pub(crate) last_theme_sync: Instant,
pub(crate) system_tab_id: Option<String>,
pub(crate) sftp_handles: std::collections::HashMap<String, crate::sftp::SftpHandle>,
pub(crate) remote_sample_in_flight: bool,
pub(crate) runtime: Runtime,
pub(crate) events_rx: mpsc::Receiver<BackendEvent>,
pub(crate) events_tx: mpsc::Sender<BackendEvent>,
pub(crate) _subscriptions: Vec<gpui::Subscription>,
}
#[derive(Clone)]
pub(crate) enum SelectorEntry {
Local,
NewSsh,
Saved(String),
}
#[derive(Clone)]
pub(crate) struct ConnectionProgress {
pub(crate) tab_id: String,
pub(crate) title: SharedString,
pub(crate) lines: Vec<SharedString>,
pub(crate) failed: bool,
}
#[derive(Clone)]
pub(crate) struct SftpContextMenuState {
pub(crate) remote_path: String,
pub(crate) is_dir: bool,
pub(crate) position: Point<Pixels>,
}
impl Ashell {
fn transfer_source_title(&self, tab_id: &str) -> String {
self.tabs
.iter()
.find(|tab| tab.id == tab_id)
.map(|tab| tab.title.clone())
.or_else(|| {
self.tab_groups
.iter()
.find(|group| group.id == tab_id)
.map(|group| group.title.clone())
})
.or_else(|| {
self.tab_groups
.iter()
.find(|group| group.pane_root.contains(tab_id))
.map(|group| group.title.clone())
})
.unwrap_or_else(|| "Unknown".to_string())
}
pub(crate) fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let host_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("host")));
let session_name_input =
cx.new(|cx| InputState::new(window, cx).placeholder("name (optional)"));
let port_input = cx.new(|cx| InputState::new(window, cx).default_value("22"));
let user_input = cx.new(|cx| InputState::new(window, cx).default_value("root"));
let password_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("password"))
.masked(true)
});
let key_path_input =
cx.new(|cx| InputState::new(window, cx).placeholder("~/.ssh/id_ed25519"));
let key_inline_input = cx.new(|cx| {
InputState::new(window, cx)
.multi_line(true)
.rows(5)
.placeholder("-----BEGIN OPENSSH PRIVATE KEY-----")
});
let passphrase_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("SSH private key passphrase (optional)")
.masked(true)
});
let sftp_path_input = cx.new(|cx| InputState::new(window, cx).default_value("/"));
let sftp_new_folder_input =
cx.new(|cx| InputState::new(window, cx).placeholder(t!("new_folder").to_string()));
let _subscriptions = vec![
cx.subscribe_in(&host_input, window, Self::on_input_event),
cx.subscribe_in(&session_name_input, window, Self::on_input_event),
cx.subscribe_in(&port_input, window, Self::on_input_event),
cx.subscribe_in(&user_input, window, Self::on_input_event),
cx.subscribe_in(&password_input, window, Self::on_input_event),
cx.subscribe_in(&key_path_input, window, Self::on_input_event),
cx.subscribe_in(&key_inline_input, window, Self::on_input_event),
cx.subscribe_in(&passphrase_input, window, Self::on_input_event),
cx.subscribe_in(&sftp_path_input, window, Self::on_input_event),
cx.subscribe_in(&sftp_new_folder_input, window, Self::on_input_event),
];
let (events_tx, events_rx) = mpsc::channel();
let workspace_panels = cx.new(|_| ResizableState::default());
let body_panels = cx.new(|_| ResizableState::default());
let mut system_sampler = SystemSampler::new();
let system = system_sampler.sample();
let default_light_theme_name = ThemeRegistry::global(cx).default_light_theme().name.clone();
let default_dark_theme_name = ThemeRegistry::global(cx).default_dark_theme().name.clone();
let config = ConfigStore::load().unwrap_or_else(|err| {
tracing::warn!("failed to load config: {err:#}");
ConfigStore::in_memory()
});
let follow_system_theme = config.follow_system_theme();
let theme_mode = match config.theme_mode() {
"light" => ThemeMode::Light,
"dark" => ThemeMode::Dark,
_ => ThemeMode::Light,
};
let light_theme_name = if config.light_theme_name().is_empty() {
default_light_theme_name
} else {
config.light_theme_name().into()
};
let dark_theme_name = if config.dark_theme_name().is_empty() {
default_dark_theme_name
} else {
config.dark_theme_name().into()
};
let configured_locale = config.locale();
let mut active_locale = configured_locale.to_string();
if active_locale == "system" {
active_locale = sys_locale::get_locale().unwrap_or_else(|| "en".to_string());
if active_locale.starts_with("zh") {
active_locale = "zh-CN".to_string();
} else {
active_locale = "en".to_string();
}
}
rust_i18n::set_locale(&active_locale);
gpui_component::set_locale(&active_locale);
let ui_font_family: SharedString = config.ui_font_family().into();
let terminal_font_family: SharedString = config.terminal_font_family().into();
let mut this = Self {
focus_handle: cx.focus_handle(),
selector_focus_handle: cx.focus_handle(),
host_input,
session_name_input,
port_input,
user_input,
password_input,
key_path_input,
key_inline_input,
passphrase_input,
sftp_path_input,
ssh_auth_method: AuthMethod::Password,
editing_session_id: None,
follow_system_theme,
theme_mode,
light_theme_name,
dark_theme_name,
ui_font_size: config.ui_font_size(),
terminal_font_size: config.terminal_font_size(),
ui_font_family,
terminal_font_family,
tabs: Vec::new(),
active_tab: None,
tab_groups: Vec::new(),
active_group: None,
pane_root: PaneLayout::Single(String::new()),
focused_pane_path: Vec::new(),
terminal_panel_bounds: None,
selector_selection: 0,
workspace_panels,
body_panels,
is_layout_reset: false,
terminal_scrollbars: HashMap::new(),
remote_files_scroll_handle: UniformListScrollHandle::new(),
disk_scroll_handle: gpui::ScrollHandle::new(),
tabs_scroll_handle: gpui::ScrollHandle::new(),
selector_scroll_handle: gpui::ScrollHandle::new(),
saved_scroll_handle: gpui::ScrollHandle::new(),
connection_scroll_handle: gpui::ScrollHandle::new(),
connection_progress: None,
pending_sftp_path_sync: Some("/".into()),
sftp_context_menu: None,
sftp_creating_folder: false,
sftp_new_folder_input,
sftp_delete_scroll_handle: gpui::ScrollHandle::new(),
show_hidden_files: config.show_hidden_files(),
transfers: {
let mut transfers = config.transfers();
for t in transfers.iter_mut() {
if matches!(
t.state,
crate::terminal::TransferState::Running
| crate::terminal::TransferState::Paused
) {
t.state =
crate::terminal::TransferState::Zombie(t!("zombie_reason").to_string());
}
}
transfers
},
show_transfers_dialog: false,
system_status: None,
terminal_bounds: HashMap::new(),
terminal_selecting: false,
terminal_marked_text: None,
dragging_splitter: None,
drag_split_origin: None,
sftp_panel_minimized: false,
sidebar_collapsed: config.sidebar_collapsed(),
collapsed_saved_scroll_handle: gpui::ScrollHandle::new(),
prev_monitoring_size: None,
status: "ready".into(),
config,
system_sampler,
recording_action: None,
active_dialog: None,
keybind_error: None,
keybinds_suspended: false,
system,
cpu_history: Vec::with_capacity(20),
net_rx_history: Vec::with_capacity(20),
net_tx_history: Vec::with_capacity(20),
last_system_sample: Instant::now(),
last_theme_sync: Instant::now(),
system_tab_id: None,
sftp_handles: std::collections::HashMap::new(),
remote_sample_in_flight: false,
runtime: Runtime::new().expect("create tokio runtime"),
events_rx,
events_tx,
_subscriptions,
};
this.apply_theme_preferences(window, cx);
// this.open_local(cx);
this.start_event_pump(cx);
this
}
pub(crate) fn on_input_event(
&mut self,
input: &Entity<InputState>,
event: &InputEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if input == &self.sftp_path_input {
if let InputEvent::PressEnter { .. } = event {
let path = self
.sftp_path_input
.read(cx)
.text()
.to_string()
.trim()
.to_string();
self.navigate_sftp(if path.is_empty() { "/".into() } else { path }, cx);
window.prevent_default();
cx.stop_propagation();
}
} else if input == &self.sftp_new_folder_input {
match event {
InputEvent::PressEnter { .. } => {
let name = self.sftp_new_folder_input.read(cx).text().to_string();
if !name.is_empty() {
let base_path = self.sftp_path_input.read(cx).text().to_string();
let path = crate::sftp::join_remote(&base_path, &name);
if let Some(handle) = self.active_sftp_handle() {
let _ = handle
.commands
.send(crate::sftp::SftpCommand::CreateDir(path));
}
}
self.sftp_creating_folder = false;
window.prevent_default();
cx.stop_propagation();
}
InputEvent::Blur => {
self.sftp_creating_folder = false;
}
_ => {}
}
}
cx.notify();
}
pub(crate) fn start_event_pump(&self, cx: &mut Context<Self>) {
cx.spawn(async move |this, cx| {
let mut idle_frames = 0u32;
loop {
cx.background_executor()
.timer(Duration::from_millis(16))
.await;
if this
.update(cx, |this, cx| {
let changed = this.drain_backend_events();
let system_sampled = this.sample_system_if_due();
this.sync_theme_if_due(cx);
if changed || system_sampled {
cx.notify();
idle_frames = 0;
} else {
idle_frames += 1;
if idle_frames >= 60 {
cx.notify();
idle_frames = 0;
}
}
})
.is_err()
{
break;
}
}
})
.detach();
}
pub(crate) fn drain_backend_events(&mut self) -> bool {
let mut changed = false;
let mut transfers_changed = false;
while let Ok(event) = self.events_rx.try_recv() {
changed = true;
match event {
BackendEvent::Output { tab_id, bytes } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.feed(&bytes);
}
}
BackendEvent::Status { tab_id, text } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.status = text.clone();
}
if let Some(progress) = self.connection_progress.as_mut() {
if progress.tab_id == tab_id {
progress.lines.push(text.clone().into());
let _idx = progress.lines.len().saturating_sub(1);
self.connection_scroll_handle
.set_offset(point(px(0.), px(-99999.0)));
}
}
self.status = text.into();
}
BackendEvent::Connected { tab_id } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.connected = true;
}
self.sync_system_tab_to_active_group();
self.request_active_system_snapshot();
if self
.connection_progress
.as_ref()
.is_some_and(|progress| progress.tab_id == tab_id && !progress.failed)
{
self.connection_progress = None;
}
}
BackendEvent::SftpEntries {
tab_id,
path,
entries,
} => {
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == tab_id) {
if let Some(sftp) = group.sftp.as_mut() {
sftp.current_path = path;
sftp.entries = entries;
self.pending_sftp_path_sync = Some(sftp.current_path.clone());
}
}
}
BackendEvent::SftpPreview { tab_id, preview } => {
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == tab_id) {
if let Some(sftp) = group.sftp.as_mut() {
sftp.selected_path = Some(preview.path.clone());
sftp.preview = Some(preview);
}
}
}
BackendEvent::SftpStatus { tab_id, text } => {
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == tab_id) {
if let Some(sftp) = group.sftp.as_mut() {
sftp.status = text.clone();
}
}
if self.active_group.as_ref() == Some(&tab_id) {
self.status = text.into();
}
}
BackendEvent::RemoteSystem { tab_id, snapshot } => {
self.remote_sample_in_flight = false;
if self.system_tab_id.as_deref() == Some(tab_id.as_str()) {
self.system_status = None;
self.system = snapshot.clone();
self.cpu_history.push(snapshot.cpu_percent);
if self.cpu_history.len() > 20 {
self.cpu_history.remove(0);
}
self.net_rx_history.push(snapshot.net_rx_rate as f32);
if self.net_rx_history.len() > 20 {
self.net_rx_history.remove(0);
}
self.net_tx_history.push(snapshot.net_tx_rate as f32);
if self.net_tx_history.len() > 20 {
self.net_tx_history.remove(0);
}
}
}
BackendEvent::RemoteSystemUnavailable { tab_id, reason } => {
self.remote_sample_in_flight = false;
if self.system_tab_id.as_deref() == Some(tab_id.as_str()) {
self.system_status = Some(reason.clone().into());
self.status = reason.into();
}
}
BackendEvent::Closed { tab_id, reason } => {
self.remote_sample_in_flight = false;
let mut tab_title = None;
let mut session_label = None;
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.connected = false;
tab.status = reason.clone();
tab_title = Some(tab.title.clone());
session_label = tab.session.as_ref().map(|session| {
format!("{}@{}:{}", session.user, session.host, session.port)
});
}
if self.system_tab_id.as_deref() == Some(tab_id.as_str()) {
self.system_status = Some(reason.clone().into());
}
let is_graceful_exit =
reason == "local shell closed" || reason == "ssh session closed";
// Auto-close the pane on graceful exit (e.g. user typed exit)
if is_graceful_exit {
self.handle_tab_close(tab_id.clone());
self.status = reason.into();
self.remote_sample_in_flight = false;
return changed;
}
let mut needs_new_progress = false;
if let Some(progress) = self.connection_progress.as_mut() {
if progress.tab_id == tab_id {
progress.lines.push(reason.clone().into());
let _idx = progress.lines.len().saturating_sub(1);
self.connection_scroll_handle
.set_offset(point(px(0.), px(-99999.0)));
let _ = session_label;
let _ = tab_title;
progress.title = t!("connection_failed").into();
progress.failed = true;
} else if !progress.failed {
// We were showing connecting progress, but another tab dropped!
// Switch to failed state so the user can see it and retry.
progress.tab_id = tab_id.clone();
let msg = format!("{}: {}", tab_title.unwrap_or_default(), reason);
progress.lines.push(msg.into());
self.connection_scroll_handle
.set_offset(point(px(0.), px(-99999.0)));
progress.title = t!("connection_failed").into();
progress.failed = true;
} else {
// Already showing a failure dialog, just append the new failure
let msg = format!("{}: {}", tab_title.unwrap_or_default(), reason);
progress.lines.push(msg.into());
self.connection_scroll_handle
.set_offset(point(px(0.), px(-99999.0)));
}
} else if let Some(_) = session_label {
needs_new_progress = true;
}
if needs_new_progress && !is_graceful_exit {
self.connection_progress = Some(ConnectionProgress {
tab_id: tab_id.clone(),
title: t!("connection_failed").into(),
lines: vec![reason.clone().into()],
failed: true,
});
}
self.status = reason.into();
}
BackendEvent::TransferProgress {
tab_id: _,
id,
transferred,
total,
state,
} => {
if let Some(t) = self.transfers.iter_mut().find(|t| t.info.id == id) {
t.transferred = transferred;
if let Some(total) = total {
t.total = Some(total);
}
t.state = state;
transfers_changed = true;
}
}
BackendEvent::TransferStarted { tab_id, info } => {
let tab_title = self.transfer_source_title(&tab_id);
self.transfers.insert(
0,
crate::terminal::Transfer {
tab_id,
tab_title,
info,
transferred: 0,
total: None,
state: crate::terminal::TransferState::Running,
},
);
if self.transfers.len() > 100 {
self.transfers.truncate(100);
}
transfers_changed = true;
}
BackendEvent::SftpHome { tab_id, home } => {
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == tab_id) {
if let Some(sftp) = group.sftp.as_mut() {
sftp.home_dir = home;
}
}
}
BackendEvent::TerminalTitleChanged { tab_id, title } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.title = title.clone();
}
}
}
}
if transfers_changed {
self.config.set_transfers(self.transfers.clone());
}
changed
}
pub(crate) fn sample_system_if_due(&mut self) -> bool {
if self.last_system_sample.elapsed() >= SystemSampler::interval() {
self.last_system_sample = Instant::now();
// Use system_tab_id (not active_tab) to decide remote vs local sampling
if let Some(ref tab_id) = self.system_tab_id.clone() {
if self
.tabs
.iter()
.any(|t| t.id == *tab_id && t.kind == TabKind::Ssh && t.connected)
&& self.system_status.is_none()
{
self.request_active_system_snapshot();
return false;
}
}
let snapshot = self.system_sampler.sample();
let cpu_usage = snapshot.cpu_percent;
self.cpu_history.push(cpu_usage);
if self.cpu_history.len() > 20 {
self.cpu_history.remove(0);
}
self.net_rx_history.push(snapshot.net_rx_rate as f32);
if self.net_rx_history.len() > 20 {
self.net_rx_history.remove(0);
}
self.net_tx_history.push(snapshot.net_tx_rate as f32);
if self.net_tx_history.len() > 20 {
self.net_tx_history.remove(0);
}
self.system = snapshot;
return true;
}
false
}
pub(crate) fn sync_theme_if_due(&mut self, cx: &mut Context<Self>) {
if self.follow_system_theme && self.last_theme_sync.elapsed() >= Duration::from_secs(1) {
self.last_theme_sync = Instant::now();
Theme::sync_system_appearance(None, cx);
cx.refresh_windows();
}
}
pub(crate) fn request_active_system_snapshot(&mut self) {
let Some(ref tab_id) = self.system_tab_id.clone() else {
return;
};
let Some(backend) = (|| {
let tab = self.tabs.iter().find(|t| t.id == *tab_id)?;
if !tab.connected {
return None;
}
Some(tab.backend.clone())
})() else {
return;
};
if self.remote_sample_in_flight {
return;
}
self.remote_sample_in_flight = true;
backend.send(crate::terminal::BackendCommand::SampleMetrics);
}
pub(crate) fn terminal_ime_bounds_for_range(
&self,
range_utf16: Range<usize>,
element_bounds: Bounds<Pixels>,
cell_width: f32,
line_height: f32,
) -> Option<Bounds<Pixels>> {
let snapshot = self.active_snapshot()?;
let cursor = snapshot.cursor?;
let x = element_bounds.origin.x
+ px(cell_width) * cursor.col as f32
+ px(cell_width) * range_utf16.start as f32;
let y = element_bounds.origin.y + px(line_height) * cursor.row as f32;
Some(Bounds::new(
point(x, y),
size(px(cell_width), px(line_height)),
))
}
pub(crate) fn remove_transfer(&mut self, transfer_id: &str, cx: &mut Context<Self>) {
self.transfers.retain(|t| t.info.id != transfer_id);
self.config.set_transfers(self.transfers.clone());
cx.notify();
}
}
+325
View File
@@ -0,0 +1,325 @@
use gpui::{App, AppContext as _, Bounds, WindowOptions, point, px, size};
use gpui_component::Root;
use crate::Ashell;
use crate::session::config::ConfigStore;
pub(crate) fn bind_workspace_keys(cx: &mut gpui::App) {
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
crate::app::keybinding_recorder::bind_workspace_keys_from_config(cx, &config);
}
struct LocalMinutelyRoller {
dir: std::path::PathBuf,
prefix: String,
current_minute: u32,
file: Option<std::fs::File>,
}
impl LocalMinutelyRoller {
fn new(dir: std::path::PathBuf, prefix: String) -> Self {
Self {
dir,
prefix,
current_minute: 60,
file: None,
}
}
fn rollover(&mut self, now: chrono::DateTime<chrono::Local>) -> std::io::Result<()> {
use chrono::Timelike;
let minute = now.minute();
if self.current_minute != minute || self.file.is_none() {
let filename = format!("{}-{}.log", self.prefix, now.format("%Y-%m-%d-%H-%M"));
let path = self.dir.join(filename);
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
self.file = Some(file);
self.current_minute = minute;
// Cleanup old files keeping last 6
if let Ok(entries) = std::fs::read_dir(&self.dir) {
let mut files: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with(&self.prefix))
.collect();
files.sort_by_key(|e| {
e.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
});
if files.len() > 6 {
for file in files.iter().take(files.len() - 6) {
let _ = std::fs::remove_file(file.path());
}
}
}
}
Ok(())
}
}
impl std::io::Write for LocalMinutelyRoller {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let now = chrono::Local::now();
let _ = self.rollover(now);
if let Some(f) = &mut self.file {
f.write(buf)
} else {
Ok(buf.len())
}
}
fn flush(&mut self) -> std::io::Result<()> {
if let Some(f) = &mut self.file {
f.flush()
} else {
Ok(())
}
}
}
pub(crate) fn init_logging() {
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
let log_dir = directories::BaseDirs::new()
.map(|dirs| dirs.home_dir().join(".config").join("ashell").join("log"))
.unwrap_or_else(|| std::path::PathBuf::from("."));
std::fs::create_dir_all(&log_dir).ok();
let roller = LocalMinutelyRoller::new(log_dir.clone(), "ashell".to_string());
let (non_blocking, _guard) = tracing_appender::non_blocking(roller);
// Leak the guard so it lives for the entire duration of the app since GPUI's run might not return
std::mem::forget(_guard);
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
let stdout_layer = if cfg!(debug_assertions) {
Some(
tracing_subscriber::fmt::layer()
.with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339())
.with_target(true),
)
} else {
None
};
let file_layer = tracing_subscriber::fmt::layer()
.with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339())
.with_writer(non_blocking)
.with_ansi(false)
.with_target(true);
tracing_subscriber::registry()
.with(env_filter)
.with(stdout_layer)
.with(file_layer)
.init();
}
#[cfg(target_os = "macos")]
pub(crate) fn sync_macos_launch_environment() {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
let Ok(output) = std::process::Command::new(&shell)
.args(["-l", "-c", "env -0"])
.output()
else {
return;
};
if !output.status.success() {
return;
}
for entry in output.stdout.split(|b| *b == 0) {
if entry.is_empty() {
continue;
}
let Some(eq) = entry.iter().position(|b| *b == b'=') else {
continue;
};
let Ok(key) = std::str::from_utf8(&entry[..eq]) else {
continue;
};
let Ok(value) = std::str::from_utf8(&entry[eq + 1..]) else {
continue;
};
let should_import = matches!(
key,
"PATH"
| "MANPATH"
| "INFOPATH"
| "LANG"
| "LC_ALL"
| "LC_CTYPE"
| "SHELL"
| "HOME"
| "HOMEBREW_PREFIX"
| "HOMEBREW_CELLAR"
| "HOMEBREW_REPOSITORY"
) || key.starts_with("LC_");
if should_import {
unsafe {
std::env::set_var(key, value);
}
}
}
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn sync_macos_launch_environment() {}
pub(crate) fn open_main_window(cx: &mut App) {
let mut window_options = WindowOptions::default();
#[cfg(not(target_os = "macos"))]
if let Ok(img) = image::load_from_memory(include_bytes!("../../assets/icons/ashell.png")) {
window_options.icon = Some(std::sync::Arc::new(img.into_rgba8()));
}
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
if let Some(bounds) = config.window_bounds() {
window_options.window_bounds = Some(match bounds {
crate::session::config::SavedWindowBounds::Fullscreen {
x,
y,
width,
height,
} => gpui::WindowBounds::Fullscreen(Bounds::new(
point(px(*x), px(*y)),
size(px(*width), px(*height)),
)),
crate::session::config::SavedWindowBounds::Maximized {
x,
y,
width,
height,
} => gpui::WindowBounds::Maximized(Bounds::new(
point(px(*x), px(*y)),
size(px(*width), px(*height)),
)),
crate::session::config::SavedWindowBounds::Windowed {
x,
y,
width,
height,
} => gpui::WindowBounds::Windowed(Bounds::new(
point(px(*x), px(*y)),
size(px(*width), px(*height)),
)),
});
} else if let Some(display) = cx.displays().first().cloned() {
let display_bounds = display.bounds();
let width = display_bounds.size.width * 0.8;
let height = display_bounds.size.height * 0.9;
let x = display_bounds.origin.x + (display_bounds.size.width - width) / 2.0;
#[cfg(target_os = "macos")]
let y = display_bounds.origin.y;
#[cfg(not(target_os = "macos"))]
let y = display_bounds.origin.y + (display_bounds.size.height - height) / 2.0;
window_options.window_bounds = Some(gpui::WindowBounds::Windowed(Bounds::new(
point(x, y),
size(width, height),
)));
}
cx.open_window(window_options, |window, cx| {
window.activate_window();
window.set_window_title("ashell");
gpui_component::Theme::sync_system_appearance(Some(window), cx);
let view = cx.new(|cx| Ashell::new(window, cx));
tracing::info!("[ui] main application window opened");
let focus_handle = view.read(cx).focus_handle.clone();
window.focus(&focus_handle, cx);
let sftp_minimized = view.read(cx).config.sftp_panel_minimized();
if sftp_minimized {
window.defer(cx, {
let view = view.clone();
move |window, cx| {
view.update(cx, |this, cx| {
this.toggle_sftp_minimized(window, cx);
});
}
});
}
let workspace_panels_clone = view.read(cx).workspace_panels.clone();
let body_panels_clone = view.read(cx).body_panels.clone();
let view_clone = view.clone();
window.on_window_should_close(cx, move |window: &mut gpui::Window, cx: &mut gpui::App| {
if view_clone.read(cx).is_layout_reset {
tracing::info!("[ui] layout was reset, skipping save layout state.");
return true;
}
tracing::info!("[ui] main application window closed, saving layout state...");
let mut config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
let current_bounds = window.window_bounds();
let saved_bounds = match current_bounds {
gpui::WindowBounds::Fullscreen(b) => {
crate::session::config::SavedWindowBounds::Fullscreen {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
gpui::WindowBounds::Maximized(b) => {
crate::session::config::SavedWindowBounds::Maximized {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
gpui::WindowBounds::Windowed(b) => {
crate::session::config::SavedWindowBounds::Windowed {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
};
let workspace_sizes: Vec<f32> = workspace_panels_clone
.read(cx)
.sizes()
.iter()
.map(|s| s.into())
.collect();
let mut body_sizes: Vec<f32> = body_panels_clone
.read(cx)
.sizes()
.iter()
.map(|s| s.into())
.collect();
if view_clone.read(cx).sftp_panel_minimized {
if let Some(prev) = view_clone.read(cx).prev_monitoring_size {
if body_sizes.len() > 1 {
body_sizes[1] = prev.into();
}
}
}
config.set_layout_state(Some(saved_bounds), Some(workspace_sizes), Some(body_sizes));
config.set_sidebar_collapsed(view_clone.read(cx).sidebar_collapsed);
config.set_sftp_panel_minimized(view_clone.read(cx).sftp_panel_minimized);
let _ = config.save();
true
});
cx.new(|cx| Root::new(view, window, cx))
})
.expect("failed to open window");
}
+162
View File
@@ -0,0 +1,162 @@
use anyhow::{Context as _, Result};
use gpui::{App, Context, SharedString, Window, px};
use gpui_component::{ActiveTheme as _, Theme, ThemeMode, ThemeRegistry};
use crate::Ashell;
pub(crate) const EMBEDDED_THEME_JSONS: &[&str] = &[
include_str!("../../assets/themes/matrix.json"),
include_str!("../../assets/themes/tokyonight.json"),
include_str!("../../assets/themes/gruvbox.json"),
include_str!("../../assets/themes/solarized.json"),
];
pub(crate) fn load_fonts(cx: &mut App) -> Result<()> {
let regular = std::borrow::Cow::Borrowed(
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Regular.ttf").as_slice(),
);
let bold = std::borrow::Cow::Borrowed(
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Bold.ttf").as_slice(),
);
cx.text_system()
.add_fonts(vec![regular, bold])
.context("load Maple Mono NF CN fonts")?;
set_theme_font_names(cx.global_mut::<Theme>(), ".SystemUIFont");
Ok(())
}
pub(crate) fn load_embedded_themes(cx: &mut App) {
let registry = ThemeRegistry::global_mut(cx);
for theme_json in EMBEDDED_THEME_JSONS {
if let Err(err) = registry.load_themes_from_str(theme_json) {
tracing::warn!("failed to load embedded theme: {err:#}");
}
}
}
pub(crate) fn set_theme_font_names(theme: &mut Theme, ui_font_family: &str) {
theme.font_family = ui_font_family.into();
theme.mono_font_family = ui_font_family.into();
}
impl Ashell {
pub(crate) fn switch_theme_mode(
&mut self,
mode: ThemeMode,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.follow_system_theme = false;
self.theme_mode = mode;
self.apply_theme_preferences(window, cx);
self.status = format!("theme mode: {}", cx.theme().mode.name()).into();
self.persist_theme_preferences();
cx.notify();
}
pub(crate) fn apply_theme(
&mut self,
name: SharedString,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(theme_config) = ThemeRegistry::global(cx).themes().get(&name).cloned() else {
self.status = format!("theme not found: {name}").into();
cx.notify();
return;
};
if theme_config.mode.is_dark() {
self.dark_theme_name = name.clone();
} else {
self.light_theme_name = name.clone();
}
self.apply_theme_preferences(window, cx);
self.status = format!("theme: {name}").into();
self.persist_theme_preferences();
window.refresh();
cx.notify();
}
pub(crate) fn set_follow_system_theme(
&mut self,
follow: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.follow_system_theme = follow;
if follow {
self.status = "theme mode: system".into();
} else {
self.status = format!("theme mode: {}", cx.theme().mode.name()).into();
}
self.apply_theme_preferences(window, cx);
self.persist_theme_preferences();
cx.notify();
}
pub(crate) fn set_display_language(
&mut self,
locale: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.config.set_locale(locale);
let mut active_locale = locale.to_string();
if active_locale == "system" {
active_locale = sys_locale::get_locale().unwrap_or_else(|| "en".to_string());
if active_locale.starts_with("zh") {
active_locale = "zh-CN".to_string();
} else {
active_locale = "en".to_string();
}
}
rust_i18n::set_locale(&active_locale);
gpui_component::set_locale(&active_locale);
if let Err(err) = self.config.save() {
tracing::warn!("failed to save language preferences: {err:#}");
}
window.refresh();
cx.notify();
}
pub(crate) fn apply_theme_preferences(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let light_theme = ThemeRegistry::global(cx)
.themes()
.get(&self.light_theme_name)
.cloned()
.unwrap_or_else(|| ThemeRegistry::global(cx).default_light_theme().clone());
let dark_theme = ThemeRegistry::global(cx)
.themes()
.get(&self.dark_theme_name)
.cloned()
.unwrap_or_else(|| ThemeRegistry::global(cx).default_dark_theme().clone());
let theme = Theme::global_mut(cx);
theme.light_theme = light_theme;
theme.dark_theme = dark_theme;
theme.font_size = px(self.ui_font_size);
set_theme_font_names(theme, &self.ui_font_family);
if self.follow_system_theme {
Theme::sync_system_appearance(Some(window), cx);
} else {
Theme::change(self.theme_mode, Some(window), cx);
}
}
pub(crate) fn persist_theme_preferences(&mut self) {
let theme_mode_str = match self.theme_mode {
ThemeMode::Light => "light",
ThemeMode::Dark => "dark",
};
self.config.set_theme_preferences(
self.follow_system_theme,
theme_mode_str,
self.light_theme_name.to_string(),
self.dark_theme_name.to_string(),
);
if let Err(err) = self.config.save() {
tracing::warn!("failed to save theme preferences: {err:#}");
}
}
}
+2686
View File
File diff suppressed because it is too large Load Diff
+30 -17
View File
@@ -94,27 +94,40 @@ pub fn spawn_local_terminal(
let write_tab = tab_id.clone();
let write_events = events.clone();
thread::spawn(move || {
while let Ok(command) = cmd_rx.recv() {
match command {
BackendCommand::Input(bytes) => {
if let Err(err) = writer.write_all(&bytes) {
loop {
match cmd_rx.recv_timeout(std::time::Duration::from_millis(100)) {
Ok(command) => match command {
BackendCommand::Input(bytes) => {
if let Err(err) = writer.write_all(&bytes) {
let _ = write_events.send(BackendEvent::Closed {
tab_id: write_tab.clone(),
reason: format!("local write error: {err}"),
});
break;
}
let _ = writer.flush();
}
BackendCommand::Resize { cols, rows } => {
let _ = master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
}
BackendCommand::Close => break,
BackendCommand::SampleMetrics => {}
},
Err(mpsc::RecvTimeoutError::Timeout) => {
if let Ok(Some(status)) = child.try_wait() {
let _ = write_events.send(BackendEvent::Closed {
tab_id: write_tab.clone(),
reason: format!("local write error: {err}"),
tab_id: write_tab,
reason: format!("local shell exited: {status}"),
});
break;
return;
}
let _ = writer.flush();
}
BackendCommand::Resize { cols, rows } => {
let _ = master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
}
BackendCommand::Close => break,
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
let _ = child.kill();
+2
View File
@@ -0,0 +1,2 @@
pub mod local;
pub mod ssh;
+154 -37
View File
@@ -14,7 +14,7 @@ use russh::{
use tokio::sync::mpsc;
use crate::{
config::{AuthMethod, Session},
session::config::{AuthMethod, Session},
system::{SystemSnapshot, remote_snapshot_from_kv},
terminal::{BackendCommand, BackendEvent, BackendTx},
};
@@ -49,10 +49,12 @@ pub fn spawn_ssh_terminal(
BackendTx::Ssh(cmd_tx)
}
pub async fn sample_remote_system(session: Session) -> Result<SystemSnapshot> {
let (events_tx, _events_rx) = std::sync::mpsc::channel();
let handle = connect_and_authenticate("remote-metrics", &session, &events_tx).await?;
async fn sample_remote_system_with_handle(
handle: Arc<tokio::sync::Mutex<russh::client::Handle<ClientHandler>>>,
) -> Result<SystemSnapshot> {
let mut channel = handle
.lock()
.await
.channel_open_session()
.await
.context("open metrics session")?;
@@ -72,9 +74,6 @@ pub async fn sample_remote_system(session: Session) -> Result<SystemSnapshot> {
}
}
let _ = handle
.disconnect(Disconnect::ByApplication, "metrics done", "")
.await;
let output = String::from_utf8_lossy(&stdout);
remote_snapshot_from_kv(&output)
}
@@ -95,9 +94,13 @@ async fn run_ssh(
),
});
let handle = connect_and_authenticate(&tab_id, &session, &events).await?;
let handle = Arc::new(tokio::sync::Mutex::new(
connect_and_authenticate(&tab_id, &session, &events).await?,
));
let mut channel = handle
.lock()
.await
.channel_open_session()
.await
.context("open session")?;
@@ -115,24 +118,48 @@ async fn run_ssh(
tab_id: tab_id.clone(),
});
let exit_reason;
let mut is_graceful_close = false;
loop {
tokio::select! {
command = commands.recv() => {
match command {
Some(BackendCommand::Input(bytes)) => {
if let Err(err) = channel.data(bytes.as_slice()).await {
let _ = events.send(BackendEvent::Closed {
tab_id: tab_id.clone(),
reason: format!("ssh write error: {err}"),
});
tracing::error!("[ssh] write error on tab {}: {}", tab_id, err);
exit_reason = format!("ssh write error: {err}");
break;
}
}
Some(BackendCommand::Resize { cols, rows }) => {
let _ = channel.window_change(cols.into(), rows.into(), 0, 0).await;
}
Some(BackendCommand::SampleMetrics) => {
let handle_clone = handle.clone();
let tab_id_clone = tab_id.clone();
let events_clone = events.clone();
tokio::spawn(async move {
match sample_remote_system_with_handle(handle_clone).await {
Ok(snapshot) => {
let _ = events_clone.send(BackendEvent::RemoteSystem {
tab_id: tab_id_clone,
snapshot,
});
}
Err(err) => {
let _ = events_clone.send(BackendEvent::RemoteSystemUnavailable {
tab_id: tab_id_clone,
reason: format!("remote metrics unavailable: {err:#}"),
});
}
}
});
}
Some(BackendCommand::Close) | None => {
tracing::info!("[ssh] local client closed the session for tab {}", tab_id);
let _ = channel.eof().await;
exit_reason = "ssh session closed".to_string();
break;
}
}
@@ -145,7 +172,29 @@ async fn run_ssh(
bytes: data.to_vec(),
});
}
Some(ChannelMsg::Close) | None => break,
Some(ChannelMsg::ExitStatus { exit_status: _ }) | Some(ChannelMsg::Eof) => {
is_graceful_close = true;
}
Some(ChannelMsg::Close) => {
if is_graceful_close {
tracing::info!("[ssh] session gracefully closed by server for tab {}", tab_id);
exit_reason = "ssh session closed".to_string();
} else {
tracing::warn!("[ssh] connection abruptly closed by server for tab {}", tab_id);
exit_reason = "ssh connection lost (abrupt close)".to_string();
}
break;
}
None => {
if is_graceful_close {
tracing::info!("[ssh] network stream ended gracefully for tab {}", tab_id);
exit_reason = "ssh session closed".to_string();
} else {
tracing::warn!("[ssh] network drop detected for tab {}", tab_id);
exit_reason = "ssh connection lost (network drop)".to_string();
}
break;
}
_ => {}
}
}
@@ -153,11 +202,13 @@ async fn run_ssh(
}
let _ = handle
.lock()
.await
.disconnect(Disconnect::ByApplication, "bye", "")
.await;
let _ = events.send(BackendEvent::Closed {
tab_id,
reason: "ssh session closed".into(),
reason: exit_reason,
});
Ok(())
}
@@ -169,9 +220,16 @@ async fn connect_and_authenticate(
) -> Result<russh::client::Handle<ClientHandler>> {
let config = Arc::new(client::Config {
inactivity_timeout: Some(std::time::Duration::from_secs(600)),
keepalive_interval: Some(std::time::Duration::from_secs(10)),
keepalive_max: 3,
..Default::default()
});
let addr = format!("{}:{}", session.host, session.port);
tracing::info!(
"[ssh] initiating tcp connection to {} (user: {})",
addr,
session.user
);
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!("opening tcp connection to {addr}"),
@@ -180,8 +238,15 @@ async fn connect_and_authenticate(
.await
.with_context(|| format!("connect {addr} failed"))?;
tracing::debug!("[ssh] tcp connected to {}", addr);
let authed = match session.auth {
AuthMethod::Password => {
tracing::info!(
"[ssh] sending password authentication for {}@{}",
session.user,
addr
);
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!(
@@ -196,6 +261,12 @@ async fn connect_and_authenticate(
}
AuthMethod::Key => {
let source = key_source_label(session);
tracing::info!(
"[ssh] sending key authentication for {}@{} (key source: {})",
session.user,
addr,
source
);
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!("connected to {addr}, loading private key from {source}"),
@@ -206,20 +277,40 @@ async fn connect_and_authenticate(
tab_id: tab_id.to_string(),
text: format!("private key loaded from {source}, algorithm {algorithm}, sending public key authentication for {}", session.user),
});
let key = private_key_with_alg(keypair).context("invalid private key")?;
handle
.authenticate_publickey(&session.user, key)
.await
.with_context(|| {
format!(
"public key authentication failed for {}@{}:{} using {} ({})",
session.user, session.host, session.port, source, algorithm
)
})?
let keys = private_keys_with_algs(keypair).context("invalid private key")?;
let mut success = false;
for key in keys {
match handle.authenticate_publickey(&session.user, key).await {
Ok(true) => {
success = true;
break;
}
Ok(false) => {
tracing::debug!("[ssh] public key auth failed with algorithm, trying next");
continue;
}
Err(e) => {
tracing::debug!("[ssh] public key auth error: {:?}, trying next", e);
continue;
}
}
}
if !success {
return Err(anyhow::anyhow!(
"public key authentication failed for {}@{}:{} using {} ({})",
session.user,
session.host,
session.port,
source,
algorithm
));
}
success
}
};
if !authed {
tracing::warn!("[ssh] authentication failed for {}@{}", session.user, addr);
let _ = handle
.disconnect(Disconnect::ByApplication, "auth failed", "")
.await;
@@ -241,6 +332,12 @@ async fn connect_and_authenticate(
));
}
tracing::info!(
"[ssh] authentication successful for {}@{}",
session.user,
addr
);
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!(
@@ -255,6 +352,8 @@ async fn connect_and_authenticate(
fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
let inline_key = normalize_inline_private_key(&session.private_key_inline);
let key_path = expand_key_path(session.private_key_path.trim());
let passphrase = session.passphrase.trim();
let passphrase = (!passphrase.is_empty()).then_some(passphrase);
let has_inline = !inline_key.is_empty();
let has_path = key_path.is_some();
@@ -265,14 +364,14 @@ fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
let mut errors = Vec::new();
if has_inline {
match decode_secret_key(&inline_key, None) {
match decode_secret_key(&inline_key, passphrase) {
Ok(key) => return Ok(key),
Err(err) => errors.push(format!("decode private key content: {err}")),
}
}
if let Some(path) = key_path {
match load_secret_key(path.as_path(), None) {
match load_secret_key(path.as_path(), passphrase) {
Ok(key) => return Ok(key),
Err(err) => errors.push(format!("load key {}: {err}", path.display())),
}
@@ -281,16 +380,33 @@ fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
Err(anyhow!(errors.join("; ")))
}
fn private_key_with_alg(keypair: PrivateKey) -> Result<PrivateKeyWithHashAlg> {
let hash_alg = if keypair.algorithm().is_rsa() {
Some(HashAlg::Sha512)
fn private_keys_with_algs(keypair: PrivateKey) -> Result<Vec<PrivateKeyWithHashAlg>> {
let mut algs = Vec::new();
let key_arc = Arc::new(keypair);
if key_arc.algorithm().is_rsa() {
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha512)) {
algs.push(k);
}
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha256)) {
algs.push(k);
}
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
algs.push(k);
}
} else {
None
};
Ok(
PrivateKeyWithHashAlg::new(Arc::new(keypair.clone()), hash_alg)
.or_else(|_| PrivateKeyWithHashAlg::new(Arc::new(keypair), Some(HashAlg::Sha256)))?,
)
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
algs.push(k);
}
}
if algs.is_empty() {
return Err(anyhow!(
"Failed to construct PrivateKeyWithHashAlg for any supported hash algorithm"
));
}
Ok(algs)
}
fn normalize_inline_private_key(value: &str) -> String {
@@ -365,7 +481,7 @@ EOF
echo "SWAP_USED=$(( ${swap_total:-0} - ${swap_free:-0} ))"
echo "NET_RX=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))"
echo "NET_TX=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))"
df -kP 2>/dev/null | awk "NR > 1 { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 4
df -kP 2>/dev/null | awk "NR > 1 && \$1 !~ /^(tmpfs|devtmpfs|ramfs|overlay|aufs)\$/ { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 6
exit 0
fi
@@ -404,7 +520,7 @@ EOF
echo "SWAP_USED=${swap_used:-0}"
echo "NET_RX=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))"
echo "NET_TX=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))"
df -kP 2>/dev/null | awk "NR > 1 { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 4
df -kP 2>/dev/null | awk "NR > 1 && \$1 !~ /^(devfs|tmpfs|devtmpfs|ramfs|overlay|aufs)\$/ { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 6
exit 0
fi
@@ -417,6 +533,7 @@ echo "NET_RX=0"
echo "NET_TX=0"
'"#;
#[derive(Clone)]
struct ClientHandler;
#[async_trait]
+32 -4359
View File
File diff suppressed because it is too large Load Diff
+122 -5
View File
@@ -27,6 +27,8 @@ pub struct Session {
#[serde(default)]
pub private_key_inline: String,
#[serde(default)]
pub passphrase: String,
#[serde(default)]
pub last_used: Option<String>,
}
@@ -43,6 +45,7 @@ impl Session {
password,
private_key_path: String::new(),
private_key_inline: String::new(),
passphrase: String::new(),
last_used: None,
}
}
@@ -53,6 +56,7 @@ impl Session {
user: String,
private_key_path: String,
private_key_inline: String,
passphrase: String,
) -> Self {
let name = format!("{user}@{host}");
Self {
@@ -65,6 +69,7 @@ impl Session {
password: String::new(),
private_key_path,
private_key_inline,
passphrase,
last_used: None,
}
}
@@ -95,7 +100,7 @@ pub enum SavedWindowBounds {
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConfigFile {
#[serde(default)]
#[serde(default = "default_follow_system_theme")]
pub follow_system_theme: bool,
#[serde(default)]
pub theme_mode: String,
@@ -125,6 +130,24 @@ pub struct ConfigFile {
pub body_panels: Option<Vec<f32>>,
#[serde(default)]
pub transfers: Vec<crate::terminal::Transfer>,
#[serde(default)]
pub show_hidden_files: bool,
#[serde(default = "default_monitoring_position")]
pub monitoring_position: String,
#[serde(default)]
pub sidebar_collapsed: bool,
#[serde(default)]
pub sftp_panel_minimized: bool,
#[serde(default)]
pub key_bindings: std::collections::HashMap<String, String>,
}
fn default_monitoring_position() -> String {
"Sidebar".to_string()
}
fn default_follow_system_theme() -> bool {
true
}
fn default_locale() -> String {
@@ -132,11 +155,11 @@ fn default_locale() -> String {
}
fn default_terminal_font_size() -> f32 {
13.0
18.0
}
fn default_ui_font_size() -> f32 {
12.0
14.0
}
pub fn default_ui_font_family() -> String {
@@ -160,12 +183,44 @@ impl ConfigStore {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create config dir {}", parent.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(mut perms) = fs::metadata(parent).map(|m| m.permissions()) {
perms.set_mode(0o700);
let _ = fs::set_permissions(parent, perms);
}
}
let tmp_dir = parent.join("tmp");
let _ = fs::remove_dir_all(&tmp_dir);
let _ = fs::create_dir_all(&tmp_dir);
}
let cache = if path.exists() {
let raw = fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
serde_json::from_str::<ConfigFile>(&raw).unwrap_or_default()
match serde_json::from_str::<ConfigFile>(&raw) {
Ok(cache) => cache,
Err(err) => {
let backup_path = path.with_extension("json.bak");
if let Err(backup_err) = fs::write(&backup_path, raw.as_bytes()) {
tracing::warn!(
"failed to parse config {}; backup to {} also failed: {backup_err:#}; parse error: {err:#}",
path.display(),
backup_path.display(),
);
} else {
tracing::warn!(
"failed to parse config {}; backed up the original to {} and loaded defaults: {err:#}",
path.display(),
backup_path.display(),
);
}
ConfigFile::default()
}
}
} else {
ConfigFile::default()
};
@@ -193,6 +248,10 @@ impl ConfigStore {
&self.cache.sessions
}
pub fn tmp_dir(&self) -> Option<PathBuf> {
self.path.parent().map(|p| p.join("tmp"))
}
pub fn follow_system_theme(&self) -> bool {
self.cache.follow_system_theme
}
@@ -221,6 +280,28 @@ impl ConfigStore {
self.cache.locale = locale.to_string();
}
pub fn key_bindings(&self) -> &std::collections::HashMap<String, String> {
&self.cache.key_bindings
}
pub fn set_key_binding(&mut self, action_name: &str, keystroke: &str) {
self.cache
.key_bindings
.insert(action_name.to_string(), keystroke.to_string());
}
pub fn monitoring_position(&self) -> &str {
if self.cache.monitoring_position.is_empty() {
"Sidebar"
} else {
&self.cache.monitoring_position
}
}
pub fn set_monitoring_position(&mut self, pos: &str) {
self.cache.monitoring_position = pos.to_string();
}
pub fn terminal_font_size(&self) -> f32 {
if self.cache.terminal_font_size <= 0.0 {
default_terminal_font_size()
@@ -250,6 +331,7 @@ impl ConfigStore {
self.cache.workspace_panels.as_ref()
}
#[allow(dead_code)]
pub fn body_panels(&self) -> Option<&Vec<f32>> {
self.cache.body_panels.as_ref()
}
@@ -324,6 +406,30 @@ impl ConfigStore {
self.cache.terminal_font_family = family.to_string();
}
pub fn show_hidden_files(&self) -> bool {
self.cache.show_hidden_files
}
pub fn set_show_hidden_files(&mut self, val: bool) {
self.cache.show_hidden_files = val;
}
pub fn sidebar_collapsed(&self) -> bool {
self.cache.sidebar_collapsed
}
pub fn set_sidebar_collapsed(&mut self, val: bool) {
self.cache.sidebar_collapsed = val;
}
pub fn sftp_panel_minimized(&self) -> bool {
self.cache.sftp_panel_minimized
}
pub fn set_sftp_panel_minimized(&mut self, val: bool) {
self.cache.sftp_panel_minimized = val;
}
pub fn get(&self, id: &str) -> Option<&Session> {
self.cache.sessions.iter().find(|s| s.id == id)
}
@@ -346,6 +452,17 @@ impl ConfigStore {
}
let raw = serde_json::to_string_pretty(&self.cache)?;
fs::write(&self.path, raw)
.with_context(|| format!("failed to write {}", self.path.display()))
.with_context(|| format!("failed to write {}", self.path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(mut perms) = fs::metadata(&self.path).map(|m| m.permissions()) {
perms.set_mode(0o600);
let _ = fs::set_permissions(&self.path, perms);
}
}
Ok(())
}
}
+1341
View File
File diff suppressed because it is too large Load Diff
+591 -66
View File
@@ -1,3 +1,5 @@
pub mod ops;
use std::{
fs,
path::{Path, PathBuf},
@@ -27,7 +29,7 @@ use zip::read::ZipArchive;
use rust_i18n::t;
use crate::{
config::{AuthMethod, Session},
session::config::{AuthMethod, Session},
terminal::BackendEvent,
};
@@ -58,6 +60,15 @@ pub enum SftpCommand {
remote: String,
local_dir: String,
},
EditFile {
remote_path: String,
},
CreateDir(String),
DeletePaths(Vec<String>),
UploadEditedFile {
local_path: String,
remote_path: String,
},
UploadPaths {
locals: Vec<String>,
remote_dir: String,
@@ -77,11 +88,17 @@ impl TransferStateFlag {
pub fn new() -> Self {
Self(Arc::new(AtomicU8::new(0)))
}
pub fn pause(&self) { self.0.store(1, Ordering::SeqCst); }
pub fn resume(&self) { self.0.store(0, Ordering::SeqCst); }
pub fn cancel(&self) { self.0.store(2, Ordering::SeqCst); }
pub fn pause(&self) {
self.0.store(1, Ordering::SeqCst);
}
pub fn resume(&self) {
self.0.store(0, Ordering::SeqCst);
}
pub fn cancel(&self) {
self.0.store(2, Ordering::SeqCst);
}
pub async fn yield_if_paused(
&self,
events: &std::sync::mpsc::Sender<crate::terminal::BackendEvent>,
@@ -161,6 +178,10 @@ impl SftpHandle {
.send(SftpCommand::UploadPaths { locals, remote_dir });
}
pub fn edit_file(&self, remote_path: String) {
let _ = self.commands.send(SftpCommand::EditFile { remote_path });
}
pub fn close(&self) {
let _ = self.commands.send(SftpCommand::Close);
}
@@ -187,7 +208,15 @@ pub fn spawn_sftp(
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let cmd_tx_clone = cmd_tx.clone();
let join = runtime.spawn(async move {
if let Err(err) = run_sftp(tab_id.clone(), session, cmd_rx, cmd_tx_clone, events.clone()).await {
if let Err(err) = run_sftp(
tab_id.clone(),
session,
cmd_rx,
cmd_tx_clone,
events.clone(),
)
.await
{
let _ = events.send(BackendEvent::SftpStatus {
tab_id,
text: format!("sftp error: {err:#}"),
@@ -229,9 +258,16 @@ async fn run_sftp(
.canonicalize(".")
.await
.unwrap_or_else(|_| "/".to_string());
let _ = events.send(BackendEvent::SftpHome {
tab_id: tab_id.clone(),
home: home.clone(),
});
emit_entries(&events, &tab_id, &sftp, &home).await?;
let mut active_transfers: std::collections::HashMap<String, TransferStateFlag> = std::collections::HashMap::new();
let mut active_transfers: std::collections::HashMap<String, TransferStateFlag> =
std::collections::HashMap::new();
while let Some(command) = commands.recv().await {
match command {
@@ -308,16 +344,33 @@ async fn run_sftp(
let commands_tx_clone = commands_tx.clone();
tokio::spawn(async move {
let Ok(channel) = handle_clone.channel_open_session().await else { return };
let Ok(_) = channel.request_subsystem(true, "sftp").await else { return };
let Ok(sftp_session) = SftpSession::new(channel.into_stream()).await else { return };
let Ok(channel) = handle_clone.channel_open_session().await else {
return;
};
let Ok(_) = channel.request_subsystem(true, "sftp").await else {
return;
};
let Ok(sftp_session) = SftpSession::new(channel.into_stream()).await else {
return;
};
let _ = events_clone.send(BackendEvent::SftpStatus {
tab_id: tab_id_clone.clone(),
text: t!("downloading_file", base = base_name(&remote)).to_string(),
});
match download_path_impl(&handle_clone, &sftp_session, &remote, Path::new(&local_dir), flag, &events_clone, &tab_id_clone, &id).await {
match download_path_impl(
&handle_clone,
&sftp_session,
&remote,
Path::new(&local_dir),
flag,
&events_clone,
&tab_id_clone,
&id,
)
.await
{
Ok(summary) => {
let _ = events_clone.send(BackendEvent::SftpStatus {
tab_id: tab_id_clone,
@@ -326,16 +379,28 @@ async fn run_sftp(
}
Err(err) => {
let err_msg = format!("{err:#}");
let is_cancelled = err_msg.contains("transfer cancelled");
let state = if is_cancelled {
crate::terminal::TransferState::Interrupted(
"User cancelled".to_string(),
)
} else {
crate::terminal::TransferState::Failed(err_msg.clone())
};
let _ = events_clone.send(BackendEvent::SftpStatus {
tab_id: tab_id_clone.clone(),
text: t!("download_failed", err = err_msg.clone()).to_string(),
text: if is_cancelled {
"Transmission cancelled".to_string()
} else {
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),
state,
});
}
}
@@ -364,7 +429,12 @@ async fn run_sftp(
} else if file_count == 0 && folder_count > 0 {
t!("n_folders", folders = folder_count).to_string()
} else {
t!("n_files_and_folders", files = file_count, folders = folder_count).to_string()
t!(
"n_files_and_folders",
files = file_count,
folders = folder_count
)
.to_string()
}
};
@@ -387,16 +457,32 @@ async fn run_sftp(
let commands_tx_clone = commands_tx.clone();
tokio::spawn(async move {
let Ok(channel) = handle_clone.channel_open_session().await else { return };
let Ok(_) = channel.request_subsystem(true, "sftp").await else { return };
let Ok(sftp_session) = SftpSession::new(channel.into_stream()).await else { return };
let Ok(channel) = handle_clone.channel_open_session().await else {
return;
};
let Ok(_) = channel.request_subsystem(true, "sftp").await else {
return;
};
let Ok(sftp_session) = SftpSession::new(channel.into_stream()).await else {
return;
};
let _ = events_clone.send(BackendEvent::SftpStatus {
tab_id: tab_id_clone.clone(),
text: t!("uploading").to_string(),
});
match upload_paths_impl(&sftp_session, &locals, &remote_dir, flag, &events_clone, &tab_id_clone, &id).await {
match upload_paths_impl(
&sftp_session,
&locals,
&remote_dir,
flag,
&events_clone,
&tab_id_clone,
&id,
)
.await
{
Ok(summary) => {
let _ = events_clone.send(BackendEvent::SftpStatus {
tab_id: tab_id_clone.clone(),
@@ -406,22 +492,262 @@ async fn run_sftp(
}
Err(err) => {
let err_msg = format!("{err:#}");
let is_cancelled = err_msg.contains("transfer cancelled");
let state = if is_cancelled {
crate::terminal::TransferState::Interrupted(
"User cancelled".to_string(),
)
} else {
crate::terminal::TransferState::Failed(err_msg.clone())
};
let _ = events_clone.send(BackendEvent::SftpStatus {
tab_id: tab_id_clone.clone(),
text: t!("upload_failed", err = err_msg.clone()).to_string(),
text: if is_cancelled {
"Transmission cancelled".to_string()
} else {
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),
state,
});
}
}
let _ = commands_tx_clone.send(SftpCommand::TransferFinished(id));
});
}
SftpCommand::EditFile { remote_path } => {
let id = uuid::Uuid::new_v4().to_string();
let config = crate::session::config::ConfigStore::load().unwrap();
let tmp_dir = config.tmp_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
let base = base_name(&remote_path);
let local_path = tmp_dir.join(format!("{}-{}", id, base));
let handle_clone = handle.clone();
let commands_tx_clone = commands_tx.clone();
let events_clone = events.clone();
let tab_id_clone = tab_id.clone();
tokio::spawn(async move {
let flag = TransferStateFlag::new();
let Ok(channel) = handle_clone.channel_open_session().await else {
return;
};
let Ok(_) = channel.request_subsystem(true, "sftp").await else {
return;
};
let Ok(sftp_session) = SftpSession::new(channel.into_stream()).await else {
return;
};
let _ = events_clone.send(BackendEvent::SftpStatus {
tab_id: tab_id_clone.clone(),
text: t!("downloading_file", base = base).to_string(),
});
if let Err(err) = download_file_impl(
&sftp_session,
&remote_path,
&local_path,
&flag,
&events_clone,
&tab_id_clone,
"edit-download",
)
.await
{
let _ = events_clone.send(BackendEvent::SftpStatus {
tab_id: tab_id_clone.clone(),
text: format!("Edit download failed: {err:#}"),
});
return;
}
if let Err(err) = open::that(&local_path) {
let _ = events_clone.send(BackendEvent::SftpStatus {
tab_id: tab_id_clone.clone(),
text: format!("Failed to open editor: {err:#}"),
});
return;
}
use notify::Watcher;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let mut watcher = match notify::recommended_watcher(
move |res: notify::Result<notify::Event>| {
if let Ok(event) = res {
if event.kind.is_modify() {
let _ = tx.send(());
}
}
},
) {
Ok(w) => w,
Err(_) => return,
};
if let Err(_) = watcher.watch(&local_path, notify::RecursiveMode::NonRecursive)
{
return;
}
while let Some(_) = rx.recv().await {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
while let Ok(_) = rx.try_recv() {} // drain pending
if commands_tx_clone
.send(SftpCommand::UploadEditedFile {
local_path: local_path.to_string_lossy().to_string(),
remote_path: remote_path.clone(),
})
.is_err()
{
break;
}
}
});
}
SftpCommand::UploadEditedFile {
local_path,
remote_path,
} => {
let handle_clone = handle.clone();
let events_clone = events.clone();
let tab_id_clone = tab_id.clone();
tokio::spawn(async move {
let flag = TransferStateFlag::new();
let Ok(channel) = handle_clone.channel_open_session().await else {
return;
};
let Ok(_) = channel.request_subsystem(true, "sftp").await else {
return;
};
let Ok(sftp_session) = SftpSession::new(channel.into_stream()).await else {
return;
};
let transferred = Arc::new(AtomicU64::new(0));
match upload_file_impl(
&sftp_session,
Path::new(&local_path),
&remote_path,
&flag,
&events_clone,
&tab_id_clone,
"edit-upload",
transferred,
None,
)
.await
{
Ok(_) => {
let now = chrono::Local::now().format("%H:%M:%S");
let _ = events_clone.send(BackendEvent::SftpStatus {
tab_id: tab_id_clone.clone(),
text: format!(
"{} ({})",
t!("auto_saved_and_uploaded", base = base_name(&remote_path)),
now
),
});
}
Err(err) => {
let _ = events_clone.send(BackendEvent::SftpStatus {
tab_id: tab_id_clone.clone(),
text: format!("Auto-upload failed: {err:#}"),
});
}
}
});
}
SftpCommand::CreateDir(path) => {
let actual_path = if path == "~" {
home.clone()
} else if let Some(rest) = path.strip_prefix("~/") {
crate::sftp::join_remote(&home, rest)
} else {
path.clone()
};
tracing::info!("[sftp] creating directory: '{}'", actual_path);
match sftp.create_dir(&actual_path).await {
Ok(_) => {
let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(),
text: t!("create_folder_success", name = base_name(&actual_path))
.to_string(),
});
// Re-fetch the parent directory to show the newly created folder
if let Some(parent) = parent_dir(&actual_path) {
let _ = commands_tx.send(SftpCommand::ListDir(parent));
} else {
let _ = commands_tx.send(SftpCommand::ListDir("/".to_string()));
}
}
Err(err) => {
let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(),
text: t!("create_folder_failed", err = format!("{err:#}")).to_string(),
});
}
}
}
SftpCommand::DeletePaths(paths) => {
tracing::info!("[sftp] batch deleting {} paths", paths.len());
let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(),
text: t!("deleting_paths", count = paths.len()).to_string(),
});
let mut errors = Vec::new();
for path in paths.clone() {
let actual_path = if path == "~" {
home.clone()
} else if let Some(rest) = path.strip_prefix("~/") {
crate::sftp::join_remote(&home, rest)
} else {
path.clone()
};
if let Err(e) = recursive_delete(&sftp, actual_path).await {
errors.push(format!("{path}: {e:#}"));
}
}
if errors.is_empty() {
let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(),
text: t!("delete_success", count = paths.len()).to_string(),
});
} else {
let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(),
text: t!("delete_failed", err = errors.join(", ")).to_string(),
});
}
if let Some(first) = paths.first() {
let actual_path = if first == "~" {
home.clone()
} else if let Some(rest) = first.strip_prefix("~/") {
crate::sftp::join_remote(&home, rest)
} else {
first.clone()
};
if let Some(parent) = parent_dir(&actual_path) {
let _ = commands_tx.send(SftpCommand::ListDir(parent));
} else {
let _ = commands_tx.send(SftpCommand::ListDir("/".to_string()));
}
}
}
}
}
@@ -431,6 +757,49 @@ async fn run_sftp(
Ok(())
}
use std::future::Future;
use std::pin::Pin;
fn recursive_delete<'a>(
sftp: &'a SftpSession,
path: String,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async move {
match sftp.read_dir(&path).await {
Ok(entries) => {
for entry in entries {
let name = entry.file_name();
if name == "." || name == ".." {
continue;
}
let child_path = crate::sftp::join_remote(&path, &name);
let meta = entry.metadata();
let permissions = meta.permissions.unwrap_or(0);
let is_dir = (permissions & 0o170_000) == 0o040_000;
if is_dir {
recursive_delete(sftp, child_path).await?;
} else {
sftp.remove_file(&child_path)
.await
.with_context(|| format!("Failed to delete file {child_path}"))?;
}
}
sftp.remove_dir(&path)
.await
.with_context(|| format!("Failed to delete dir {path}"))?;
}
Err(_) => {
sftp.remove_file(&path)
.await
.with_context(|| format!("Failed to delete {path}"))?;
}
}
Ok(())
})
}
async fn emit_entries(
events: &std::sync::mpsc::Sender<BackendEvent>,
tab_id: &str,
@@ -469,11 +838,35 @@ async fn connect_and_authenticate(
.context("password authentication failed")?,
AuthMethod::Key => {
let keypair = load_session_private_key(session)?;
let key = private_key_with_alg(keypair).context("invalid private key")?;
handle
.authenticate_publickey(&session.user, key)
.await
.context("public key authentication failed")?
let keys = private_keys_with_algs(keypair).context("invalid private key")?;
let mut success = false;
for key in keys {
match handle.authenticate_publickey(&session.user, key).await {
Ok(true) => {
success = true;
break;
}
Ok(false) => {
tracing::debug!(
"[sftp] public key auth failed with algorithm, trying next"
);
continue;
}
Err(e) => {
tracing::debug!("[sftp] public key auth error: {:?}, trying next", e);
continue;
}
}
}
if !success {
return Err(anyhow!(
"public key authentication failed for {}@{}:{}",
session.user,
session.host,
session.port
));
}
success
}
};
@@ -499,6 +892,8 @@ async fn connect_and_authenticate(
fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
let inline_key = normalize_inline_private_key(&session.private_key_inline);
let key_path = expand_key_path(session.private_key_path.trim());
let passphrase = session.passphrase.trim();
let passphrase = (!passphrase.is_empty()).then_some(passphrase);
let has_inline = !inline_key.is_empty();
let has_path = key_path.is_some();
@@ -509,14 +904,14 @@ fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
let mut errors = Vec::new();
if has_inline {
match decode_secret_key(&inline_key, None) {
match decode_secret_key(&inline_key, passphrase) {
Ok(key) => return Ok(key),
Err(err) => errors.push(format!("decode private key content: {err}")),
}
}
if let Some(path) = key_path {
match load_secret_key(path.as_path(), None) {
match load_secret_key(path.as_path(), passphrase) {
Ok(key) => return Ok(key),
Err(err) => errors.push(format!("load key {}: {err}", path.display())),
}
@@ -525,16 +920,33 @@ fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
Err(anyhow!(errors.join("; ")))
}
fn private_key_with_alg(keypair: PrivateKey) -> Result<PrivateKeyWithHashAlg> {
let hash_alg = if keypair.algorithm().is_rsa() {
Some(HashAlg::Sha512)
fn private_keys_with_algs(keypair: PrivateKey) -> Result<Vec<PrivateKeyWithHashAlg>> {
let mut algs = Vec::new();
let key_arc = Arc::new(keypair);
if key_arc.algorithm().is_rsa() {
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha512)) {
algs.push(k);
}
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha256)) {
algs.push(k);
}
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
algs.push(k);
}
} else {
None
};
Ok(
PrivateKeyWithHashAlg::new(Arc::new(keypair.clone()), hash_alg)
.or_else(|_| PrivateKeyWithHashAlg::new(Arc::new(keypair), Some(HashAlg::Sha256)))?,
)
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
algs.push(k);
}
}
if algs.is_empty() {
return Err(anyhow!(
"Failed to construct PrivateKeyWithHashAlg for any supported hash algorithm"
));
}
Ok(algs)
}
fn normalize_inline_private_key(value: &str) -> String {
@@ -571,7 +983,23 @@ fn base_name(path: &str) -> String {
.to_string()
}
fn join_remote(parent: &str, child: &str) -> String {
pub(crate) fn parent_dir(path: &str) -> Option<String> {
if path == "/" || path.is_empty() {
return None;
}
let trimmed = path.trim_end_matches('/');
if let Some(idx) = trimmed.rfind('/') {
if idx == 0 {
Some("/".to_string())
} else {
Some(trimmed[..idx].to_string())
}
} else {
Some("/".to_string())
}
}
pub(crate) fn join_remote(parent: &str, child: &str) -> String {
if parent == "/" {
format!("/{child}")
} else {
@@ -721,6 +1149,12 @@ async fn download_path_impl(
.await
.with_context(|| format!("create {}", local_dir.display()))?;
// Check for cancellation after initial setup
let state = flag.0.load(Ordering::SeqCst);
if state == 2 {
return Err(anyhow::anyhow!("transfer cancelled"));
}
let metadata = sftp
.metadata(remote)
.await
@@ -736,8 +1170,17 @@ async fn download_path_impl(
base_name(remote),
Uuid::new_v4()
));
let extracted_to =
download_remote_directory_archive(handle, sftp, remote, &local_archive, &flag, events, tab_id, id).await?;
let extracted_to = download_remote_directory_archive(
handle,
sftp,
remote,
&local_archive,
&flag,
events,
tab_id,
id,
)
.await?;
return Ok(t!("downloaded_folder", path = extracted_to.display()).to_string());
}
@@ -763,9 +1206,27 @@ async fn download_dir_recursive(
for entry in entries {
let local_path = local_dir.join(&entry.name);
if entry.is_dir {
Box::pin(download_dir_recursive(sftp, &entry.full_path, &local_path, flag, events, tab_id, id)).await?;
Box::pin(download_dir_recursive(
sftp,
&entry.full_path,
&local_path,
flag,
events,
tab_id,
id,
))
.await?;
} else {
download_file_impl(sftp, &entry.full_path, &local_path, flag, events, tab_id, id).await?;
download_file_impl(
sftp,
&entry.full_path,
&local_path,
flag,
events,
tab_id,
id,
)
.await?;
let _ = maybe_extract_archive(&local_path).await;
}
}
@@ -787,14 +1248,31 @@ async fn download_remote_directory_archive(
base_name(remote_dir),
Uuid::new_v4()
);
// Check for cancellation before creating remote archive
let state = flag.0.load(Ordering::SeqCst);
if state == 2 {
return Err(anyhow::anyhow!("transfer cancelled"));
}
create_remote_archive(handle, remote_dir, &remote_archive).await?;
let local_extract_root = local_archive
.parent()
.unwrap_or_else(|| Path::new("."))
.join(base_name(remote_dir));
let archive_download = async {
download_file_impl(sftp, &remote_archive, local_archive, flag, events, tab_id, id).await?;
download_file_impl(
sftp,
&remote_archive,
local_archive,
flag,
events,
tab_id,
id,
)
.await?;
extract_archive_to(
local_archive,
local_archive.parent().unwrap_or_else(|| Path::new(".")),
@@ -839,7 +1317,8 @@ async fn download_file_impl(
let mut buffer = vec![0u8; 128 * 1024];
loop {
flag.yield_if_paused(events, tab_id, id, transferred, total).await?;
flag.yield_if_paused(events, tab_id, id, transferred, total)
.await?;
let read = remote_file
.read(&mut buffer)
.await
@@ -851,7 +1330,7 @@ async fn download_file_impl(
.write_all(&buffer[..read])
.await
.with_context(|| format!("write {}", local.display()))?;
transferred += read as u64;
let _ = events.send(BackendEvent::TransferProgress {
tab_id: tab_id.to_string(),
@@ -862,7 +1341,7 @@ async fn download_file_impl(
});
}
local_file.flush().await.context("flush local file")?;
let _ = events.send(BackendEvent::TransferProgress {
tab_id: tab_id.to_string(),
id: id.to_string(),
@@ -870,7 +1349,7 @@ async fn download_file_impl(
total,
state: crate::terminal::TransferState::Completed,
});
Ok(())
}
@@ -883,10 +1362,16 @@ async fn upload_paths_impl(
tab_id: &str,
id: &str,
) -> Result<String> {
// Check for cancellation before starting
let state = flag.0.load(Ordering::SeqCst);
if state == 2 {
return Err(anyhow::anyhow!("transfer cancelled"));
}
create_remote_dir_all(sftp, remote_dir).await?;
let mut file_count = 0usize;
let mut folder_count = 0usize;
let mut total_bytes = 0u64;
let mut files_to_upload = Vec::new();
let mut dirs_to_create = Vec::new();
@@ -902,14 +1387,20 @@ async fn upload_paths_impl(
for entry in WalkDir::new(&p) {
let entry = entry?;
let path = entry.path();
if path == p { continue; }
if path == p {
continue;
}
if let Ok(meta) = tokio::fs::metadata(&path).await {
let relative = path.strip_prefix(&p)?;
let remote_path = if relative.as_os_str().is_empty() {
remote_root.clone()
} else {
let rel = relative.components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::<Vec<_>>().join("/");
let rel = relative
.components()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>()
.join("/");
join_remote(&remote_root, &rel)
};
@@ -929,8 +1420,19 @@ async fn upload_paths_impl(
}
}
// Check for cancellation before creating directories
let state = flag.0.load(Ordering::SeqCst);
if state == 2 {
return Err(anyhow::anyhow!("transfer cancelled"));
}
// Create directories sequentially first
for dir in dirs_to_create {
// Check for cancellation between each directory creation
let state = flag.0.load(Ordering::SeqCst);
if state == 2 {
return Err(anyhow::anyhow!("transfer cancelled"));
}
create_remote_dir_all(sftp, &dir).await?;
}
@@ -955,7 +1457,8 @@ async fn upload_paths_impl(
&id_clone,
transferred_clone,
Some(total_bytes),
).await
)
.await
});
}
@@ -964,7 +1467,7 @@ async fn upload_paths_impl(
while let Some(res) = stream.next().await {
res?;
}
let _ = events.send(BackendEvent::TransferProgress {
tab_id: tab_id.to_string(),
id: id.to_string(),
@@ -972,7 +1475,7 @@ async fn upload_paths_impl(
total: Some(total_bytes),
state: crate::terminal::TransferState::Completed,
});
let summary = if file_count == 1 && folder_count == 0 {
t!("uploaded_file").to_string()
} else if file_count == 0 && folder_count == 1 {
@@ -982,14 +1485,19 @@ async fn upload_paths_impl(
} else if file_count == 0 && folder_count > 0 {
t!("uploaded_n_folders", folders = folder_count).to_string()
} else {
t!("uploaded_files_and_folders", files = file_count, folders = folder_count).to_string()
t!(
"uploaded_files_and_folders",
files = file_count,
folders = folder_count
)
.to_string()
};
Ok(summary)
}
async fn upload_file_impl(
sftp: &SftpSession,
local_file: &Path,
sftp: &SftpSession,
local_file: &Path,
remote_path: &str,
flag: &TransferStateFlag,
events: &std::sync::mpsc::Sender<BackendEvent>,
@@ -1018,7 +1526,7 @@ async fn upload_file_impl(
.write_all(&buffer[..read])
.await
.with_context(|| format!("write remote {remote_path}"))?;
let new_cur = transferred.fetch_add(read as u64, Ordering::Relaxed) + read as u64;
let _ = events.send(BackendEvent::TransferProgress {
tab_id: tab_id.to_string(),
@@ -1092,14 +1600,31 @@ async fn exec_remote_command(
let mut stderr = Vec::new();
let mut stdout = Vec::new();
let mut exit_status = None;
while let Some(msg) = channel.wait().await {
match msg {
russh::ChannelMsg::Data { data } => stdout.extend_from_slice(&data),
russh::ChannelMsg::ExtendedData { data, .. } => stderr.extend_from_slice(&data),
russh::ChannelMsg::ExitStatus { exit_status: code } => exit_status = Some(code),
russh::ChannelMsg::Close => break,
_ => {}
// Add timeout to prevent indefinite blocking (300 seconds = 5 minutes)
let timeout = tokio::time::Duration::from_secs(300);
let result = tokio::time::timeout(timeout, async {
loop {
// Yield to allow cancellation
tokio::task::yield_now().await;
if let Some(msg) = channel.wait().await {
match msg {
russh::ChannelMsg::Data { data } => stdout.extend_from_slice(&data),
russh::ChannelMsg::ExtendedData { data, .. } => stderr.extend_from_slice(&data),
russh::ChannelMsg::ExitStatus { exit_status: code } => exit_status = Some(code),
russh::ChannelMsg::Close => break,
_ => {}
}
} else {
break;
}
}
})
.await;
if result.is_err() {
return Err(anyhow!("remote command timeout: {command}"));
}
match exit_status.unwrap_or(0) {
+395
View File
@@ -0,0 +1,395 @@
use gpui::{Context, PathPromptOptions, Pixels, Point, Window};
use crate::{
Ashell, SftpContextMenuState,
sftp::{RemoteEntry, SftpHandle},
terminal,
};
pub(crate) fn is_editable_text_file(filename: &str) -> bool {
let lower = filename.to_lowercase();
let ext = std::path::Path::new(&lower)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("");
let known_exts = [
"txt", "conf", "json", "yaml", "yml", "xml", "ini", "sh", "py", "rs", "js", "ts", "html",
"css", "md", "toml", "csv", "log", "cfg",
];
if known_exts.contains(&ext) {
return true;
}
let known_names = ["dockerfile", "makefile", ".gitignore", ".env"];
if known_names.contains(&lower.as_str()) {
return true;
}
false
}
impl Ashell {
pub(crate) fn active_sftp(&self) -> Option<&terminal::SftpUiState> {
self.active_group
.as_ref()
.and_then(|id| self.tab_groups.iter().find(|g| &g.id == id))
.and_then(|g| g.sftp.as_ref())
}
pub(crate) fn active_sftp_mut(&mut self) -> Option<&mut terminal::SftpUiState> {
let active_id = self.active_group.clone()?;
self.tab_groups
.iter_mut()
.find(|g| g.id == active_id)
.and_then(|g| g.sftp.as_mut())
}
pub(crate) fn active_sftp_handle(&self) -> Option<&SftpHandle> {
self.active_group
.as_ref()
.and_then(|id| self.sftp_handles.get(id))
}
pub(crate) fn navigate_sftp(&mut self, path: String, cx: &mut Context<Self>) {
if let Some(handle) = self.active_sftp_handle() {
tracing::info!("[sftp] navigating to directory: '{}'", path);
handle.list_dir(path.clone());
if let Some(sftp) = self.active_sftp_mut() {
sftp.current_path = path;
self.pending_sftp_path_sync = Some(sftp.current_path.clone());
}
cx.notify();
}
}
pub(crate) fn select_sftp_entry(&mut self, entry: RemoteEntry, cx: &mut Context<Self>) {
if entry.is_dir {
self.navigate_sftp(entry.full_path, cx);
return;
}
self.mark_sftp_entry_selected(&entry.full_path, cx);
if let Some(sftp) = self.active_sftp_mut() {
if !sftp.selected_entries.remove(&entry.full_path) {
sftp.selected_entries.insert(entry.full_path);
}
}
}
pub(crate) fn mark_sftp_entry_selected(&mut self, path: &str, cx: &mut Context<Self>) {
if let Some(sftp) = self.active_sftp_mut() {
sftp.selected_path = Some(path.to_string());
}
cx.notify();
}
pub(crate) fn sftp_parent_path(path: &str) -> String {
if path == "/" {
return "/".to_string();
}
path.trim_end_matches('/')
.rsplit_once('/')
.map(|(parent, _)| {
if parent.is_empty() {
"/".to_string()
} else {
parent.to_string()
}
})
.unwrap_or_else(|| "/".to_string())
}
pub(crate) fn refresh_sftp(&mut self, cx: &mut Context<Self>) {
if let Some(path) = self.active_sftp().map(|sftp| sftp.current_path.clone()) {
self.navigate_sftp(path, cx);
}
}
pub(crate) fn sync_sftp_path_input(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(path) = self.pending_sftp_path_sync.take() else {
return;
};
self.sftp_path_input.update(cx, |state, cx| {
state.set_value(path, window, cx);
});
}
pub(crate) fn open_sftp_context_menu(
&mut self,
remote_path: String,
is_dir: bool,
position: Point<Pixels>,
cx: &mut Context<Self>,
) {
self.sftp_context_menu = Some(SftpContextMenuState {
remote_path,
is_dir,
position,
});
cx.notify();
}
pub(crate) fn dismiss_sftp_context_menu(&mut self, cx: &mut Context<Self>) {
if self.sftp_context_menu.take().is_some() {
cx.notify();
}
}
pub(crate) fn trigger_sftp_context_download(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(menu) = self.sftp_context_menu.take() else {
return;
};
self.download_sftp_entry(menu.remote_path, window, cx);
cx.notify();
}
pub(crate) fn trigger_sftp_context_edit(&mut self, cx: &mut Context<Self>) {
let Some(menu) = self.sftp_context_menu.take() else {
return;
};
if let Some(handle) = self.active_sftp_handle() {
tracing::info!("[sftp] triggering edit for file: '{}'", menu.remote_path);
handle.edit_file(menu.remote_path);
}
cx.notify();
}
pub(crate) fn download_sftp_entry(
&mut self,
remote_path: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Select Download Folder".into()),
});
cx.spawn_in(window, async move |this, cx| {
match path_prompt.await {
Ok(Ok(Some(mut paths))) => {
if let Some(folder) = paths.pop() {
let local_path = folder.to_string_lossy().to_string();
tracing::info!(
"[sftp] initiating download of '{}' to '{}'",
remote_path,
local_path
);
handle.download(remote_path, local_path);
this.update(cx, |this, cx| {
this.show_transfers_dialog = true;
cx.notify();
})?;
}
}
Ok(Err(err)) => {
this.update(cx, |this, cx| {
this.status = format!("download picker failed: {err}").into();
cx.notify();
})?;
}
_ => {}
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
pub(crate) fn upload_sftp_files(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
let remote_dir = self
.active_sftp()
.map(|sftp| sftp.current_path.clone())
.unwrap_or_else(|| "/".into());
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: Some("Select File to Upload".into()),
});
cx.spawn_in(window, async move |this, cx| {
match path_prompt.await {
Ok(Ok(Some(mut paths))) => {
if let Some(file) = paths.pop() {
let local_path = file.to_string_lossy().to_string();
tracing::info!(
"[sftp] initiating upload of file '{}' to '{}'",
local_path,
remote_dir
);
handle.upload_paths(vec![local_path], remote_dir);
this.update(cx, |this, cx| {
this.show_transfers_dialog = true;
cx.notify();
})?;
}
}
Ok(Err(err)) => {
this.update(cx, |this, cx| {
this.status = format!("upload picker failed: {err}").into();
cx.notify();
})?;
}
_ => {}
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
pub(crate) fn upload_sftp_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
let remote_dir = self
.active_sftp()
.map(|sftp| sftp.current_path.clone())
.unwrap_or_else(|| "/".into());
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Select Folder to Upload".into()),
});
cx.spawn_in(window, async move |this, cx| {
match path_prompt.await {
Ok(Ok(Some(mut paths))) => {
if let Some(folder) = paths.pop() {
let local_path = folder.to_string_lossy().to_string();
tracing::info!(
"[sftp] initiating upload of folder '{}' to '{}'",
local_path,
remote_dir
);
handle.upload_paths(vec![local_path], remote_dir);
this.update(cx, |this, cx| {
this.show_transfers_dialog = true;
cx.notify();
})?;
}
}
Ok(Err(err)) => {
this.update(cx, |this, cx| {
this.status = format!("upload picker failed: {err}").into();
cx.notify();
})?;
}
_ => {}
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
pub(crate) fn toggle_sftp_entry(
&mut self,
path: String,
checked: bool,
cx: &mut Context<Self>,
) {
if let Some(sftp) = self.active_sftp_mut() {
if checked {
sftp.selected_entries.insert(path);
} else {
sftp.selected_entries.remove(&path);
}
cx.notify();
}
}
pub(crate) fn toggle_all_sftp_entries(&mut self, checked: bool, cx: &mut Context<Self>) {
if let Some(sftp) = self.active_sftp_mut() {
if checked {
let paths: Vec<String> = sftp.entries.iter().map(|e| e.full_path.clone()).collect();
for path in paths {
sftp.selected_entries.insert(path);
}
} else {
sftp.selected_entries.clear();
}
cx.notify();
}
}
pub(crate) fn download_selected_sftp_entries(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(sftp) = self.active_sftp() else {
return;
};
let selected: Vec<String> = sftp.selected_entries.iter().cloned().collect();
if selected.is_empty() {
return;
}
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Select Download Folder".into()),
});
cx.spawn_in(window, async move |this, cx| {
if let Ok(Ok(Some(mut paths))) = path_prompt.await {
if let Some(folder) = paths.pop() {
let local_dir = folder.to_string_lossy().to_string();
tracing::info!(
"[sftp] initiating batch download of {} entries to '{}'",
selected.len(),
local_dir
);
for remote in selected {
let _ = handle.commands.send(crate::sftp::SftpCommand::Download {
remote,
local_dir: local_dir.clone(),
});
}
let _ = this.update(cx, |this, cx| {
if let Some(sftp_mut) = this.active_sftp_mut() {
sftp_mut.selected_entries.clear();
}
this.show_transfers_dialog = true;
cx.notify();
});
}
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
pub(crate) fn upload_sftp_files_batch(&mut self, paths: Vec<String>, cx: &mut Context<Self>) {
if paths.is_empty() {
return;
}
if let Some(sftp) = self.active_sftp() {
if let Some(handle) = self.active_sftp_handle() {
tracing::info!(
"[sftp] initiating batch upload of {} files to '{}'",
paths.len(),
sftp.current_path
);
let _ = handle.commands.send(crate::sftp::SftpCommand::UploadPaths {
locals: paths,
remote_dir: sftp.current_path.clone(),
});
self.show_transfers_dialog = true;
cx.notify();
}
}
}
}
+43 -3
View File
@@ -1,11 +1,20 @@
use std::{
collections::BTreeMap,
ffi::OsStr,
time::{Duration, Instant},
};
use anyhow::{Result, anyhow};
use sysinfo::{Disks, Networks, System};
/// Known virtual/ram filesystems to exclude from disk monitoring.
fn is_real_filesystem(fs: &OsStr) -> bool {
!matches!(
fs.to_str(),
Some("tmpfs" | "devtmpfs" | "ramfs" | "overlay" | "aufs")
)
}
#[derive(Debug, Clone, Default)]
pub struct DiskSample {
pub mount: String,
@@ -22,7 +31,10 @@ pub struct SystemSnapshot {
pub swap_detail: String,
pub net_rx: String,
pub net_tx: String,
pub net_rx_rate: u64,
pub net_tx_rate: u64,
pub disks: Vec<DiskSample>,
pub total_swap: u64,
}
pub struct SystemSampler {
@@ -82,17 +94,25 @@ impl SystemSampler {
self.last_tx_total = tx_total;
self.last_instant = now;
let disks = self
let mut disks: Vec<DiskSample> = self
.disks
.iter()
.filter(|disk| disk.total_space() > 0)
.take(4)
.filter(|disk| disk.total_space() > 0 && is_real_filesystem(disk.file_system()))
.map(|disk| DiskSample {
mount: disk.mount_point().to_string_lossy().to_string(),
available_bytes: disk.available_space(),
total_bytes: disk.total_space(),
})
.collect();
disks.sort_by(|a, b| {
if a.mount == "/" {
return std::cmp::Ordering::Less;
}
if b.mount == "/" {
return std::cmp::Ordering::Greater;
}
a.mount.cmp(&b.mount)
});
SystemSnapshot {
cpu_percent,
@@ -102,7 +122,10 @@ impl SystemSampler {
swap_detail: format!("{}/{}", format_bytes(swap_used), format_bytes(swap_total)),
net_rx: format!("{}/s", format_bytes(rx_rate)),
net_tx: format!("{}/s", format_bytes(tx_rate)),
net_rx_rate: rx_rate,
net_tx_rate: tx_rate,
disks,
total_swap: swap_total,
}
}
}
@@ -176,6 +199,20 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
let rx_rate = parse_u64(&kv, "NET_RX");
let tx_rate = parse_u64(&kv, "NET_TX");
// Safety filter: exclude entries with zero/negligible total size
// (catches any virtual fs lines that slipped past the script filter)
disks.retain(|d| d.total_bytes >= 1024 * 1024);
disks.sort_by(|a, b| {
if a.mount == "/" {
return std::cmp::Ordering::Less;
}
if b.mount == "/" {
return std::cmp::Ordering::Greater;
}
a.mount.cmp(&b.mount)
});
Ok(SystemSnapshot {
cpu_percent: cpu_percent.clamp(0.0, 1.0),
mem_percent: ratio(mem_used, mem_total),
@@ -184,7 +221,10 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
swap_detail: format!("{}/{}", format_bytes(swap_used), format_bytes(swap_total)),
net_rx: format!("{}/s", format_bytes(rx_rate)),
net_tx: format!("{}/s", format_bytes(tx_rate)),
net_rx_rate: rx_rate,
net_tx_rate: tx_rate,
disks,
total_swap: swap_total,
})
}
+337
View File
@@ -0,0 +1,337 @@
use gpui::{Bounds, Hsla, Path, Pixels, Window, fill, point, px, size};
pub fn is_custom_block_supported(c: char) -> bool {
match c as u32 {
0x2580..=0x258F | 0x2590 | 0x2594..=0x259F => true, // Block Elements
0x2500 | 0x2502 | 0x250C | 0x2510 | 0x2514 | 0x2518 | 0x251C | 0x2524 | 0x252C | 0x2534
| 0x253C => true, // Light lines
0x2501 | 0x2503 | 0x250F | 0x2513 | 0x2517 | 0x251B | 0x2523 | 0x252B | 0x2533 | 0x253B
| 0x254B => true, // Heavy lines
0xE0B0..=0xE0B6 => true, // Powerline
_ => false,
}
}
pub fn paint_custom_block(
window: &mut Window,
c: char,
raw_x: f32,
raw_y: f32,
raw_w: f32,
raw_h: f32,
color: Hsla,
) -> bool {
let scale = window.scale_factor();
let x = px((raw_x * scale).round() / scale);
let y = px((raw_y * scale).round() / scale);
let w = px(((raw_x + raw_w) * scale).round() / scale) - x;
let h = px(((raw_y + raw_h) * scale).round() / scale) - y;
let mut painted = false;
// Helper to paint a quad snapping its true physical boundaries to integer physical pixels
let mut paint_quad = |qx: Pixels, qy: Pixels, qw: Pixels, qh: Pixels| {
let l = px((qx.as_f32() * scale).round() / scale);
let t = px((qy.as_f32() * scale).round() / scale);
let r = px(((qx + qw).as_f32() * scale).round() / scale);
let b = px(((qy + qh).as_f32() * scale).round() / scale);
window.paint_quad(fill(Bounds::new(point(l, t), size(r - l, b - t)), color));
painted = true;
};
let px_1 = px(1.0);
let px_2 = px(2.0);
// Box drawing thickness
let light = (w * 0.1).max(px_1);
let heavy = (w * 0.2).max(px_2);
let cx = x + w / 2.0;
let cy = y + h / 2.0;
match c as u32 {
// --- Block Elements (U+2580..=U+259F) ---
0x2580 => paint_quad(x, y, w, h / 2.0),
0x2581 => paint_quad(x, y + h * 7.0 / 8.0, w, h / 8.0),
0x2582 => paint_quad(x, y + h * 3.0 / 4.0, w, h / 4.0),
0x2583 => paint_quad(x, y + h * 5.0 / 8.0, w, h * 3.0 / 8.0),
0x2584 => paint_quad(x, y + h / 2.0, w, h / 2.0),
0x2585 => paint_quad(x, y + h * 3.0 / 8.0, w, h * 5.0 / 8.0),
0x2586 => paint_quad(x, y + h / 4.0, w, h * 3.0 / 4.0),
0x2587 => paint_quad(x, y + h / 8.0, w, h * 7.0 / 8.0),
0x2588 => paint_quad(x, y, w, h),
0x2589 => paint_quad(x, y, w * 7.0 / 8.0, h),
0x258A => paint_quad(x, y, w * 3.0 / 4.0, h),
0x258B => paint_quad(x, y, w * 5.0 / 8.0, h),
0x258C => paint_quad(x, y, w / 2.0, h),
0x258D => paint_quad(x, y, w * 3.0 / 8.0, h),
0x258E => paint_quad(x, y, w / 4.0, h),
0x258F => paint_quad(x, y, w / 8.0, h),
0x2590 => paint_quad(x + w / 2.0, y, w / 2.0, h),
0x2594 => paint_quad(x, y, w, h / 8.0),
0x2595 => paint_quad(x + w * 7.0 / 8.0, y, w / 8.0, h),
0x2596 => paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0),
0x2597 => paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0),
0x2598 => paint_quad(x, y, w / 2.0, h / 2.0),
0x2599 => {
paint_quad(x, y, w / 2.0, h);
paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259A => {
paint_quad(x, y, w / 2.0, h / 2.0);
paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259B => {
paint_quad(x, y, w, h / 2.0);
paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259C => {
paint_quad(x, y, w, h / 2.0);
paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259D => paint_quad(x + w / 2.0, y, w / 2.0, h / 2.0),
0x259E => {
paint_quad(x + w / 2.0, y, w / 2.0, h / 2.0);
paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259F => {
paint_quad(x + w / 2.0, y, w / 2.0, h);
paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0);
}
// --- Basic Box Drawing (U+2500..=U+257F) ---
// Light lines
0x2500 => paint_quad(x, cy - light / 2.0, w, light), // Horizontal
0x2502 => paint_quad(cx - light / 2.0, y, light, h), // Vertical
0x250C => {
// Down + Right
paint_quad(
cx - light / 2.0,
cy - light / 2.0,
light,
h / 2.0 + light / 2.0,
);
paint_quad(
cx - light / 2.0,
cy - light / 2.0,
w / 2.0 + light / 2.0,
light,
);
}
0x2510 => {
// Down + Left
paint_quad(
cx - light / 2.0,
cy - light / 2.0,
light,
h / 2.0 + light / 2.0,
);
paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light);
}
0x2514 => {
// Up + Right
paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0);
paint_quad(
cx - light / 2.0,
cy - light / 2.0,
w / 2.0 + light / 2.0,
light,
);
}
0x2518 => {
// Up + Left
paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0);
paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light);
}
0x251C => {
// Vertical + Right
paint_quad(cx - light / 2.0, y, light, h);
paint_quad(
cx - light / 2.0,
cy - light / 2.0,
w / 2.0 + light / 2.0,
light,
);
}
0x2524 => {
// Vertical + Left
paint_quad(cx - light / 2.0, y, light, h);
paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light);
}
0x252C => {
// Horizontal + Down
paint_quad(x, cy - light / 2.0, w, light);
paint_quad(
cx - light / 2.0,
cy - light / 2.0,
light,
h / 2.0 + light / 2.0,
);
}
0x2534 => {
// Horizontal + Up
paint_quad(x, cy - light / 2.0, w, light);
paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0);
}
0x253C => {
// Vertical + Horizontal (Cross)
paint_quad(x, cy - light / 2.0, w, light);
paint_quad(cx - light / 2.0, y, light, h);
}
// Heavy lines
0x2501 => paint_quad(x, cy - heavy / 2.0, w, heavy), // Heavy Horizontal
0x2503 => paint_quad(cx - heavy / 2.0, y, heavy, h), // Heavy Vertical
0x250F => {
// Heavy Down + Right
paint_quad(
cx - heavy / 2.0,
cy - heavy / 2.0,
heavy,
h / 2.0 + heavy / 2.0,
);
paint_quad(
cx - heavy / 2.0,
cy - heavy / 2.0,
w / 2.0 + heavy / 2.0,
heavy,
);
}
0x2513 => {
// Heavy Down + Left
paint_quad(
cx - heavy / 2.0,
cy - heavy / 2.0,
heavy,
h / 2.0 + heavy / 2.0,
);
paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
}
0x2517 => {
// Heavy Up + Right
paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0);
paint_quad(
cx - heavy / 2.0,
cy - heavy / 2.0,
w / 2.0 + heavy / 2.0,
heavy,
);
}
0x251B => {
// Heavy Up + Left
paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0);
paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
}
0x2523 => {
// Heavy Vertical + Right
paint_quad(cx - heavy / 2.0, y, heavy, h);
paint_quad(
cx - heavy / 2.0,
cy - heavy / 2.0,
w / 2.0 + heavy / 2.0,
heavy,
);
}
0x252B => {
// Heavy Vertical + Left
paint_quad(cx - heavy / 2.0, y, heavy, h);
paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
}
0x2533 => {
// Heavy Horizontal + Down
paint_quad(x, cy - heavy / 2.0, w, heavy);
paint_quad(
cx - heavy / 2.0,
cy - heavy / 2.0,
heavy,
h / 2.0 + heavy / 2.0,
);
}
0x253B => {
// Heavy Horizontal + Up
paint_quad(x, cy - heavy / 2.0, w, heavy);
paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0);
}
0x254B => {
// Heavy Vertical + Horizontal (Cross)
paint_quad(x, cy - heavy / 2.0, w, heavy);
paint_quad(cx - heavy / 2.0, y, heavy, h);
}
// --- Powerline ---
0xE0B0 => {
// Rightward Solid Arrow
let mut path = Path::new(point(x, y));
path.line_to(point(x + w, y + h / 2.0));
path.line_to(point(x, y + h));
path.line_to(point(x, y));
window.paint_path(path, color);
painted = true;
}
0xE0B2 => {
// Leftward Solid Arrow
let mut path = Path::new(point(x + w, y));
path.line_to(point(x, y + h / 2.0));
path.line_to(point(x + w, y + h));
path.line_to(point(x + w, y));
window.paint_path(path, color);
painted = true;
}
0xE0B1 => {
// Rightward Line Arrow
let t = px(1.0);
let mut p = Path::new(point(x, y));
p.line_to(point(x + w, y + h / 2.0));
p.line_to(point(x + w - t, y + h / 2.0));
p.line_to(point(x, y + t));
p.line_to(point(x, y));
window.paint_path(p, color);
let mut p2 = Path::new(point(x, y + h));
p2.line_to(point(x + w, y + h / 2.0));
p2.line_to(point(x + w - t, y + h / 2.0));
p2.line_to(point(x, y + h - t));
p2.line_to(point(x, y + h));
window.paint_path(p2, color);
painted = true;
}
0xE0B3 => {
// Leftward Line Arrow
let t = px(1.0);
let mut p = Path::new(point(x + w, y));
p.line_to(point(x, y + h / 2.0));
p.line_to(point(x + t, y + h / 2.0));
p.line_to(point(x + w, y + t));
p.line_to(point(x + w, y));
window.paint_path(p, color);
let mut p2 = Path::new(point(x + w, y + h));
p2.line_to(point(x, y + h / 2.0));
p2.line_to(point(x + t, y + h / 2.0));
p2.line_to(point(x + w, y + h - t));
p2.line_to(point(x + w, y + h));
window.paint_path(p2, color);
painted = true;
}
// Half arches (Powerline rounded)
0xE0B4 => {
// Rightward Solid Semicircle
let mut path = Path::new(point(x, y));
path.curve_to(point(x, y + h), point(x + w * 2.0, y + h / 2.0));
path.line_to(point(x, y));
window.paint_path(path, color);
painted = true;
}
0xE0B6 => {
// Leftward Solid Semicircle
let mut path = Path::new(point(x + w, y));
path.curve_to(point(x + w, y + h), point(x - w, y + h / 2.0));
path.line_to(point(x + w, y));
window.paint_path(path, color);
painted = true;
}
_ => {}
}
painted
}
@@ -11,6 +11,7 @@ use gpui::{
use gpui_component::ActiveTheme as _;
use crate::Ashell;
use crate::terminal::custom_blocks::{is_custom_block_supported, paint_custom_block};
use crate::terminal::{RenderSnapshot, ViewportSelection};
#[derive(Clone, Copy)]
@@ -138,12 +139,24 @@ pub struct PrepaintState {
metrics: TerminalMetrics,
rects: Vec<LayoutRect>,
runs: Vec<BatchedTextRun>,
custom_blocks: Vec<LayoutCustomBlock>,
cursor: Option<CursorLayout>,
}
#[derive(Clone)]
struct LayoutCustomBlock {
c: char,
row: i32,
col: i32,
cells: usize,
color: Hsla,
}
struct TerminalInputHandler {
view: Entity<Ashell>,
element_bounds: Bounds<Pixels>,
cell_width: f32,
line_height: f32,
}
impl InputHandler for TerminalInputHandler {
@@ -217,9 +230,12 @@ impl InputHandler for TerminalInputHandler {
_window: &mut Window,
cx: &mut App,
) -> Option<Bounds<Pixels>> {
self.view
.read(cx)
.terminal_ime_bounds_for_range(range_utf16, self.element_bounds)
self.view.read(cx).terminal_ime_bounds_for_range(
range_utf16,
self.element_bounds,
self.cell_width,
self.line_height,
)
}
fn character_index_for_point(
@@ -329,9 +345,13 @@ impl TerminalElement {
}
}
fn layout_grid(&self, cx: &App) -> (Vec<LayoutRect>, Vec<BatchedTextRun>) {
fn layout_grid(
&self,
cx: &App,
) -> (Vec<LayoutRect>, Vec<BatchedTextRun>, Vec<LayoutCustomBlock>) {
let mut rects = Vec::new();
let mut runs = Vec::new();
let mut custom_blocks = Vec::new();
let mut current_run: Option<BatchedTextRun> = None;
for render_cell in &self.snapshot.cells {
@@ -350,7 +370,11 @@ impl TerminalElement {
rects.push(LayoutRect {
row: render_cell.row,
col: render_cell.col,
cells: 1,
cells: if cell.flags.contains(Flags::WIDE_CHAR) {
2
} else {
1
},
color: if selected {
cx.theme().selection
} else if cell.flags.contains(Flags::INVERSE) {
@@ -369,6 +393,28 @@ impl TerminalElement {
}
let style = self.cell_run_style(cell, cx);
// Box Drawing & Block Elements interception
let is_custom_block = is_custom_block_supported(cell.c);
if is_custom_block {
if let Some(run) = current_run.take() {
runs.push(run);
}
custom_blocks.push(LayoutCustomBlock {
c: cell.c,
row: render_cell.row,
col: render_cell.col,
cells: if cell.flags.contains(Flags::WIDE_CHAR) {
2
} else {
1
},
color: style.color,
});
continue;
}
if let Some(run) = current_run.as_mut() {
if run.can_append(&style, render_cell.row, render_cell.col) {
run.append(cell.c, cell.zerowidth());
@@ -400,7 +446,7 @@ impl TerminalElement {
runs.push(run);
}
(merge_rects(rects), runs)
(merge_rects(rects), runs, custom_blocks)
}
fn cursor_layout(&self, cx: &App) -> Option<CursorLayout> {
@@ -456,7 +502,7 @@ impl Element for TerminalElement {
cx: &mut App,
) -> Self::PrepaintState {
let _ = self.base_text_style(cx);
let (rects, runs) = self.layout_grid(cx);
let (rects, runs, custom_blocks) = self.layout_grid(cx);
PrepaintState {
bounds,
metrics: TerminalMetrics {
@@ -465,6 +511,7 @@ impl Element for TerminalElement {
},
rects,
runs,
custom_blocks,
cursor: self.cursor_layout(cx),
}
}
@@ -487,11 +534,29 @@ impl Element for TerminalElement {
run.paint(prepaint.bounds.origin, prepaint.metrics, window, cx);
}
for block in &prepaint.custom_blocks {
let x = prepaint.bounds.origin.x.as_f32()
+ block.col as f32 * prepaint.metrics.cell_width.as_f32();
let y = prepaint.bounds.origin.y.as_f32()
+ block.row as f32 * prepaint.metrics.line_height.as_f32();
paint_custom_block(
window,
block.c,
x,
y,
prepaint.metrics.cell_width.as_f32() * block.cells as f32,
prepaint.metrics.line_height.as_f32(),
block.color,
);
}
window.handle_input(
&self.focus_handle,
TerminalInputHandler {
view: self.view.clone(),
element_bounds: prepaint.bounds,
cell_width: prepaint.metrics.cell_width.as_f32(),
line_height: prepaint.metrics.line_height.as_f32(),
},
cx,
);
+487
View File
@@ -0,0 +1,487 @@
use std::ops::Range;
use alacritty_terminal::index::Side;
use alacritty_terminal::selection::SelectionType;
use gpui::{
ClipboardItem, Context, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent,
MouseUpEvent, Pixels, Point, ScrollDelta, ScrollWheelEvent, Window, px,
};
use crate::{
Ashell, TerminalBacktabKey, TerminalTabKey,
terminal::{BackendCommand, encode_key},
};
impl Ashell {
pub(crate) fn on_terminal_key_down(
&mut self,
event: &KeyDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
// Pane navigation: Alt + h/j/k/l
if event.keystroke.modifiers.alt
&& !event.keystroke.modifiers.shift
&& !event.keystroke.modifiers.control
&& !event.keystroke.modifiers.platform
{
match event.keystroke.key.to_ascii_lowercase().as_str() {
"h" => self.focus_adjacent_pane("left"),
"j" => self.focus_adjacent_pane("down"),
"k" => self.focus_adjacent_pane("up"),
"l" => self.focus_adjacent_pane("right"),
"q" => {
if let Some(active_id) = self.active_tab.clone() {
self.close_tab(active_id, cx);
}
}
_ => return,
}
window.prevent_default();
cx.stop_propagation();
cx.notify();
return;
}
// Pane split: Shift+Alt + h/j/k/l
if event.keystroke.modifiers.shift
&& event.keystroke.modifiers.alt
&& !event.keystroke.modifiers.control
&& !event.keystroke.modifiers.platform
{
let direction = match event.keystroke.key.to_ascii_lowercase().as_str() {
"h" => Some("left"),
"j" => Some("down"),
"k" => Some("up"),
"l" => Some("right"),
_ => None,
};
if let Some(dir) = direction {
self.split_current_pane(dir, cx);
window.prevent_default();
cx.stop_propagation();
cx.notify();
return;
}
}
if event.keystroke.modifiers.secondary() && event.keystroke.key == "," {
self.show_settings_dialog(window, cx);
window.prevent_default();
cx.stop_propagation();
return;
}
if event.keystroke.modifiers.shift
&& event.keystroke.modifiers.secondary()
&& event.keystroke.key == "o"
{
self.show_selector_dialog(window, cx);
window.prevent_default();
cx.stop_propagation();
return;
}
if event.keystroke.modifiers.secondary() && event.keystroke.key.eq_ignore_ascii_case("c") {
if let Some(text) = self.active_terminal_selection_text() {
cx.write_to_clipboard(ClipboardItem::new_string(text));
window.prevent_default();
cx.stop_propagation();
return;
}
}
if event.keystroke.modifiers.secondary() && event.keystroke.key.eq_ignore_ascii_case("v") {
if let Some(clipboard) = cx.read_from_clipboard() {
if let Some(text) = clipboard.text() {
self.paste_into_terminal(&text, window, cx);
return;
}
}
}
if event.prefer_character_input {
if let Some(text) = event.keystroke.key_char.as_deref() {
if !text.is_empty()
&& !event.keystroke.modifiers.control
&& !event.keystroke.modifiers.function
&& !event.keystroke.modifiers.platform
{
self.send_terminal_input(text.as_bytes().to_vec(), window, cx);
}
}
return;
}
let Some(active_id) = self.active_tab.clone() else {
return;
};
let Some(tab) = self.tabs.iter_mut().find(|t| t.id == active_id) else {
return;
};
if tab.render_snapshot().display_offset > 0 {
tab.scroll_to_bottom();
}
tab.clear_selection();
if let Some(bytes) = encode_key(&event.keystroke, tab.app_cursor_mode(), false) {
tab.backend.send(BackendCommand::Input(bytes));
window.prevent_default();
cx.stop_propagation();
cx.notify();
}
}
pub(crate) fn on_terminal_tab_action(
&mut self,
_: &TerminalTabKey,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.send_terminal_input(vec![b'\t'], window, cx);
}
pub(crate) fn on_terminal_backtab_action(
&mut self,
_: &TerminalBacktabKey,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.send_terminal_input(b"\x1b[Z".to_vec(), window, cx);
}
fn send_terminal_input(&mut self, bytes: Vec<u8>, window: &mut Window, cx: &mut Context<Self>) {
let Some(active_id) = self.active_tab.clone() else {
return;
};
let Some(tab) = self.tabs.iter_mut().find(|t| t.id == active_id) else {
return;
};
if tab.render_snapshot().display_offset > 0 {
tab.scroll_to_bottom();
}
tab.clear_selection();
tab.backend.send(BackendCommand::Input(bytes));
window.prevent_default();
cx.stop_propagation();
cx.notify();
}
fn active_terminal_selection_text(&self) -> Option<String> {
let active_id = self.active_tab.as_ref()?;
self.tabs
.iter()
.find(|tab| &tab.id == active_id)
.and_then(|tab| tab.selection_text())
}
fn paste_into_terminal(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
let Some(active_id) = self.active_tab.clone() else {
return;
};
let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) else {
return;
};
if tab.render_snapshot().display_offset > 0 {
tab.scroll_to_bottom();
}
tab.clear_selection();
tab.paste_text(text);
window.prevent_default();
cx.stop_propagation();
cx.notify();
}
pub(crate) fn terminal_accepts_text_input(&self) -> bool {
self.active_tab.is_some()
}
pub(crate) fn terminal_marked_text_range(&self) -> Option<Range<usize>> {
self.terminal_marked_text
.as_ref()
.map(|text| 0..text.encode_utf16().count())
}
pub(crate) fn set_terminal_marked_text(
&mut self,
text: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.terminal_marked_text = if text.is_empty() { None } else { Some(text) };
window.invalidate_character_coordinates();
cx.notify();
}
pub(crate) fn clear_terminal_marked_text(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.terminal_marked_text.take().is_some() {
window.invalidate_character_coordinates();
cx.notify();
}
}
pub(crate) fn commit_terminal_ime_text(
&mut self,
text: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(active_id) = self.active_tab.clone() else {
return;
};
let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) else {
return;
};
if tab.render_snapshot().display_offset > 0 {
tab.scroll_to_bottom();
}
tab.clear_selection();
self.terminal_marked_text = None;
tab.backend
.send(BackendCommand::Input(text.as_bytes().to_vec()));
window.invalidate_character_coordinates();
cx.notify();
}
pub(crate) fn on_terminal_right_click(
&mut self,
_event: &MouseDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.config.right_click_copy_paste() {
return;
}
let mut handled = false;
if let Some(text) = self.active_terminal_selection_text() {
if !text.is_empty() {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
let active_id = self.active_tab.clone();
if let Some(active_id) = active_id {
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
tab.clear_selection();
}
}
cx.notify();
handled = true;
}
}
if !handled {
if let Some(clipboard_item) = cx.read_from_clipboard() {
if let Some(text) = clipboard_item.text() {
if !text.is_empty() {
self.paste_into_terminal(&text, window, cx);
}
}
}
}
}
pub(crate) fn begin_terminal_selection(
&mut self,
event: &MouseDownEvent,
cx: &mut Context<Self>,
) {
let click_count = event.click_count.max(1);
let selection_type = match click_count {
1 => SelectionType::Simple,
2 => SelectionType::Semantic,
3 => SelectionType::Lines,
_ => SelectionType::Simple,
};
let Some((row, col, side)) = self.terminal_grid_point_and_side(event.position) else {
return;
};
let Some(active_id) = self.active_tab.clone() else {
return;
};
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
tab.begin_selection(row, col, side, selection_type);
self.terminal_selecting = true;
cx.notify();
}
}
pub(crate) fn on_terminal_mouse_move(
&mut self,
event: &MouseMoveEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
// Handle split drag
if self.dragging_splitter.is_some() {
if event.pressed_button == Some(MouseButton::Left) {
self.on_split_drag_move(event, window, cx);
cx.notify();
} else {
self.end_drag_split();
cx.notify();
}
return;
}
if !self.terminal_selecting || event.pressed_button != Some(MouseButton::Left) {
return;
}
let Some((row, col, side)) = self.terminal_grid_point_and_side(event.position) else {
return;
};
let Some(active_id) = self.active_tab.clone() else {
return;
};
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
tab.update_selection(row, col, side);
cx.notify();
}
}
pub(crate) fn on_terminal_mouse_up(
&mut self,
_event: &MouseUpEvent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if self.dragging_splitter.is_some() {
self.end_drag_split();
}
self.terminal_selecting = false;
cx.notify();
}
fn terminal_grid_point_and_side(
&self,
position: Point<Pixels>,
) -> Option<(usize, usize, Side)> {
let active_id = self.active_tab.as_ref()?;
let bounds = self.terminal_bounds.get(active_id)?;
if !bounds.contains(&position) {
// Try other pane bounds
for (_, b) in &self.terminal_bounds {
if b.contains(&position) {
// Found a different pane - focus it
// (this path is for click-to-focus; handled via focus_terminal)
return None;
}
}
return None;
}
let local_x = (position.x - bounds.origin.x).max(px(0.));
let local_y = (position.y - bounds.origin.y).max(px(0.));
let cell_width = px(self.terminal_cell_width());
let line_height = px(self.terminal_line_height());
let snapshot = self.active_snapshot()?;
let max_col = snapshot.cols.saturating_sub(1);
let max_row = snapshot.rows.saturating_sub(1);
let col = ((local_x / cell_width).floor() as usize).min(max_col);
let row = ((local_y / line_height).floor() as usize).min(max_row);
let cell_offset_x = px(local_x.as_f32() % cell_width.as_f32());
let side = if cell_offset_x >= (cell_width / 2.) {
Side::Right
} else {
Side::Left
};
Some((row, col, side))
}
pub(crate) fn on_terminal_scroll(
&mut self,
event: &ScrollWheelEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(active_id) = self.active_tab.clone() else {
return;
};
// Get coordinates before mutably borrowing tabs
let grid_point = self.terminal_grid_point_and_side(event.position);
let line_height = self.terminal_line_height();
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
let delta_lines = match event.delta {
ScrollDelta::Lines(point) => point.y.round() as i32,
ScrollDelta::Pixels(point) => {
tab.scroll_pixel_y += point.y.as_f32();
let lines = (tab.scroll_pixel_y / line_height).trunc() as i32;
tab.scroll_pixel_y -= (lines as f32) * line_height;
lines
}
};
if delta_lines == 0 {
return;
}
let mode = tab.term.mode();
let is_mouse_tracking = mode.intersects(
alacritty_terminal::term::TermMode::MOUSE_REPORT_CLICK
| alacritty_terminal::term::TermMode::MOUSE_MOTION
| alacritty_terminal::term::TermMode::MOUSE_DRAG,
);
let is_alternate_scroll = mode.contains(
alacritty_terminal::term::TermMode::ALT_SCREEN
| alacritty_terminal::term::TermMode::ALTERNATE_SCROLL,
);
if is_mouse_tracking {
if let Some((row, col, _)) = grid_point {
let sgr = mode.contains(alacritty_terminal::term::TermMode::SGR_MOUSE);
let button = if delta_lines > 0 { 64 } else { 65 };
let times = delta_lines.abs();
let mut bytes = Vec::new();
for _ in 0..times {
if sgr {
bytes.extend_from_slice(
format!("\x1b[<{};{};{}M", button, col + 1, row + 1).as_bytes(),
);
} else {
if col < 223 && row < 223 {
bytes.extend_from_slice(b"\x1b[M");
bytes.push(button as u8 + 32);
bytes.push(col as u8 + 33);
bytes.push(row as u8 + 33);
}
}
}
if !bytes.is_empty() {
tab.backend
.send(crate::terminal::BackendCommand::Input(bytes));
}
}
window.prevent_default();
cx.stop_propagation();
return;
} else if is_alternate_scroll {
let times = delta_lines.abs();
let code = if delta_lines > 0 { b'A' } else { b'B' };
let mut bytes = Vec::with_capacity((times * 3) as usize);
for _ in 0..times {
bytes.extend_from_slice(&[b'\x1b', b'O', code]);
}
if !bytes.is_empty() {
tab.backend
.send(crate::terminal::BackendCommand::Input(bytes));
}
window.prevent_default();
cx.stop_propagation();
return;
}
tab.scroll_history(delta_lines);
window.prevent_default();
cx.stop_propagation();
cx.notify();
}
}
}
+64 -17
View File
@@ -1,3 +1,7 @@
pub mod custom_blocks;
pub mod element;
pub mod input;
use std::sync::mpsc::Sender;
use alacritty_terminal::{
@@ -10,7 +14,7 @@ use alacritty_terminal::{
};
use gpui::Keystroke;
use crate::config::Session;
use crate::session::config::Session;
use crate::sftp::{PreviewData, RemoteEntry};
use crate::system::SystemSnapshot;
@@ -24,6 +28,7 @@ pub enum TabKind {
pub enum BackendCommand {
Input(Vec<u8>),
Resize { cols: u16, rows: u16 },
SampleMetrics,
Close,
}
@@ -61,6 +66,10 @@ pub enum BackendEvent {
tab_id: String,
reason: String,
},
SftpHome {
tab_id: String,
home: String,
},
TransferProgress {
#[allow(dead_code)]
tab_id: String,
@@ -109,12 +118,12 @@ pub struct TerminalTab {
pub status: String,
pub connected: bool,
pub session: Option<Session>,
pub sftp: Option<SftpUiState>,
processor: Processor,
term: Term<TerminalListener>,
cols: u16,
rows: u16,
pub backend: BackendTx,
pub scroll_pixel_y: f32,
}
#[derive(Clone, Copy)]
@@ -159,6 +168,7 @@ pub struct SftpUiState {
pub selected_path: Option<String>,
pub preview: Option<PreviewData>,
pub selected_entries: std::collections::HashSet<String>,
pub home_dir: String,
}
impl TerminalTab {
@@ -197,14 +207,6 @@ impl TerminalTab {
);
tab.session = Some(session.clone());
tab.connected = false;
tab.sftp = Some(SftpUiState {
current_path: "/".into(),
status: t!("sftp_connecting").to_string(),
entries: Vec::new(),
selected_path: None,
preview: None,
selected_entries: std::collections::HashSet::new(),
});
tab
}
@@ -223,12 +225,12 @@ impl TerminalTab {
status,
connected: matches!(kind, TabKind::Local),
session: None,
sftp: None,
processor: Processor::new(),
term: new_term(100, 30, backend.clone(), id, events),
cols: 100,
rows: 30,
backend,
scroll_pixel_y: 0.0,
}
}
@@ -237,10 +239,19 @@ impl TerminalTab {
}
pub fn resize(&mut self, cols: u16, rows: u16) {
self.cols = cols.max(1);
self.rows = rows.max(1);
self.term.resize(TerminalSize::new(self.cols, self.rows));
self.backend.send(BackendCommand::Resize { cols, rows });
let new_cols = cols.max(1);
let new_rows = rows.max(1);
if self.cols != new_cols || self.rows != new_rows {
self.cols = new_cols;
self.rows = new_rows;
tracing::info!(
"[ui] terminal resized to {}x{} (cols x rows)",
self.cols,
self.rows
);
self.term.resize(TerminalSize::new(self.cols, self.rows));
self.backend.send(BackendCommand::Resize { cols, rows });
}
}
pub fn cursor_state(&self) -> Option<CursorState> {
@@ -437,7 +448,6 @@ impl EventListener for TerminalListener {
}
}
use rust_i18n::t;
fn new_term(
cols: u16,
rows: u16,
@@ -668,15 +678,52 @@ pub enum TransferType {
Download,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub enum TransferState {
Running,
Paused,
Completed,
Failed(String),
Interrupted(String), // 中断传输:包含原因(例如 "User cancelled", "Network timeout")
Zombie(String), // 程序重启后残留的 Running/Paused 任务
// 兼容 v0.3.11 -> v0.4.x:旧配置里曾保存过 `Cancelled`,
// 新版本改成了带原因的状态,因此要手动接住旧枚举值。
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
enum TransferStateCompat {
Running,
Paused,
Completed,
Failed(String),
Interrupted(String),
Zombie(String),
Cancelled,
}
impl From<TransferStateCompat> for TransferState {
fn from(value: TransferStateCompat) -> Self {
match value {
TransferStateCompat::Running => Self::Running,
TransferStateCompat::Paused => Self::Paused,
TransferStateCompat::Completed => Self::Completed,
TransferStateCompat::Failed(reason) => Self::Failed(reason),
TransferStateCompat::Interrupted(reason) => Self::Interrupted(reason),
TransferStateCompat::Zombie(reason) => Self::Zombie(reason),
TransferStateCompat::Cancelled => Self::Interrupted("Cancelled".to_string()),
}
}
}
impl<'de> serde::Deserialize<'de> for TransferState {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
TransferStateCompat::deserialize(deserializer).map(Into::into)
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TransferInfo {
pub id: String,