Compare commits

...
92 Commits
Author SHA1 Message Date
TomZz c65354e8d6 fix(ui): resolve terminal split-screen sizing alignment and remainder margin gap 2026-06-29 17:13:35 +08:00
TomZz fcfbefe3ab fix: do not label Maple Mono as built-in if loaded from system 2026-06-27 18:32:50 +08:00
TomZz 39d369aaaf fix: conditionally load built-in fonts if not present in system (resolves #65) 2026-06-27 18:28:25 +08:00
TomZz 5c0c0cc701 fix: default empty or missing session proxy type to 'none' 2026-06-27 18:23:46 +08:00
TomZz 99b7fe30e4 fix: restrict terminal copy/paste actions to terminal focus 2026-06-27 18:21:28 +08:00
TomZz 24f817f9e0 revert: remove manual keyboard-interactive authentication settings and dialog 2026-06-27 18:13:37 +08:00
TomZz 1a3f638004 fix: support bracketed paste mode (fixes #62) 2026-06-27 11:48:17 +08:00
TomZz 5f318f66c8 fix: draw background rectangle for cells with INVERSE flag (fixes #66) 2026-06-27 11:44:29 +08:00
TomZz 8854ee9afb fix: restore connection progress dialog and support Enter to retry 2026-06-27 00:37:33 +08:00
TomZz 8cc64acdc1 fix: smooth terminal font zooming using scroll accumulator 2026-06-27 00:23:20 +08:00
peace-young 77a0380ba7 feat: add Ctrl+scroll to zoom terminal font size (#64) 2026-06-27 00:08:23 +08:00
TomZz 3770826764 bump version to v0.4.8 2026-06-26 11:41:23 +08:00
TomZz cae6323888 fix: reduce keepalive interval and max attempts for faster network drop detection 2026-06-26 11:35:13 +08:00
TomZz 54bcc97fe1 fix: restore graceful terminal exit tab closure 2026-06-26 11:29:50 +08:00
phygerr 273bc2fe0c feature:1、支持断开重连功能;2、支持更多高亮关键词 (#59) 2026-06-26 11:07:25 +08:00
TomZz f0e02e0ce7 fix: resolve sftp connection timeout and disconnect handling (resolves #57) 2026-06-25 03:32:31 +08:00
TomZz f6d9cd00e7 feat: add global proxy settings and startup env reader (resolves #53) 2026-06-25 03:20:33 +08:00
TomZz bfc7c9ac83 feat: support keybinding configuration for terminal copy/paste and update hint text 2026-06-25 02:33:34 +08:00
TomZz fd19daedad feat: allow left-click to copy terminal selection text 2026-06-25 02:28:26 +08:00
TomZz 042834c7d5 feat: support custom terminal cursor style and blinking setting 2026-06-25 02:25:32 +08:00
TomZz 3c6b9e3b46 fix: correct active window check logic to fix window controls greying out when app loses focus 2026-06-24 18:06:52 +08:00
TomZz a88379b01c fix: prevent sidebar from flashing and resizing on window resize / fullscreen 2026-06-24 18:01:40 +08:00
TomZz f322b2c717 fix: beautify window control traffic lights on Windows/Linux 2026-06-24 17:46:57 +08:00
TomZz 45d0c64c06 fix: resolve gpui window not found on exit and restore layout saving on Windows 2026-06-24 17:25:25 +08:00
TomZz 6312a482cb fix: preserve original windowed bounds when saving maximized state to fix restore size on startup 2026-06-24 17:12:33 +08:00
TomZz 8bb4cbc655 fix: register WindowControlArea for window controls on non-macos to support restore toggling 2026-06-24 16:58:22 +08:00
TomZz 847f0626f4 refactor: use titlebar_double_click to toggle maximize/restore correctly 2026-06-24 16:24:15 +08:00
TomZz 7cfe5c64d5 refactor: manually implement Default for ConfigFile to respect default settings 2026-06-23 23:57:30 +08:00
TomZz 6d23d27fea feat: implement throttled auto-scrolling when dragging selection near viewport boundaries 2026-06-23 15:39:01 +08:00
TomZz da856ac9d7 fix: clamp selection coordinates when they scroll out of viewport to prevent selection from breaking 2026-06-23 15:28:46 +08:00
TomZz 7653c66a6f feat: add setting switch for terminal keyword highlighting under copy/paste option 2026-06-23 10:44:13 +08:00
TomZz 56fe508122 refactor: optimize keyword highlighting by caching results and moving logic out of custom_blocks 2026-06-23 10:44:11 +08:00
phygerr 3b7165a4bd feature:1、新增Phyger主题;2、支持terminal搜索 (#52) 2026-06-23 10:43:26 +08:00
TomZz 5ae44c9e44 bump version to 0.4.7 2026-06-23 00:23:11 +08:00
TomZz 73938aef8a feat: support keyboard-interactive auth and interactive passphrase prompts (resolves #33) 2026-06-23 00:16:48 +08:00
TomZz 59df2b5a9a feat: add context menu to collapsed sidebar session cards 2026-06-22 12:48:20 +08:00
TomZz b225b0e56f fix: persist sidebar and sftp panel state on window close and remove startup flashing 2026-06-22 12:36:20 +08:00
TomZz 2cdd6d3ded Merge branch 'pr-47' 2026-06-22 10:53:17 +08:00
TomZz 42c408f63f style: change sync backend selection to segmented buttons 2026-06-22 10:53:14 +08:00
TomZz f4005bf2bb Merge pull request #46 from WutangNailao/main
fix: pin Zed GPUI dependencies to stable-compatible revision
2026-06-22 10:35:07 +08:00
nailao d5fab2e9ca feat: add encrypted config sync via WebDAV and S3
- encrypt SSH configuration with Argon2id and XChaCha20-Poly1305
- support WebDAV and S3-compatible storage backends
- add ETag-based conflict protection
- add sync settings and localized UI
- keep remote credentials in memory only
2026-06-20 09:47:26 +08:00
nailao 1b7f625aea fix: pin Zed GPUI dependencies to stable-compatible revision
cause the newer version of GPUI use unstable feature `std::hint::cold_path`, it made we can not build success, so pin it to a specific commit.
2026-06-20 09:07:45 +08:00
TomZz b6b4d3c35e feat: set integrated as default title bar style 2026-06-18 14:30:53 +08:00
TomZz ae763eae1c fix: title bar buttons unresponsive on windows in integrated mode 2026-06-18 14:28:58 +08:00
TomZz a89d15d255 feat: add title bar style option (native vs integrated)
Introduced a new configuration setting allowing users to choose between:
1. Native: Standard OS title bar with the tab bar rendered below it.
2. Integrated: Custom title bar with Mac-style controls and embedded tabs.

Updated:
- ConfigFile/Store to include title_bar_style (defaulting to Native).
- localization files for English and Chinese with 'Restart required' hints.
- Settings dialog with a new dropdown in the Appearance group.
- Startup and UI logic to handle conditional title bar and tab bar rendering.
- UI now uses the title bar style detected at startup to ensure consistency
  until the application is restarted.
- Restored Windows app icon loading in startup.rs.
- Fixed Native mode layout to match previous standard behavior.
2026-06-18 13:31:51 +08:00
TomZz b5a068541d feat: implement fully custom title bar with mac-style controls
Replaced gpui-component's TitleBar with a self-drawn one that provides:
1. Mac-style traffic light window controls on the left.
2. App session and split pane buttons on the right.
3. Tab bar in the middle with robust flex-shrink and overflow-hidden
   logic to prevent it from squeezing the side buttons when many tabs
   are open.
2026-06-18 13:10:27 +08:00
TomZz 7be0c420e1 chore: release v0.4.6 2026-06-18 08:20:33 +08:00
TomZz 62028c7921 feat: replace native title bar with custom TitleBar and integrate tab bar
This commit removes the native OS title bar and introduces a cross-platform custom title bar using gpui-component's TitleBar. To optimize vertical screen space, the tab bar has been relocated into the newly created custom title bar.

Fixes #37
2026-06-18 08:18:00 +08:00
TomZz 56a6ef98b7 feat: add tooltips for sidebar and tab bar buttons 2026-06-18 07:58:50 +08:00
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
31 changed files with 9441 additions and 1938 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
+447 -27
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"
@@ -252,6 +265,18 @@ dependencies = [
"syn",
]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -264,6 +289,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,11 +315,14 @@ dependencies = [
[[package]]
name = "ashell"
version = "0.1.0"
version = "0.4.8"
dependencies = [
"alacritty_terminal",
"anyhow",
"argon2",
"async-trait",
"base64",
"chacha20poly1305",
"chrono",
"directories",
"flate2",
@@ -297,11 +331,15 @@ dependencies = [
"gpui-component",
"gpui-component-assets",
"gpui_platform",
"hex",
"hmac",
"image",
"menu",
"notify 6.1.1",
"open",
"portable-pty",
"rand 0.8.6",
"reqwest",
"rfd",
"russh",
"russh-keys",
@@ -309,13 +347,17 @@ dependencies = [
"rust-i18n 3.1.5",
"serde",
"serde_json",
"sha2",
"ssh-key",
"sys-locale",
"sysinfo 0.33.1",
"tar",
"thiserror 1.0.69",
"time",
"tokio",
"tokio-socks",
"tracing",
"tracing-appender",
"tracing-subscriber",
"uuid",
"walkdir",
@@ -352,9 +394,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",
]
@@ -816,6 +863,15 @@ dependencies = [
"no_std_io2",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block"
version = "0.1.6"
@@ -973,6 +1029,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"
@@ -1053,6 +1121,19 @@ dependencies = [
"cpufeatures",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"cipher",
"poly1305",
"zeroize",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -1163,9 +1244,8 @@ dependencies = [
[[package]]
name = "collections"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
dependencies = [
"gpui_util",
"indexmap",
"rustc-hash 2.1.2",
]
@@ -1230,6 +1310,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"
@@ -1594,6 +1694,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"
@@ -1631,7 +1740,7 @@ dependencies = [
[[package]]
name = "derive_refineable"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
dependencies = [
"proc-macro2",
"quote",
@@ -2406,6 +2515,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"
@@ -2605,7 +2724,7 @@ dependencies = [
[[package]]
name = "gpui"
version = "0.2.2"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
dependencies = [
"accesskit",
"anyhow",
@@ -2635,7 +2754,6 @@ dependencies = [
"gpui_macros",
"gpui_shared_string",
"gpui_util",
"heapless",
"http_client",
"image",
"inventory",
@@ -2755,22 +2873,29 @@ dependencies = [
[[package]]
name = "gpui_linux"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
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",
@@ -2783,12 +2908,23 @@ 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]]
name = "gpui_macos"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
dependencies = [
"accesskit",
"accesskit_macos",
@@ -2833,7 +2969,7 @@ dependencies = [
[[package]]
name = "gpui_macros"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -2844,7 +2980,7 @@ dependencies = [
[[package]]
name = "gpui_platform"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
dependencies = [
"console_error_panic_hook",
"gpui",
@@ -2857,7 +2993,7 @@ dependencies = [
[[package]]
name = "gpui_shared_string"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
dependencies = [
"schemars",
"serde",
@@ -2867,7 +3003,7 @@ dependencies = [
[[package]]
name = "gpui_util"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
dependencies = [
"anyhow",
"log",
@@ -2876,7 +3012,7 @@ dependencies = [
[[package]]
name = "gpui_web"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
dependencies = [
"anyhow",
"console_error_panic_hook",
@@ -2900,7 +3036,7 @@ dependencies = [
[[package]]
name = "gpui_wgpu"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
dependencies = [
"anyhow",
"bytemuck",
@@ -2923,12 +3059,13 @@ dependencies = [
"wasm-bindgen-futures",
"web-sys",
"wgpu",
"zed-font-kit",
]
[[package]]
name = "gpui_windows"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
dependencies = [
"accesskit",
"accesskit_windows",
@@ -3177,7 +3314,7 @@ dependencies = [
[[package]]
name = "http_client"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
dependencies = [
"anyhow",
"async-compression",
@@ -3240,6 +3377,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -3248,13 +3386,16 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64",
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"tokio",
@@ -4050,7 +4191,7 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
[[package]]
name = "media"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
dependencies = [
"anyhow",
"bindgen",
@@ -4098,7 +4239,7 @@ dependencies = [
[[package]]
name = "menu"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
dependencies = [
"gpui",
]
@@ -4455,6 +4596,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"
@@ -4517,6 +4664,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"
@@ -4903,6 +5059,17 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "paste"
version = "1.0.15"
@@ -4968,7 +5135,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "perf"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
dependencies = [
"collections",
"serde",
@@ -5286,6 +5453,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"
@@ -5744,7 +5917,7 @@ dependencies = [
[[package]]
name = "refineable"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
dependencies = [
"derive_refineable",
]
@@ -5784,6 +5957,44 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
name = "resvg"
version = "0.45.1"
@@ -6360,7 +6571,7 @@ dependencies = [
[[package]]
name = "scheduler"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
dependencies = [
"async-task",
"backtrace",
@@ -7099,7 +7310,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "sum_tree"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
dependencies = [
"heapless",
"log",
@@ -7213,6 +7424,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"
@@ -7448,6 +7665,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"
@@ -7689,6 +7948,24 @@ dependencies = [
"tower-service",
]
[[package]]
name = "tower-http"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags 2.12.1",
"bytes",
"futures-util",
"http",
"http-body",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
"url",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
@@ -7713,6 +7990,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"
@@ -7758,6 +8048,7 @@ dependencies = [
"sharded-slab",
"smallvec",
"thread_local",
"time",
"tracing",
"tracing-core",
"tracing-log",
@@ -8003,7 +8294,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "util"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
dependencies = [
"anyhow",
"async-fs",
@@ -8042,7 +8333,7 @@ dependencies = [
[[package]]
name = "util_macros"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
dependencies = [
"perf",
"quote",
@@ -8344,6 +8635,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"
@@ -8356,6 +8658,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"
@@ -8399,6 +8727,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
@@ -9376,6 +9713,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"
@@ -9407,6 +9774,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"
@@ -9679,6 +10086,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"
@@ -9799,7 +10219,7 @@ dependencies = [
[[package]]
name = "zlog"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
dependencies = [
"anyhow",
"chrono",
@@ -9828,7 +10248,7 @@ dependencies = [
[[package]]
name = "ztracing"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
dependencies = [
"tracing",
"tracing-subscriber",
@@ -9839,7 +10259,7 @@ dependencies = [
[[package]]
name = "ztracing_macro"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
[[package]]
name = "zune-core"
+14 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "ashell"
version = "0.1.0"
version = "0.4.8"
edition = "2024"
authors = ["ashell contributors"]
description = "A GPUI Component based SSH and local terminal client"
@@ -9,13 +9,14 @@ rust-version = "1.85.0"
[dependencies]
anyhow = "1"
tokio-socks = "0.5"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "fcf32feacb367b75ec84dd40f041e4fd411d3cc1" }
async-trait = "0.1"
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,7 +35,7 @@ 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"] }
@@ -42,6 +43,16 @@ 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"
argon2 = "0.5"
base64 = "0.22"
chacha20poly1305 = "0.10"
hmac = "0.12"
hex = "0.4"
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
sha2 = "0.10"
[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 Settings** (Thanks to [@ancion](https://github.com/ancion) for [PR #6](https://github.com/rust-kotlin/ashell/pull/6)): Customize your global UI fonts and terminal rendering fonts independently. Adjust text size in real-time with automatic persistence.
- ✨ **Concurrent SFTP Transfers**: A dedicated Transfers dialog now supports highly optimized, concurrent file uploads and downloads with precise progress display.
- ✨ **Persistent Workspace State**: Your sidebar width adjustments, terminal themes, font settings, and file visibility preferences are completely restored upon your next launch!
- ✨ **Intelligent SSH Disconnect/Reconnect**: Greatly enhanced SSH connection resilience. Provides a beautiful and localized overlay for one-click re-connection on unexpected disconnections.
- ✨ **Hot-Swappable i18n System**: Deeply optimized internationalization. Switch instantly between English and Simplified Chinese without restarting the application.
- ✨ **Smart Right-Click Actions**: Added intelligent right-click copy and paste for the terminal, featuring a dedicated settings toggle and OS-native keyboard shortcut hints.
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 并发传输、布局记忆、断线感知、多语言热切换,以及终端右键复制粘贴等增强能力。
## 运行
+308
View File
@@ -0,0 +1,308 @@
{
"$schema": "https://github.com/longbridge/gpui-component/raw/refs/heads/main/.theme-schema.json",
"name": "Phyger",
"author": "phygerr",
"url": "https://github.com/phygerr",
"themes": [
{
"name": "Phyger Light",
"mode": "light",
"colors": {
"accent.background": "#D6E4F0",
"accent.foreground": "#1B3A5C",
"background": "#F8F9FC",
"border": "#D0D5DD",
"danger.background": "#D94848",
"danger.active.background": "#B53535",
"danger.foreground": "#FFFFFF",
"foreground": "#2B2D30",
"input.border": "#C0C7D0",
"link.active.foreground": "#2563EB",
"link.foreground": "#2563EB",
"link.hover.foreground": "#1D4ED8",
"list.active.background": "#2563EB18",
"list.hover.background": "#E8EDF4",
"list.active.border": "#2563EB",
"list.even.background": "#F0F2F7",
"muted.background": "#E2E6EE88",
"muted.foreground": "#7A7F88",
"panel.background": "#ECEEF3",
"popover.background": "#F8F9FC",
"popover.foreground": "#2B2D30",
"primary.active.background": "#1D4ED8",
"primary.background": "#2563EB",
"primary.foreground": "#FFFFFF",
"primary.hover.background": "#3B72F0",
"ring": "#2563EB",
"scrollbar.background": "#F8F9FC00",
"scrollbar.thumb.background": "#C0C7D0",
"secondary.background": "#E8EDF4",
"secondary.active.background": "#D0D5DD",
"secondary.foreground": "#2B2D30",
"secondary.hover.background": "#DCE2EB",
"tab.active.background": "#F8F9FC",
"tab.active.foreground": "#2B2D30",
"tab.background": "#ECEEF300",
"tab.foreground": "#7A7F88",
"tab_bar.background": "#ECEEF3",
"title_bar.background": "#ECEEF3",
"title_bar.border": "#D0D5DD",
"base.magenta.light": "#C2185B44",
"base.red": "#D32F2F",
"base.green": "#2E7D32",
"base.yellow": "#BF8C00",
"base.blue": "#1565C0",
"base.magenta": "#8E24AA",
"base.cyan": "#00838F"
},
"highlight": {
"editor.foreground": "#2B2D30",
"editor.background": "#F8F9FC",
"editor.active_line.background": "#E8EDF4",
"editor.line_number": "#A0A5AE",
"editor.active_line_number": "#2B2D30",
"editor.invisible": "#A0A5AE55",
"conflict": "#D32F2F",
"created": "#2E7D32",
"deleted": "#C62828",
"error": "#D32F2F",
"hidden": "#A0A5AE",
"hint": "#1565C0",
"ignored": "#A0A5AE",
"info": "#00838F",
"modified": "#BF8C00",
"predictive": "#A0A5AE",
"renamed": "#BF8C00",
"success": "#2E7D32",
"unreachable": "#A0A5AE",
"warning": "#E65100",
"syntax": {
"attribute": {
"color": "#7B1FA2"
},
"boolean": {
"color": "#00838F"
},
"comment": {
"color": "#6A737D",
"font_style": "italic"
},
"comment.doc": {
"color": "#6A737D",
"font_style": "italic"
},
"constant": {
"color": "#00838F"
},
"constructor": {
"color": "#1565C0"
},
"embedded": {
"color": "#2B2D30"
},
"function": {
"color": "#6A1B9A"
},
"keyword": {
"color": "#1565C0"
},
"link_text": {
"color": "#1565C0",
"font_style": "normal"
},
"link_uri": {
"color": "#00838F",
"font_style": "italic"
},
"number": {
"color": "#1B5E20"
},
"string": {
"color": "#BF360C"
},
"string.escape": {
"color": "#BF360C"
},
"string.regex": {
"color": "#BF360C"
},
"string.special": {
"color": "#00838F"
},
"string.special.symbol": {
"color": "#00838F"
},
"tag": {
"color": "#1565C0"
},
"text.literal": {
"color": "#00838F"
},
"title": {
"color": "#6A1B9A",
"font_weight": 600
},
"type": {
"color": "#2E7D32"
},
"property": {
"color": "#2B2D30"
},
"variable.special": {
"color": "#D32F2F"
}
}
}
},
{
"name": "Phyger Dark",
"mode": "dark",
"colors": {
"accent.background": "#2A3040",
"accent.foreground": "#C8D3E0",
"background": "#1A1D23",
"border": "#2E3340",
"danger.background": "#E05555",
"danger.active.background": "#CC3D3D",
"danger.foreground": "#FFFFFF",
"foreground": "#C8CED8",
"input.border": "#3A4050",
"link.active.foreground": "#6CB4EE",
"link.foreground": "#6CB4EE",
"link.hover.foreground": "#8FC8F6",
"list.active.background": "#6CB4EE15",
"list.hover.background": "#252A34",
"list.active.border": "#6CB4EE",
"list.even.background": "#1E2229",
"muted.background": "#252A3466",
"muted.foreground": "#6B7280",
"panel.background": "#1E2229",
"popover.background": "#1A1D23",
"popover.foreground": "#C8CED8",
"primary.active.background": "#4A90D9",
"primary.background": "#5BA0E0",
"primary.foreground": "#1A1D23",
"primary.hover.background": "#6CB4EE",
"ring": "#5BA0E0",
"scrollbar.background": "#1A1D2300",
"scrollbar.thumb.background": "#3A4050",
"secondary.active.background": "#333A48",
"secondary.background": "#252A34",
"secondary.foreground": "#C8CED8",
"secondary.hover.background": "#2E3542",
"tab.active.background": "#1A1D23",
"tab.active.foreground": "#C8CED8",
"tab.background": "#252A3400",
"tab.foreground": "#6B7280",
"tab_bar.background": "#1E2229DD",
"title_bar.background": "#1E2229",
"title_bar.border": "#2E3340",
"base.magenta.light": "#C792EA44",
"base.red": "#E06060",
"base.green": "#7EC699",
"base.yellow": "#E8C97A",
"base.blue": "#6CB4EE",
"base.magenta": "#C792EA",
"base.cyan": "#56D4C7"
},
"highlight": {
"editor.foreground": "#C8CED8",
"editor.background": "#1A1D23",
"editor.active_line.background": "#222730",
"editor.line_number": "#5A6170",
"editor.active_line_number": "#C8CED8",
"editor.invisible": "#5A617055",
"conflict": "#E06060",
"created": "#7EC699",
"deleted": "#CC4444",
"error": "#E06060",
"hidden": "#6B7280",
"hint": "#C792EA",
"ignored": "#5A6170",
"info": "#56D4C7",
"modified": "#E8C97A",
"predictive": "#4A5568",
"renamed": "#E8C97A",
"success": "#7EC699",
"unreachable": "#5A6170",
"warning": "#E8C97A",
"syntax": {
"attribute": {
"color": "#C792EA"
},
"boolean": {
"color": "#56D4C7"
},
"comment": {
"color": "#5A6A7A",
"font_style": "italic"
},
"comment.doc": {
"color": "#5A6A7A",
"font_style": "italic"
},
"constant": {
"color": "#56D4C7"
},
"constructor": {
"color": "#6CB4EE"
},
"embedded": {
"color": "#C8CED8"
},
"function": {
"color": "#E2C08D"
},
"keyword": {
"color": "#6CB4EE"
},
"link_text": {
"color": "#6CB4EE",
"font_style": "normal"
},
"link_uri": {
"color": "#56D4C7",
"font_style": "italic"
},
"number": {
"color": "#7EC699"
},
"string": {
"color": "#E8A87C"
},
"string.escape": {
"color": "#E8A87C"
},
"string.regex": {
"color": "#E8A87C"
},
"string.special": {
"color": "#56D4C7"
},
"string.special.symbol": {
"color": "#56D4C7"
},
"tag": {
"color": "#6CB4EE"
},
"text.literal": {
"color": "#56D4C7"
},
"title": {
"color": "#E2C08D",
"font_weight": 600
},
"type": {
"color": "#7EC699"
},
"property": {
"color": "#C8CED8"
},
"variable.special": {
"color": "#E06060"
}
}
}
}
]
}
+98 -3
View File
@@ -62,12 +62,22 @@ add_ssh: "+ ssh"
open_local_or_ssh: "Open a local terminal or pick an SSH session."
open_session: "Open Session"
settings: "Settings"
proxy: "Proxy"
proxy_none: "Follow Global"
proxy_host: "Proxy Host"
proxy_port: "Proxy Port"
proxy_user: "Proxy Username (optional)"
proxy_password: "Proxy Password (optional)"
ui_font_size: "UI Font Size"
terminal_font_size: "Terminal Font Size"
system_default: "System Default"
ui_font_family: "UI Font"
terminal_font_family: "Terminal Font"
theme_management_hint: "Theme mode and light/dark theme families are managed from the top-bar theme button."
cursor_style: "Cursor Style"
cursor_style_default: "Default"
cursor_style_blink: "Blink"
cursor_style_beam: "Line"
cursor_style_beam_blink: "Line Blink"
monitoring_position: "Monitoring Position"
position_bottom: "Bottom"
position_sidebar: "Sidebar"
@@ -77,6 +87,8 @@ local_terminal_opened: "local terminal opened"
connecting: "Connecting"
starting_connection: "starting connection..."
connection_failed: "Connection Failed"
session_disconnected: "Session disconnected: %{reason}"
press_enter_to_reconnect: "Press Enter to reconnect"
preview_failed: "Preview failed: %{err}"
downloading_file: "Downloading %{base}..."
@@ -95,7 +107,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"
@@ -104,13 +116,96 @@ 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."
keyword_highlight: "Terminal Keyword Highlighting"
copy_paste_hint: "Selected text can also be copied by left-clicking."
settings_copy: "Copy Selection"
settings_paste: "Paste Clipboard"
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_open_search: "Search"
search: "Search"
no_results: "No results"
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'
title_bar_style: "Title Bar Style"
title_bar_native: "Native"
title_bar_integrated: "Integrated"
restart_hint: " (Restart required)"
settings_sync: "Configuration Sync"
sync_endpoint: "WebDAV Endpoint"
sync_username: "WebDAV Username"
sync_webdav_password: "WebDAV Password"
sync_encryption_password: "Encryption Password (8+ characters)"
sync_security_hint: "Only connection parameters are stored locally. WebDAV passwords, S3 secrets, and encryption passwords remain in memory for this process. Uploaded data is end-to-end encrypted. Downloading replaces locally saved SSH sessions."
sync_s3_endpoint: "S3 Endpoint (optional for AWS S3)"
sync_s3_region: "S3 Region"
sync_s3_bucket: "S3 Bucket"
sync_s3_object_key: "Object Key"
sync_s3_access_key: "Access Key ID"
sync_s3_secret_key: "Secret Access Key"
sync_s3_session_token: "Session Token (optional)"
sync_download: "Download Remote"
sync_upload: "Upload Remote"
sync_not_run: "Not synchronized yet"
sync_uploading: "Encrypting and uploading configuration..."
sync_downloading: "Downloading and decrypting configuration..."
sync_upload_complete: "Configuration uploaded"
sync_download_complete: "Configuration downloaded"
sync_failed: "Synchronization failed"
settings_proxy: "Proxy"
enable_proxy: "Enable Proxy"
read_env_proxy: "Read environment variables on startup"
read_env_proxy_desc: "If enabled, the global proxy settings below will be overridden by environment variables on startup."
global_proxy_settings: "Global Proxy Configuration"
global_proxy_type: "Proxy Type"
global_proxy_host: "Proxy Host"
global_proxy_port: "Proxy Port"
global_proxy_user: "Proxy Username"
global_proxy_password: "Proxy Password"
save_proxy: "Save Proxy"
+99 -4
View File
@@ -23,7 +23,6 @@ system_path_warning: "警告:您即将删除系统关键路径!这可能会
delete_success: "成功删除 %{count} 个项"
delete_failed: "删除失败: %{err}"
deleting_paths: "正在删除 %{count} 个项..."
cancel: "取消"
confirm: "确认"
local_terminal: "本地终端"
open_local_shell_tab: "打开一个新的本地终端页面"
@@ -64,12 +63,22 @@ add_ssh: "+ SSH"
open_local_or_ssh: "打开本地终端或选择一个 SSH 会话。"
open_session: "打开会话"
settings: "设置"
proxy: "代理"
proxy_none: "跟随全局"
proxy_host: "代理服务器地址"
proxy_port: "端口"
proxy_user: "用户名 (可选)"
proxy_password: "密码 (可选)"
ui_font_size: "界面字体大小"
terminal_font_size: "终端字体大小"
system_default: "系统默认"
ui_font_family: "界面字体"
terminal_font_family: "终端字体"
theme_management_hint: "深色/浅色模式以及主题配色由顶部栏的样式按钮统一管理。"
cursor_style: "光标样式"
cursor_style_default: "默认"
cursor_style_blink: "闪烁"
cursor_style_beam: "细线"
cursor_style_beam_blink: "细线闪烁"
monitoring_position: "系统监控显示位置"
position_bottom: "底部"
position_sidebar: "侧边栏"
@@ -79,6 +88,8 @@ local_terminal_opened: "已打开本地终端"
connecting: "正在连接"
starting_connection: "开始连接..."
connection_failed: "连接失败"
session_disconnected: "会话已断开: %{reason}"
press_enter_to_reconnect: "按 Enter 重新连接"
preview_failed: "预览失败: %{err}"
downloading_file: "正在下载 %{base}..."
@@ -97,7 +108,7 @@ n_folders: "%{folders} 个文件夹"
n_files_and_folders: "%{files} 个文件及 %{folders} 个文件夹"
reset: "重置"
reset_layout: "恢复默认布局"
reset_layout_success: "布局占比已恢复默认。窗口大小将在下次启动时生效。"
reset_layout_hint: "重置面板布局占比,窗口大小将在下次启动时生效"
transfers: "传输记录"
no_transfers_yet: "暂无传输记录"
@@ -106,13 +117,97 @@ paused: "已暂停"
completed: "已完成"
failed: "失败"
cancelled: "已取消"
interrupted: "已中断"
session: "会话"
zombie: "僵尸任务"
zombie_reason: "此传输任务因程序退出而中断,传输进程已不存在。"
software_builtin: "软件内置"
retry: "重试"
right_click_copy_paste: "右键复制/粘贴"
copy_paste_hint: "提示:不管是否开启此开关,都可以使用 %{key} + C/V 快捷键进行复制和粘贴。"
keyword_highlight: "终端关键词高亮"
copy_paste_hint: "选中后左键点击也可复制。"
settings_copy: "复制选区"
settings_paste: "粘贴剪贴板"
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_open_search: "搜索"
search: "搜索"
no_results: "无结果"
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}」使用,请选择其他快捷键'
title_bar_style: "标题栏样式"
title_bar_native: "原生"
title_bar_integrated: "融合"
restart_hint: " (修改后重启生效)"
settings_sync: "配置同步"
sync_endpoint: "WebDAV 地址"
sync_username: "WebDAV 用户名"
sync_webdav_password: "WebDAV 密码"
sync_encryption_password: "配置加密口令(至少 8 位)"
sync_security_hint: "仅连接参数会保存到本机。WebDAV 密码、S3 密钥和加密口令只保留在当前进程内;上传内容使用端到端加密。下载会替换本机保存的 SSH 会话。"
sync_s3_endpoint: "S3 Endpoint(AWS S3 可留空)"
sync_s3_region: "S3 Region"
sync_s3_bucket: "S3 Bucket"
sync_s3_object_key: "对象路径"
sync_s3_access_key: "Access Key ID"
sync_s3_secret_key: "Secret Access Key"
sync_s3_session_token: "Session Token(可选)"
sync_download: "从远程下载"
sync_upload: "上传到远程"
sync_not_run: "尚未同步"
sync_uploading: "正在加密并上传配置..."
sync_downloading: "正在下载并解密配置..."
sync_upload_complete: "配置上传完成"
sync_download_complete: "配置下载完成"
sync_failed: "同步失败"
settings_proxy: "代理设置"
enable_proxy: "启用代理功能"
read_env_proxy: "启动时读取环境变量"
read_env_proxy_desc: "若开启此项,启动时将读取环境变量,下方配置的全局代理设置将失效。"
global_proxy_settings: "全局代理配置"
global_proxy_type: "代理类型"
global_proxy_host: "代理地址"
global_proxy_port: "代理端口"
global_proxy_user: "代理用户名"
global_proxy_password: "代理密码"
save_proxy: "保存代理设置"
+129
View File
@@ -0,0 +1,129 @@
use gpui::{Context, Entity, SharedString};
use gpui_component::input::InputState;
use rust_i18n::t;
use crate::{
Ashell,
sync::{self, SyncBackendCredentials, SyncCredentials, SyncPayload, SyncResult},
terminal::BackendEvent,
};
impl Ashell {
fn sync_input_value(input: &Entity<InputState>, cx: &Context<Self>) -> String {
input.read(cx).value().trim().to_string()
}
fn sync_credentials(&self, cx: &Context<Self>) -> SyncCredentials {
let backend = if self.config.sync_backend() == "s3" {
SyncBackendCredentials::S3 {
endpoint: Self::sync_input_value(&self.sync_s3_endpoint_input, cx),
region: Self::sync_input_value(&self.sync_s3_region_input, cx),
bucket: Self::sync_input_value(&self.sync_s3_bucket_input, cx),
object_key: Self::sync_input_value(&self.sync_s3_object_key_input, cx),
access_key: Self::sync_input_value(&self.sync_s3_access_key_input, cx),
secret_key: self.sync_s3_secret_key_input.read(cx).value().to_string(),
session_token: self
.sync_s3_session_token_input
.read(cx)
.value()
.to_string(),
}
} else {
SyncBackendCredentials::WebDav {
endpoint: Self::sync_input_value(&self.sync_endpoint_input, cx),
username: Self::sync_input_value(&self.sync_username_input, cx),
password: self.sync_webdav_password_input.read(cx).value().to_string(),
}
};
SyncCredentials {
backend,
encryption_password: self
.sync_encryption_password_input
.read(cx)
.value()
.to_string(),
}
}
fn begin_sync(
&mut self,
status: SharedString,
cx: &mut Context<Self>,
) -> Option<SyncCredentials> {
if self.sync_in_progress {
return None;
}
let credentials = self.sync_credentials(cx);
match &credentials.backend {
SyncBackendCredentials::WebDav {
endpoint, username, ..
} => {
self.config
.set_sync_connection(endpoint.clone(), username.clone());
}
SyncBackendCredentials::S3 {
endpoint,
region,
bucket,
object_key,
..
} => {
self.config.set_sync_s3_connection(
endpoint.clone(),
region.clone(),
bucket.clone(),
object_key.clone(),
);
}
}
if let Err(err) = self.config.save() {
self.sync_status = format!("{}: {err:#}", t!("sync_failed")).into();
cx.notify();
return None;
}
self.sync_in_progress = true;
self.sync_status = status;
cx.notify();
Some(credentials)
}
pub(crate) fn set_sync_backend(&mut self, backend: &str, cx: &mut Context<Self>) {
self.config.set_sync_backend(backend);
let _ = self.config.save();
self.sync_status = t!("sync_not_run").into();
cx.notify();
}
pub(crate) fn upload_sync_config(&mut self, cx: &mut Context<Self>) {
let Some(credentials) = self.begin_sync(t!("sync_uploading").into(), cx) else {
return;
};
let payload = SyncPayload::new(
self.config.sync_device_id().to_string(),
self.config.sessions().to_vec(),
);
let expected_etag = self.config.sync_etag().map(str::to_string);
let events = self.events_tx.clone();
self.runtime.spawn(async move {
let result = match sync::upload(credentials, payload, expected_etag).await {
Ok(etag) => SyncResult::Uploaded { etag },
Err(err) => SyncResult::Failed(format!("{err:#}")),
};
let _ = events.send(BackendEvent::SyncFinished(result));
});
}
pub(crate) fn download_sync_config(&mut self, cx: &mut Context<Self>) {
let Some(credentials) = self.begin_sync(t!("sync_downloading").into(), cx) else {
return;
};
let events = self.events_tx.clone();
self.runtime.spawn(async move {
let result = match sync::download(credentials).await {
Ok((payload, etag)) => SyncResult::Downloaded { payload, etag },
Err(err) => SyncResult::Failed(format!("{err:#}")),
};
let _ = events.send(BackendEvent::SyncFinished(result));
});
}
}
+6
View File
@@ -1,7 +1,13 @@
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;
#[allow(dead_code)]
pub(crate) const TAB_BAR_HEIGHT: f32 = 52.0;
#[allow(dead_code)]
pub(crate) const TERMINAL_PADDING_X: f32 = 32.0;
#[allow(dead_code)]
pub(crate) const TERMINAL_PADDING_Y: f32 = 32.0;
pub(crate) const TERMINAL_KEY_CONTEXT: &str = "AshellTerminal";
+1397 -670
View File
File diff suppressed because it is too large Load Diff
+415
View File
@@ -0,0 +1,415 @@
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,
OpenSearch,
ToggleSidebar,
ToggleSftpZoom,
FocusPaneLeft,
FocusPaneRight,
FocusPaneUp,
FocusPaneDown,
SplitPaneLeft,
SplitPaneRight,
SplitPaneUp,
SplitPaneDown,
ClosePane,
Copy,
Paste
]
);
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: "OpenSearch",
label_key: "settings_open_search",
default_suffix: "f",
},
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",
},
WorkspaceAction {
id: "Copy",
label_key: "settings_copy",
default_suffix: if cfg!(target_os = "macos") { "c" } else { "shift-c" },
},
WorkspaceAction {
id: "Paste",
label_key: "settings_paste",
default_suffix: if cfg!(target_os = "macos") { "v" } else { "shift-v" },
},
];
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!("OpenSearch", crate::OpenSearch);
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);
unbind_action!("Copy", crate::Copy);
unbind_action!("Paste", crate::Paste);
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!("OpenSearch", crate::OpenSearch);
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);
bind_action!("Copy", crate::Copy);
bind_action!("Paste", crate::Paste);
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", "OpenSearch", "Copy", "Paste"],
),
(
"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
}
}
+556 -101
View File
@@ -1,5 +1,8 @@
pub mod config_sync;
pub mod constants;
pub mod dialogs;
pub mod keybinding_recorder;
pub mod search;
pub mod startup;
pub mod theme;
pub mod ui;
@@ -14,9 +17,8 @@ use std::{
};
use gpui::{
AppContext as _, Bounds, Context, Entity, FocusHandle, Pixels, Point,
SharedString, Size, UniformListScrollHandle, Window, point,
px, size,
AppContext as _, Bounds, Context, Entity, FocusHandle, Pixels, Point, SharedString, Size,
UniformListScrollHandle, Window, point, px, size,
};
use gpui_component::{
Theme, ThemeMode, ThemeRegistry,
@@ -31,7 +33,6 @@ use crate::{
session::config::{AuthMethod, ConfigStore},
system::{SystemSampler, SystemSnapshot},
terminal::{self, BackendEvent, TabKind, TerminalTab},
backend::ssh,
};
#[derive(Clone, Debug)]
@@ -82,7 +83,10 @@ impl PaneLayout {
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 @ ..]) => {
(
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _),
[first, rest @ ..],
) => {
if let Some(child) = children.get_mut(*first) {
child.replace_at(rest, replacement);
}
@@ -187,7 +191,13 @@ impl ScrollbarHandle for TerminalScrollbarHandle {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DialogKind {
Settings,
SessionSelector,
Transfers,
NewSsh,
}
pub(crate) struct Ashell {
pub(crate) focus_handle: FocusHandle,
@@ -199,6 +209,30 @@ pub(crate) struct Ashell {
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) ssh_proxy_type: String,
pub(crate) proxy_host_input: Entity<InputState>,
pub(crate) proxy_port_input: Entity<InputState>,
pub(crate) proxy_user_input: Entity<InputState>,
pub(crate) proxy_password_input: Entity<InputState>,
pub(crate) global_proxy_type: String,
pub(crate) global_proxy_host_input: Entity<InputState>,
pub(crate) global_proxy_port_input: Entity<InputState>,
pub(crate) global_proxy_user_input: Entity<InputState>,
pub(crate) global_proxy_password_input: Entity<InputState>,
pub(crate) sync_endpoint_input: Entity<InputState>,
pub(crate) sync_username_input: Entity<InputState>,
pub(crate) sync_webdav_password_input: Entity<InputState>,
pub(crate) sync_s3_endpoint_input: Entity<InputState>,
pub(crate) sync_s3_region_input: Entity<InputState>,
pub(crate) sync_s3_bucket_input: Entity<InputState>,
pub(crate) sync_s3_object_key_input: Entity<InputState>,
pub(crate) sync_s3_access_key_input: Entity<InputState>,
pub(crate) sync_s3_secret_key_input: Entity<InputState>,
pub(crate) sync_s3_session_token_input: Entity<InputState>,
pub(crate) sync_encryption_password_input: Entity<InputState>,
pub(crate) sync_in_progress: bool,
pub(crate) sync_status: SharedString,
pub(crate) sftp_path_input: Entity<InputState>,
pub(crate) ssh_auth_method: AuthMethod,
pub(crate) editing_session_id: Option<String>,
@@ -208,6 +242,7 @@ pub(crate) struct Ashell {
pub(crate) dark_theme_name: SharedString,
pub(crate) ui_font_size: f32,
pub(crate) terminal_font_size: f32,
pub(crate) terminal_zoom_accumulator: f32,
pub(crate) ui_font_family: SharedString,
pub(crate) terminal_font_family: SharedString,
pub(crate) tabs: Vec<TerminalTab>,
@@ -217,6 +252,7 @@ pub(crate) struct Ashell {
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,
@@ -236,16 +272,27 @@ pub(crate) struct Ashell {
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) active_title_bar_style: crate::session::config::TitleBarStyle,
pub(crate) cursor_style: crate::session::config::CursorStyle,
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>,
@@ -253,13 +300,23 @@ pub(crate) struct Ashell {
pub(crate) last_system_sample: Instant,
pub(crate) last_theme_sync: Instant,
pub(crate) search_input: Entity<InputState>,
pub(crate) search_active: bool,
pub(crate) search_query: String,
pub(crate) search_matches: Vec<(i32, i32)>,
pub(crate) search_current: usize,
pub(crate) search_target_tab: Option<String>,
pub(crate) search_bar_bounds: Option<Bounds<Pixels>>,
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) last_window_size: Option<gpui::Size<Pixels>>,
pub(crate) last_sidebar_width: Option<Pixels>,
pub(crate) _subscriptions: Vec<gpui::Subscription>,
}
@@ -286,6 +343,26 @@ pub(crate) struct SftpContextMenuState {
}
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 =
@@ -305,8 +382,99 @@ impl Ashell {
.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 proxy_host_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_host").to_string()));
let proxy_port_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_port").to_string()));
let proxy_user_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_user").to_string()));
let proxy_password_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_password").to_string()).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 sftp_new_folder_input =
cx.new(|cx| InputState::new(window, cx).placeholder(t!("new_folder").to_string()));
let search_input = cx.new(|cx| {
InputState::new(window, cx).placeholder(t!("search").to_string())
});
let config = ConfigStore::load().unwrap_or_else(|err| {
tracing::warn!("failed to load config: {err:#}");
ConfigStore::in_memory()
});
let global_proxy_host_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("proxy_host").to_string())
.default_value(config.global_proxy_host())
});
let global_proxy_port_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("proxy_port").to_string())
.default_value(config.global_proxy_port().map(|p| p.to_string()).unwrap_or_default())
});
let global_proxy_user_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("proxy_user").to_string())
.default_value(config.global_proxy_user())
});
let global_proxy_password_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("proxy_password").to_string())
.masked(true)
.default_value(config.global_proxy_password())
});
let sync_endpoint_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("https://dav.example.com/ashell/")
.default_value(config.sync_endpoint())
});
let sync_username_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("sync_username").to_string())
.default_value(config.sync_username())
});
let sync_webdav_password_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("sync_webdav_password").to_string())
.masked(true)
});
let sync_s3_endpoint_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("https://s3.example.com")
.default_value(config.sync_s3_endpoint())
});
let sync_s3_region_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("us-east-1")
.default_value(config.sync_s3_region())
});
let sync_s3_bucket_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("sync_s3_bucket").to_string())
.default_value(config.sync_s3_bucket())
});
let sync_s3_object_key_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("ashell-sync.json")
.default_value(config.sync_s3_object_key())
});
let sync_s3_access_key_input = cx.new(|cx| {
InputState::new(window, cx).placeholder(t!("sync_s3_access_key").to_string())
});
let sync_s3_secret_key_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("sync_s3_secret_key").to_string())
.masked(true)
});
let sync_s3_session_token_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("sync_s3_session_token").to_string())
.masked(true)
});
let sync_encryption_password_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("sync_encryption_password").to_string())
.masked(true)
});
let _subscriptions = vec![
cx.subscribe_in(&host_input, window, Self::on_input_event),
@@ -316,8 +484,29 @@ impl Ashell {
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(&proxy_host_input, window, Self::on_input_event),
cx.subscribe_in(&proxy_port_input, window, Self::on_input_event),
cx.subscribe_in(&proxy_user_input, window, Self::on_input_event),
cx.subscribe_in(&proxy_password_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),
cx.subscribe_in(&search_input, window, Self::on_input_event),
cx.subscribe_in(&sync_endpoint_input, window, Self::on_input_event),
cx.subscribe_in(&sync_username_input, window, Self::on_input_event),
cx.subscribe_in(&sync_webdav_password_input, window, Self::on_input_event),
cx.subscribe_in(&sync_s3_endpoint_input, window, Self::on_input_event),
cx.subscribe_in(&sync_s3_region_input, window, Self::on_input_event),
cx.subscribe_in(&sync_s3_bucket_input, window, Self::on_input_event),
cx.subscribe_in(&sync_s3_object_key_input, window, Self::on_input_event),
cx.subscribe_in(&sync_s3_access_key_input, window, Self::on_input_event),
cx.subscribe_in(&sync_s3_secret_key_input, window, Self::on_input_event),
cx.subscribe_in(&sync_s3_session_token_input, window, Self::on_input_event),
cx.subscribe_in(
&sync_encryption_password_input,
window,
Self::on_input_event,
),
];
let (events_tx, events_rx) = mpsc::channel();
@@ -327,16 +516,7 @@ impl Ashell {
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 =
if config.light_theme_name().is_empty() && config.dark_theme_name().is_empty() {
true
} else {
config.follow_system_theme()
};
let follow_system_theme = config.follow_system_theme();
let theme_mode = match config.theme_mode() {
"light" => ThemeMode::Light,
@@ -368,6 +548,10 @@ impl Ashell {
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 last_sidebar_width = Some(px(config
.workspace_panels()
.and_then(|s| s.first().copied())
.unwrap_or(constants::SIDEBAR_WIDTH)));
let mut this = Self {
focus_handle: cx.focus_handle(),
selector_focus_handle: cx.focus_handle(),
@@ -378,6 +562,30 @@ impl Ashell {
password_input,
key_path_input,
key_inline_input,
passphrase_input,
ssh_proxy_type: "none".to_string(),
proxy_host_input,
proxy_port_input,
proxy_user_input,
proxy_password_input,
global_proxy_type: config.global_proxy_type().to_string(),
global_proxy_host_input,
global_proxy_port_input,
global_proxy_user_input,
global_proxy_password_input,
sync_endpoint_input,
sync_username_input,
sync_webdav_password_input,
sync_s3_endpoint_input,
sync_s3_region_input,
sync_s3_bucket_input,
sync_s3_object_key_input,
sync_s3_access_key_input,
sync_s3_secret_key_input,
sync_s3_session_token_input,
sync_encryption_password_input,
sync_in_progress: false,
sync_status: t!("sync_not_run").into(),
sftp_path_input,
ssh_auth_method: AuthMethod::Password,
editing_session_id: None,
@@ -387,6 +595,8 @@ impl Ashell {
dark_theme_name,
ui_font_size: config.ui_font_size(),
terminal_font_size: config.terminal_font_size(),
terminal_zoom_accumulator: 0.0,
cursor_style: config.cursor_style(),
ui_font_family,
terminal_font_family,
tabs: Vec::new(),
@@ -395,9 +605,11 @@ impl Ashell {
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(),
@@ -412,7 +624,20 @@ impl Ashell {
sftp_new_folder_input,
sftp_delete_scroll_handle: gpui::ScrollHandle::new(),
show_hidden_files: config.show_hidden_files(),
transfers: config.transfers(),
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(),
@@ -420,11 +645,18 @@ impl Ashell {
terminal_marked_text: None,
dragging_splitter: None,
drag_split_origin: None,
sftp_panel_minimized: false,
sftp_panel_minimized: config.sftp_panel_minimized(),
sidebar_collapsed: config.sidebar_collapsed(),
collapsed_saved_scroll_handle: gpui::ScrollHandle::new(),
prev_monitoring_size: None,
status: "ready".into(),
active_title_bar_style: config.title_bar_style(),
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),
@@ -432,13 +664,23 @@ impl Ashell {
last_system_sample: Instant::now(),
last_theme_sync: Instant::now(),
search_input,
search_active: false,
search_query: String::new(),
search_matches: Vec::new(),
search_current: 0,
search_target_tab: None,
search_bar_bounds: None,
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,
last_window_size: None,
last_sidebar_width,
_subscriptions,
};
@@ -476,7 +718,9 @@ impl Ashell {
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));
let _ = handle
.commands
.send(crate::sftp::SftpCommand::CreateDir(path));
}
}
self.sftp_creating_folder = false;
@@ -488,6 +732,18 @@ impl Ashell {
}
_ => {}
}
} else if input == &self.search_input {
if let InputEvent::PressEnter { .. } = event {
if self.search_query.is_empty()
|| self.search_input.read(cx).text().to_string() != self.search_query
{
self.perform_search(window, cx);
} else {
self.search_goto_next(cx);
}
window.prevent_default();
cx.stop_propagation();
}
}
cx.notify();
}
@@ -495,6 +751,7 @@ impl Ashell {
pub(crate) fn start_event_pump(&self, cx: &mut Context<Self>) {
cx.spawn(async move |this, cx| {
let mut idle_frames = 0u32;
let mut last_blink_time = std::time::Instant::now();
loop {
cx.background_executor()
.timer(Duration::from_millis(16))
@@ -504,9 +761,19 @@ impl Ashell {
let changed = this.drain_backend_events();
let system_sampled = this.sample_system_if_due();
this.sync_theme_if_due(cx);
if changed || system_sampled {
let is_blinking = matches!(
this.cursor_style,
crate::session::config::CursorStyle::Blink
| crate::session::config::CursorStyle::BeamBlink
);
let now = std::time::Instant::now();
let blink_due = is_blinking && now.duration_since(last_blink_time) >= std::time::Duration::from_millis(600);
if changed || system_sampled || blink_due {
cx.notify();
idle_frames = 0;
if blink_due {
last_blink_time = now;
}
} else {
idle_frames += 1;
if idle_frames >= 60 {
@@ -532,25 +799,30 @@ impl Ashell {
match event {
BackendEvent::Output { tab_id, bytes } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.backend_initialized = true;
tab.feed(&bytes);
}
}
BackendEvent::Status { tab_id, text } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.backend_initialized = true;
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.connection_scroll_handle
.set_offset(gpui::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.backend_initialized = true;
tab.connected = true;
tab.disconnected_reason = None;
}
self.sync_system_tab_to_active_group();
self.request_active_system_snapshot();
@@ -621,64 +893,45 @@ impl Ashell {
}
BackendEvent::Closed { tab_id, reason } => {
self.remote_sample_in_flight = false;
let mut tab_title = None;
let mut session_label = None;
let is_stale = self
.tabs
.iter()
.find(|t| t.id == tab_id)
.is_some_and(|tab| {
// After retry_disconnected_tab, the old backend's threads
// may still send Closed events. Skip those — they arrive
// before the new backend sends its first Output/Connected.
// Once backend_initialized is set, any Closed is from the
// current backend and should be processed.
tab.backend_generation > 0 && !tab.backend_initialized
});
if is_stale {
continue;
}
let is_graceful_exit =
reason == "local shell closed" || reason == "ssh session closed";
if is_graceful_exit {
self.handle_tab_close(tab_id.clone());
self.status = reason.into();
continue;
}
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)
});
tab.disconnected_reason = Some(reason.clone());
}
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;
self.connection_scroll_handle
.set_offset(gpui::point(px(0.), px(-99999.0)));
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();
}
@@ -699,12 +952,7 @@ impl Ashell {
}
}
BackendEvent::TransferStarted { tab_id, info } => {
let tab_title = self
.tabs
.iter()
.find(|t| t.id == tab_id)
.map(|t| t.title.clone())
.unwrap_or_else(|| "Unknown".to_string());
let tab_title = self.transfer_source_title(&tab_id);
self.transfers.insert(
0,
crate::terminal::Transfer {
@@ -716,6 +964,9 @@ impl Ashell {
state: crate::terminal::TransferState::Running,
},
);
if self.transfers.len() > 100 {
self.transfers.truncate(100);
}
transfers_changed = true;
}
BackendEvent::SftpHome { tab_id, home } => {
@@ -730,6 +981,32 @@ impl Ashell {
tab.title = title.clone();
}
}
BackendEvent::SyncFinished(result) => {
self.sync_in_progress = false;
match result {
crate::sync::SyncResult::Uploaded { etag } => {
if etag.is_some() {
self.config.set_sync_etag(etag);
}
self.sync_status = t!("sync_upload_complete").into();
let _ = self.config.save();
}
crate::sync::SyncResult::Downloaded { payload, etag } => {
self.config.replace_sessions(payload.sessions);
self.config.set_sync_etag(etag);
match self.config.save() {
Ok(()) => self.sync_status = t!("sync_download_complete").into(),
Err(err) => {
self.sync_status =
format!("{}: {err:#}", t!("sync_failed")).into()
}
}
}
crate::sync::SyncResult::Failed(error) => {
self.sync_status = format!("{}: {error}", t!("sync_failed")).into();
}
}
}
}
}
if transfers_changed {
@@ -743,7 +1020,12 @@ impl Ashell {
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() {
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;
}
@@ -777,31 +1059,25 @@ impl Ashell {
}
pub(crate) fn request_active_system_snapshot(&mut self) {
let Some(ref tab_id) = self.system_tab_id.clone() else { return };
let Some(session) = (|| {
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; }
tab.session.clone()
})() else { return };
if !tab.connected {
return None;
}
Some(tab.backend.clone())
})() else {
return;
};
if self.remote_sample_in_flight {
return;
}
self.remote_sample_in_flight = true;
let events = self.events_tx.clone();
let tab_id = tab_id.clone();
self.runtime.spawn(async move {
match ssh::sample_remote_system(session).await {
Ok(snapshot) => {
let _ = events.send(BackendEvent::RemoteSystem { tab_id, snapshot });
}
Err(err) => {
let _ = events.send(BackendEvent::RemoteSystemUnavailable {
tab_id,
reason: format!("remote metrics unavailable: {err:#}"),
});
}
}
});
if let Ok(backend) = backend.lock() {
backend.send(crate::terminal::BackendCommand::SampleMetrics);
}
}
pub(crate) fn terminal_ime_bounds_for_range(
@@ -816,14 +1092,193 @@ impl Ashell {
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;
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),
),
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();
}
pub(crate) fn retry_connection_progress(&mut self, cx: &mut Context<Self>) {
let Some(progress) = self.connection_progress.clone() else {
return;
};
self.connection_progress = None;
let mut retry_tabs = Vec::new();
for (ix, tab) in self.tabs.iter().enumerate() {
if !tab.connected && tab.session.is_some() && tab.id == progress.tab_id {
retry_tabs.push((ix, tab.id.clone(), tab.session.clone().unwrap()));
}
}
if retry_tabs.is_empty() {
cx.notify();
return;
}
for (ix, tab_id, session) in retry_tabs {
// Close old backend
self.tabs[ix].send_backend(crate::terminal::BackendCommand::Close);
// Spawn new backend
let backend = crate::backend::ssh::spawn_ssh_terminal(
self.runtime.handle(),
tab_id.clone(),
session.clone(),
self.tabs[ix].cols,
self.tabs[ix].rows,
self.events_tx.clone(),
);
// Replace tab state
self.tabs[ix].set_backend(backend);
self.tabs[ix].connected = false;
self.tabs[ix].status = "connecting".into();
self.tabs[ix].disconnected_reason = None;
self.tabs[ix].backend_initialized = false;
// Restart SFTP for the group containing this tab
if let Some(group) = self
.tab_groups
.iter()
.find(|g| g.pane_root.contains(&tab_id))
{
let group_id = group.id.clone();
let group_session = self
.tabs
.iter()
.find(|t| group.pane_root.contains(&t.id) && t.session.is_some())
.and_then(|t| t.session.clone());
if let Some(session) = group_session {
if let Some(old_handle) = self.sftp_handles.remove(&group_id) {
old_handle.close();
}
let sftp_handle = crate::sftp::spawn_sftp(
self.runtime.handle(),
group_id.clone(),
session,
self.events_tx.clone(),
);
self.sftp_handles.insert(group_id.clone(), sftp_handle);
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) {
if let Some(sftp) = group.sftp.as_mut() {
sftp.status = rust_i18n::t!("sftp_connecting").to_string();
}
}
}
}
}
self.connection_progress = Some(ConnectionProgress {
tab_id: progress.tab_id.clone(),
title: t!("connecting").into(),
lines: vec![t!("starting_connection").into()],
failed: false,
});
self.status = "ssh tabs retrying".into();
cx.notify();
}
pub(crate) fn cancel_connection_progress(&mut self, cx: &mut Context<Self>) {
if let Some(progress) = &self.connection_progress {
let tab_id = progress.tab_id.clone();
self.connection_progress = None;
self.handle_tab_close(tab_id);
}
cx.notify();
}
pub(crate) fn save_layout_state(&self, window: &mut gpui::Window, cx: &gpui::App) {
if self.is_layout_reset {
tracing::info!("[ui] layout was reset, skipping save layout state.");
return;
}
let current_bounds = window.window_bounds();
let bounds = match current_bounds {
gpui::WindowBounds::Fullscreen(b) => b,
gpui::WindowBounds::Maximized(b) => b,
gpui::WindowBounds::Windowed(b) => b,
};
let size = bounds.size;
if size.width.as_f32() > 400.0 && size.height.as_f32() > 300.0 {
tracing::info!("[ui] saving layout state...");
let mut config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
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) => {
let mut restore_bounds = (b.origin.x.into(), b.origin.y.into(), b.size.width.into(), b.size.height.into());
if let Some(existing_bounds) = config.window_bounds() {
match existing_bounds {
crate::session::config::SavedWindowBounds::Windowed { x, y, width, height } => {
restore_bounds = (*x, *y, *width, *height);
}
crate::session::config::SavedWindowBounds::Maximized { x, y, width, height } => {
restore_bounds = (*x, *y, *width, *height);
}
_ => {}
}
}
crate::session::config::SavedWindowBounds::Maximized {
x: restore_bounds.0,
y: restore_bounds.1,
width: restore_bounds.2,
height: restore_bounds.3,
}
}
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> = self.workspace_panels
.read(cx)
.sizes()
.iter()
.map(|s| s.into())
.collect();
let mut body_sizes: Vec<f32> = self.body_panels
.read(cx)
.sizes()
.iter()
.map(|s| s.into())
.collect();
if self.sftp_panel_minimized {
if let Some(prev) = self.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(self.sidebar_collapsed);
config.set_sftp_panel_minimized(self.sftp_panel_minimized);
let _ = config.save();
} else {
tracing::warn!(
"[ui] window size is too small ({:?}), skipping save layout state to prevent corrupting saved bounds.",
size
);
}
}
}
+486
View File
@@ -0,0 +1,486 @@
use std::collections::HashMap;
use gpui::{
Context, Focusable as _, Hsla, IntoElement, InteractiveElement as _, MouseButton,
ParentElement as _, Styled as _, Window, div, prelude::FluentBuilder as _, px, rems,
};
use gpui_component::{
ActiveTheme as _, Disableable as _, ElementExt as _, IconName, Sizable as _,
button::{Button, ButtonVariants as _},
h_flex, input::Input,
};
use rust_i18n::t;
use crate::Ashell;
impl Ashell {
pub(crate) fn toggle_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.search_active {
self.close_search(window, cx);
} else {
self.open_search(window, cx);
}
}
pub(crate) fn open_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.search_active = true;
// Focus the search input on the next frame so it happens after the
// current render cycle completes, avoiding focus being stolen back
// by the terminal panel's track_focus.
let search_input = self.search_input.clone();
cx.on_next_frame(window, move |_this, window, cx| {
search_input.update(cx, |state, cx| {
state.focus_handle(cx).focus(window, cx);
});
});
cx.notify();
}
pub(crate) fn close_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.search_active = false;
self.search_query.clear();
self.search_matches.clear();
self.search_current = 0;
self.search_target_tab = None;
self.search_bar_bounds = None;
self.focus_handle.focus(window, cx);
cx.notify();
}
/// Move keyboard focus back to the search input so the user can keep typing.
/// Deferred to the next frame so it happens after the current render cycle,
/// preventing the terminal panel's track_focus from stealing focus back.
fn refocus_search_input(&self, window: &mut Window, cx: &mut Context<Self>) {
let search_input = self.search_input.clone();
cx.on_next_frame(window, move |_this, window, cx| {
search_input.update(cx, |state, cx| {
state.focus_handle(cx).focus(window, cx);
});
});
}
pub(crate) fn perform_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let query = self.search_input.read(cx).text().to_string();
if query.is_empty() {
self.search_query.clear();
self.search_matches.clear();
self.search_current = 0;
self.refocus_search_input(window, cx);
cx.notify();
return;
}
// Find the active tab — try active_tab first, then fall back to the
// first tab in the active group, then any tab.
let tab = self
.active_tab
.as_ref()
.and_then(|id| self.tabs.iter().find(|t| &t.id == id));
let tab = tab.or_else(|| {
let first_id = self
.active_group
.as_ref()
.and_then(|gid| self.tab_groups.iter().find(|g| &g.id == gid))
.and_then(|g| g.pane_root.tab_ids().into_iter().next())
.map(|s| s.to_string());
first_id
.as_deref()
.and_then(|id| self.tabs.iter().find(|t| t.id == id))
});
let tab = tab.or_else(|| self.tabs.first());
let Some(tab) = tab else {
self.status = t!("no_results").into();
self.refocus_search_input(window, cx);
cx.notify();
return;
};
// Remember which tab was searched so highlights only appear in that pane.
self.search_target_tab = Some(tab.id.clone());
let query_lower = query.to_lowercase();
let query_byte_len = query.len();
// Search the ENTIRE terminal buffer (scrollback + visible screen).
// full_grid_rows returns grid line indices; the first row is at
// `grid_start` (typically -history_size).
let (grid_start, all_rows) = tab.full_grid_rows();
let mut matches: Vec<(i32, i32)> = Vec::new();
for (row_idx, row) in all_rows.iter().enumerate() {
if row.is_empty() {
continue;
}
// Build text string and byte→column index mapping.
let mut text = String::with_capacity(row.len());
let mut byte_to_col: Vec<i32> = Vec::new();
for &(col, c) in row {
text.push(c);
while byte_to_col.len() < text.len() {
byte_to_col.push(col);
}
}
let text_lower = text.to_lowercase();
// Grid line index for this row.
let abs_row = grid_start + row_idx as i32;
let mut search_start = 0;
while let Some(pos) = text_lower[search_start..].find(&query_lower) {
let abs = search_start + pos;
let start_col = byte_to_col[abs];
let end_byte = (abs + query_byte_len).min(byte_to_col.len());
let end_col = byte_to_col[end_byte - 1];
for c in start_col..=end_col {
matches.push((abs_row, c));
}
search_start = abs + query_byte_len;
}
}
let match_count = count_match_groups(&matches);
self.search_query = query;
self.search_matches = matches;
if match_count > 0 {
self.search_current = 0;
self.jump_to_current_match(cx);
}
self.status = format!(
"{}: {} ({})",
t!("search"),
self.search_query,
if match_count == 0 {
t!("no_results").to_string()
} else {
format!("{}/{}", self.search_current + 1, match_count)
}
)
.into();
// Keep focus on the search input so the user can continue typing.
self.refocus_search_input(window, cx);
cx.notify();
}
pub(crate) fn search_goto_next(&mut self, cx: &mut Context<Self>) {
let match_count = count_match_groups(&self.search_matches);
if match_count == 0 {
return;
}
self.search_current = (self.search_current + 1) % match_count;
self.jump_to_current_match(cx);
cx.notify();
}
pub(crate) fn search_goto_prev(&mut self, cx: &mut Context<Self>) {
let match_count = count_match_groups(&self.search_matches);
if match_count == 0 {
return;
}
self.search_current = (self.search_current + match_count - 1) % match_count;
self.jump_to_current_match(cx);
cx.notify();
}
fn jump_to_current_match(&mut self, _cx: &mut Context<Self>) {
// target_grid_line is the grid line index (negative = history).
let Some((target_grid_line, _)) =
find_nth_match_start(&self.search_matches, self.search_current)
else {
return;
};
// Find the tab ID first (immutable borrow), then look up mutably.
let tab_id = self.active_tab.clone().or_else(|| {
self.active_group
.as_ref()
.and_then(|gid| self.tab_groups.iter().find(|g| &g.id == gid))
.and_then(|g| g.pane_root.tab_ids().into_iter().next())
.map(|s| s.to_string())
});
let tab = if let Some(id) = tab_id.as_deref() {
self.tabs.iter_mut().find(|t| t.id == id)
} else {
self.tabs.first_mut()
};
if let Some(tab) = tab {
let snapshot = tab.render_snapshot();
let display_offset = snapshot.display_offset as i32;
let rows = snapshot.rows as i32;
// viewport row = grid_line + display_offset
let vp_row = target_grid_line + display_offset;
let visible = vp_row >= 0 && vp_row < rows;
if !visible {
// Scroll so the target grid line appears near the top.
// display_offset = -grid_line puts it at viewport row 0.
let new_offset = (-target_grid_line).max(0) as usize;
if new_offset > snapshot.display_offset {
tab.scroll_up_by(new_offset - snapshot.display_offset);
} else if new_offset < snapshot.display_offset {
tab.scroll_down_by(snapshot.display_offset - new_offset);
}
}
}
}
/// Build a highlight map for search matches, converting grid line indices
/// to the current viewport coordinates. Only returns highlights for the
/// pane that was actually searched (`search_target_tab`).
pub(crate) fn search_highlight_map(
&self,
tab_id: &str,
match_color: Hsla,
current_color: Hsla,
) -> Option<HashMap<(i32, i32), Hsla>> {
if self.search_matches.is_empty()
|| self.search_query.is_empty()
|| self.search_target_tab.as_deref() != Some(tab_id)
{
return None;
}
// Get current display_offset to convert grid line → viewport row.
let tab = self
.active_tab
.as_ref()
.and_then(|id| self.tabs.iter().find(|t| &t.id == id));
let tab = tab.or_else(|| {
let first_id = self
.active_group
.as_ref()
.and_then(|gid| self.tab_groups.iter().find(|g| &g.id == gid))
.and_then(|g| g.pane_root.tab_ids().into_iter().next())
.map(|s| s.to_string());
first_id
.as_deref()
.and_then(|id| self.tabs.iter().find(|t| t.id == id))
});
let tab = tab.or_else(|| self.tabs.first());
let Some(tab) = tab else {
return None;
};
let snapshot = tab.render_snapshot();
let display_offset = snapshot.display_offset as i32;
let rows = snapshot.rows as i32;
let mut map = HashMap::new();
let mut sorted: Vec<(i32, i32)> = self.search_matches.clone();
sorted.sort();
let mut group_idx = 0;
let mut i = 0;
while i < sorted.len() {
let is_current = group_idx == self.search_current;
let color = if is_current {
current_color
} else {
match_color
};
// grid_line → viewport row: vp_row = grid_line + display_offset
let (grid_line, _) = sorted[i];
let vp_row = grid_line + display_offset;
let mut j = i;
if vp_row >= 0 && vp_row < rows {
while j < sorted.len() && sorted[j].0 == grid_line {
if j > i && sorted[j].1 != sorted[j - 1].1 + 1 {
break;
}
map.insert((vp_row, sorted[j].1), color);
j += 1;
}
} else {
// Outside current viewport — skip.
while j < sorted.len() && sorted[j].0 == grid_line {
if j > i && sorted[j].1 != sorted[j - 1].1 + 1 {
break;
}
j += 1;
}
}
group_idx += 1;
i = j;
}
Some(map)
}
/// Render the search button (used in the tab bar).
pub(crate) fn render_search_button(
&self,
cx: &mut Context<Self>,
) -> impl gpui::IntoElement {
// Wrap in a div so .hover() doesn't conflict with Button's internal hover.
div()
.child(
Button::new("search-btn")
.ghost()
.small()
.rounded(px(999.))
.icon(IconName::Search)
.tooltip(t!("search").to_string())
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_search(window, cx);
})),
)
}
/// Render the expanded search bar overlay (when search is active).
pub(crate) fn render_search_bar(
&mut self,
_window: &mut Window,
cx: &mut Context<Self>,
) -> impl gpui::IntoElement {
let match_count = count_match_groups(&self.search_matches);
let has_query = !self.search_query.is_empty();
let has_matches = match_count > 0;
let current_display = if has_matches {
format!("{}/{}", self.search_current + 1, match_count)
} else if has_query {
"0".to_string()
} else {
String::new()
};
let view = cx.entity();
div()
.absolute()
.top(px(8.))
.right(px(24.))
.on_prepaint(move |bounds, _window, cx| {
let _ = view.update(cx, |this, _| {
this.search_bar_bounds = Some(bounds);
});
})
.on_mouse_down(MouseButton::Left, cx.listener(|this, _, window, cx| {
this.refocus_search_input(window, cx);
cx.stop_propagation();
}))
.child(
h_flex()
.gap_1()
.items_center()
.p_1()
.rounded(px(6.))
.bg(cx.theme().popover)
.border_1()
.border_color(cx.theme().border)
.child(
div()
.w(px(200.))
.on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, window, cx| {
if event.keystroke.key.as_str() == "escape" {
this.close_search(window, cx);
window.prevent_default();
cx.stop_propagation();
}
}))
.child(Input::new(&self.search_input).small()),
)
.when(!current_display.is_empty(), |this| {
this.child(
div()
.text_size(rems(0.75))
.text_color(cx.theme().muted_foreground)
.min_w(px(36.))
.text_center()
.child(current_display),
)
})
.child(
Button::new("search-prev")
.ghost()
.xsmall()
.icon(IconName::ChevronUp)
.disabled(!has_matches)
.on_click(cx.listener(|this, _, _, cx| {
this.search_goto_prev(cx);
})),
)
.child(
Button::new("search-next")
.ghost()
.xsmall()
.icon(IconName::ChevronDown)
.disabled(!has_matches)
.on_click(cx.listener(|this, _, _, cx| {
this.search_goto_next(cx);
})),
)
.child(
Button::new("search-close")
.ghost()
.xsmall()
.icon(IconName::Close)
.on_click(cx.listener(|this, _, window, cx| {
this.close_search(window, cx);
})),
),
)
.into_any_element()
}
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// Count distinct match groups in a sorted list of (row, col) positions.
/// A group is a run of consecutive columns in the same row.
fn count_match_groups(matches: &[(i32, i32)]) -> usize {
if matches.is_empty() {
return 0;
}
let mut sorted: Vec<(i32, i32)> = matches.to_vec();
sorted.sort();
let mut count = 0;
let mut i = 0;
while i < sorted.len() {
count += 1;
let (r, _) = sorted[i];
i += 1;
// Skip consecutive columns in the same row.
while i < sorted.len() && sorted[i].0 == r && sorted[i].1 == sorted[i - 1].1 + 1 {
i += 1;
}
}
count
}
/// Find the (row, col) start of the Nth distinct match group.
fn find_nth_match_start(matches: &[(i32, i32)], n: usize) -> Option<(i32, i32)> {
if matches.is_empty() {
return None;
}
let mut sorted: Vec<(i32, i32)> = matches.to_vec();
sorted.sort();
let mut group_idx = 0;
let mut i = 0;
while i < sorted.len() {
if group_idx == n {
return Some(sorted[i]);
}
group_idx += 1;
let (r, _) = sorted[i];
i += 1;
while i < sorted.len() && sorted[i].0 == r && sorted[i].1 == sorted[i - 1].1 + 1 {
i += 1;
}
}
None
}
+196 -42
View File
@@ -1,13 +1,134 @@
use gpui::{App, AppContext as _, Bounds, WindowOptions, point, px, size};
use gpui_component::Root;
use crate::session::config::ConfigStore;
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 {
let Ok(output) = std::process::Command::new(&shell)
.args(["-l", "-c", "env -0"])
.output()
else {
return;
};
if !output.status.success() {
@@ -41,6 +162,12 @@ pub(crate) fn sync_macos_launch_environment() {
| "HOMEBREW_PREFIX"
| "HOMEBREW_CELLAR"
| "HOMEBREW_REPOSITORY"
| "HTTP_PROXY"
| "HTTPS_PROXY"
| "ALL_PROXY"
| "http_proxy"
| "https_proxy"
| "all_proxy"
) || key.starts_with("LC_");
if should_import {
@@ -51,18 +178,71 @@ pub(crate) fn sync_macos_launch_environment() {
}
}
fn read_proxy_from_env() -> Option<(String, String, Option<u16>, String, String)> {
let vars = ["ALL_PROXY", "all_proxy", "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"];
for var in vars {
if let Ok(val) = std::env::var(var) {
if val.is_empty() {
continue;
}
if let Ok(url) = reqwest::Url::parse(&val) {
let scheme = url.scheme();
let proxy_type = match scheme {
"socks5" | "socks5h" => "socks5".to_string(),
"http" | "https" => "http".to_string(),
_ => "socks5".to_string(),
};
let host = url.host_str().unwrap_or("").to_string();
let port = url.port();
let user = url.username().to_string();
let password = url.password().unwrap_or("").to_string();
return Some((proxy_type, host, port, user, password));
}
}
}
None
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn sync_macos_launch_environment() {}
pub(crate) fn open_main_window(cx: &mut App) {
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
let _ = crate::session::config::ENV_PROXY.get_or_init(|| {
read_proxy_from_env().map(|(proxy_type, host, port, user, password)| {
tracing::info!(
"[proxy] Loaded proxy configuration from environment: type={}, host={}, port={:?}, user={}",
proxy_type,
host,
port,
user
);
crate::session::config::EnvProxy {
proxy_type,
host,
port,
user,
pass: password,
}
})
});
let mut window_options = WindowOptions::default();
if config.title_bar_style() == crate::session::config::TitleBarStyle::Integrated {
window_options.titlebar = Some(gpui::TitlebarOptions {
title: None,
appears_transparent: true,
traffic_light_position: Some(gpui::point(px(9.0), px(9.0))),
});
}
#[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 {
@@ -117,45 +297,20 @@ pub(crate) fn open_main_window(cx: &mut App) {
gpui_component::Theme::sync_system_appearance(Some(window), cx);
let view = cx.new(|cx| Ashell::new(window, cx));
let workspace_panels_clone = view.read(cx).workspace_panels.clone();
let body_panels_clone = view.read(cx).body_panels.clone();
tracing::info!("[ui] main application window opened");
let focus_handle = view.read(cx).focus_handle.clone();
window.focus(&focus_handle, cx);
let view_clone = view.clone();
window.on_window_should_close(cx, move |window: &mut gpui::Window, cx: &mut gpui::App| {
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 body_sizes: Vec<f32> = body_panels_clone
.read(cx)
.sizes()
.iter()
.map(|s| s.into())
.collect();
config.set_layout_state(Some(saved_bounds), Some(workspace_sizes), Some(body_sizes));
let _ = config.save();
let handle = window.window_handle();
if !cx.windows().contains(&handle) {
tracing::warn!("[ui] window not found in app during close, skipping save layout state.");
return true;
}
view_clone.read(cx).save_layout_state(window, cx);
true
});
@@ -163,4 +318,3 @@ pub(crate) fn open_main_window(cx: &mut App) {
})
.expect("failed to open window");
}
+39 -105
View File
@@ -1,11 +1,6 @@
use anyhow::{Context as _, Result};
use gpui::{Anchor, App, Context, IntoElement, SharedString, Window, px};
use gpui_component::{
ActiveTheme as _, IconName, Sizable as _, Theme, ThemeMode, ThemeRegistry,
button::{Button, ButtonVariants as _},
menu::{DropdownMenu as _, PopupMenuItem},
};
use rust_i18n::t;
use gpui::{App, Context, SharedString, Window, px};
use gpui_component::{ActiveTheme as _, Theme, ThemeMode, ThemeRegistry};
use crate::Ashell;
@@ -14,15 +9,28 @@ pub(crate) const EMBEDDED_THEME_JSONS: &[&str] = &[
include_str!("../../assets/themes/tokyonight.json"),
include_str!("../../assets/themes/gruvbox.json"),
include_str!("../../assets/themes/solarized.json"),
include_str!("../../assets/themes/phygerr.json"),
];
use std::sync::atomic::{AtomicBool, Ordering};
pub(crate) static USING_SYSTEM_MAPLE: AtomicBool = AtomicBool::new(false);
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")?;
let has_system_maple = cx.text_system().all_font_names().contains(&"Maple Mono NF CN".to_string());
if has_system_maple {
USING_SYSTEM_MAPLE.store(true, Ordering::Relaxed);
} else {
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(())
}
@@ -42,7 +50,12 @@ pub(crate) fn set_theme_font_names(theme: &mut Theme, ui_font_family: &str) {
}
impl Ashell {
pub(crate) fn switch_theme_mode(&mut self, mode: ThemeMode, window: &mut Window, cx: &mut Context<Self>) {
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);
@@ -51,7 +64,12 @@ impl Ashell {
cx.notify();
}
pub(crate) fn apply_theme(&mut self, name: SharedString, window: &mut Window, cx: &mut Context<Self>) {
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();
@@ -87,7 +105,12 @@ impl Ashell {
cx.notify();
}
pub(crate) fn set_display_language(&mut self, locale: &str, window: &mut Window, cx: &mut Context<Self>) {
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" {
@@ -146,93 +169,4 @@ impl Ashell {
tracing::warn!("failed to save theme preferences: {err:#}");
}
}
pub(crate) fn theme_dropdown(&self, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
let themes = ThemeRegistry::global(cx)
.sorted_themes()
.into_iter()
.cloned()
.collect::<Vec<_>>();
let light_themes = themes
.iter()
.filter(|theme| !theme.mode.is_dark())
.map(|theme| theme.name.clone())
.collect::<Vec<_>>();
let dark_themes = themes
.iter()
.filter(|theme| theme.mode.is_dark())
.map(|theme| theme.name.clone())
.collect::<Vec<_>>();
let follow_system = self.follow_system_theme;
let is_dark_mode = cx.theme().mode.is_dark();
let light_theme_name = self.light_theme_name.clone();
let dark_theme_name = self.dark_theme_name.clone();
let icon = if follow_system {
IconName::Sun
} else if is_dark_mode {
IconName::Moon
} else {
IconName::Sun
};
Button::new("theme-dropdown")
.ghost()
.small()
.icon(icon)
.dropdown_menu_with_anchor(Anchor::BottomRight, move |mut menu, window, _| {
menu = menu
.min_w(220.)
.item(
PopupMenuItem::new(t!("follow_system"))
.checked(follow_system)
.on_click(window.listener_for(&view, |this, _, window, cx| {
this.set_follow_system_theme(true, window, cx)
})),
)
.item(
PopupMenuItem::new(t!("use_light_mode"))
.checked(!follow_system && !is_dark_mode)
.on_click(window.listener_for(&view, |this, _, window, cx| {
this.switch_theme_mode(ThemeMode::Light, window, cx)
})),
)
.item(
PopupMenuItem::new(t!("use_dark_mode"))
.checked(!follow_system && is_dark_mode)
.on_click(window.listener_for(&view, |this, _, window, cx| {
this.switch_theme_mode(ThemeMode::Dark, window, cx)
})),
)
.separator()
.label(t!("light_theme").to_string());
for theme_name in light_themes.clone() {
let checked = theme_name == light_theme_name;
menu = menu.item(
PopupMenuItem::new(theme_name.clone())
.checked(checked)
.on_click(window.listener_for(&view, move |this, _, window, cx| {
this.apply_theme(theme_name.clone(), window, cx)
})),
);
}
menu = menu.separator();
menu = menu.label(t!("dark_theme").to_string());
for theme_name in dark_themes.clone() {
let checked = theme_name == dark_theme_name;
menu = menu.item(
PopupMenuItem::new(theme_name.clone())
.checked(checked)
.on_click(window.listener_for(&view, move |this, _, window, cx| {
this.apply_theme(theme_name.clone(), window, cx)
})),
);
}
menu
})
}
}
+1569 -595
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();
+135 -32
View File
@@ -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")?;
@@ -124,6 +127,7 @@ async fn run_ssh(
match command {
Some(BackendCommand::Input(bytes)) => {
if let Err(err) = channel.data(bytes.as_slice()).await {
tracing::error!("[ssh] write error on tab {}: {}", tab_id, err);
exit_reason = format!("ssh write error: {err}");
break;
}
@@ -131,7 +135,29 @@ async fn run_ssh(
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;
@@ -151,16 +177,20 @@ async fn run_ssh(
}
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;
@@ -172,6 +202,8 @@ async fn run_ssh(
}
let _ = handle
.lock()
.await
.disconnect(Disconnect::ByApplication, "bye", "")
.await;
let _ = events.send(BackendEvent::Closed {
@@ -188,21 +220,40 @@ 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,
keepalive_interval: Some(std::time::Duration::from_secs(3)),
keepalive_max: 2,
..Default::default()
});
let addr = format!("{}:{}", session.host, session.port);
tracing::info!(
"[ssh] initiating tcp connection to {} (user: {})",
addr,
session.user
);
let status_text = if let Some((ptype, phost, pport)) = crate::session::config::active_proxy(session) {
let pport_val = pport.unwrap_or_else(|| if ptype == "http" { 8080 } else { 1080 });
format!("connecting to {addr} via {} proxy {}:{}", ptype.to_uppercase(), phost, pport_val)
} else {
format!("opening tcp connection to {addr}")
};
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!("opening tcp connection to {addr}"),
text: status_text,
});
let mut handle = client::connect(config, addr.as_str(), ClientHandler)
let stream = crate::session::config::connect_proxy(session).await?;
let mut handle = client::connect_stream(config, stream, ClientHandler)
.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!(
@@ -217,6 +268,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}"),
@@ -227,20 +284,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;
@@ -262,6 +339,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!(
@@ -276,6 +359,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();
@@ -286,14 +371,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())),
}
@@ -302,16 +387,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 {
@@ -438,6 +540,7 @@ echo "NET_RX=0"
echo "NET_TX=0"
'"#;
#[derive(Clone)]
struct ClientHandler;
#[async_trait]
+23 -11
View File
@@ -1,12 +1,13 @@
#![windows_subsystem = "windows"]
use gpui::{ KeyBinding };
use gpui::KeyBinding;
use gpui_component_assets::Assets;
mod app;
mod backend;
mod session;
mod sftp;
mod sync;
mod system;
mod terminal;
@@ -14,26 +15,28 @@ rust_i18n::i18n!("locales", fallback = "en");
gpui::actions!(ashell_terminal, [TerminalTabKey, TerminalBacktabKey]);
pub(crate) use app::keybinding_recorder::{
ClosePane, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSearch,
OpenSession, OpenSettings, OpenTransfers, SplitPaneDown, SplitPaneLeft, SplitPaneRight,
SplitPaneUp, ToggleSftpZoom, ToggleSidebar, Copy, Paste,
};
pub(crate) use app::{
Ashell, ConnectionProgress, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup,
Ashell, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup,
};
fn main() {
app::startup::sync_macos_launch_environment();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
app::startup::init_logging();
#[cfg(target_os = "macos")]
let app = gpui_platform::application()
.with_assets(Assets)
.with_quit_mode(gpui::QuitMode::Explicit);
.with_quit_mode(gpui::QuitMode::LastWindowClosed);
#[cfg(not(target_os = "macos"))]
let app = gpui_platform::application().with_assets(Assets);
app.on_reopen(|cx| {
if cx.windows().is_empty() {
app::startup::open_main_window(cx);
@@ -42,9 +45,18 @@ fn main() {
app.run(move |cx| {
gpui_component::init(cx);
cx.bind_keys([
KeyBinding::new("tab", TerminalTabKey, Some(app::constants::TERMINAL_KEY_CONTEXT)),
KeyBinding::new("shift-tab", TerminalBacktabKey, Some(app::constants::TERMINAL_KEY_CONTEXT)),
KeyBinding::new(
"tab",
TerminalTabKey,
Some(app::constants::TERMINAL_KEY_CONTEXT),
),
KeyBinding::new(
"shift-tab",
TerminalBacktabKey,
Some(app::constants::TERMINAL_KEY_CONTEXT),
),
]);
app::startup::bind_workspace_keys(cx);
app::theme::load_embedded_themes(cx);
if let Err(err) = app::theme::load_fonts(cx) {
tracing::warn!("failed to load embedded fonts: {err:#}");
+545 -9
View File
@@ -27,7 +27,19 @@ pub struct Session {
#[serde(default)]
pub private_key_inline: String,
#[serde(default)]
pub passphrase: String,
#[serde(default)]
pub last_used: Option<String>,
#[serde(default = "default_global_proxy_type")]
pub proxy_type: String, // "none", "socks5", "http"
#[serde(default)]
pub proxy_host: String,
#[serde(default)]
pub proxy_port: Option<u16>,
#[serde(default)]
pub proxy_user: String,
#[serde(default)]
pub proxy_password: String,
}
impl Session {
@@ -43,7 +55,13 @@ impl Session {
password,
private_key_path: String::new(),
private_key_inline: String::new(),
passphrase: String::new(),
last_used: None,
proxy_type: "none".to_string(),
proxy_host: String::new(),
proxy_port: None,
proxy_user: String::new(),
proxy_password: String::new(),
}
}
@@ -53,6 +71,7 @@ impl Session {
user: String,
private_key_path: String,
private_key_inline: String,
passphrase: String,
) -> Self {
let name = format!("{user}@{host}");
Self {
@@ -65,7 +84,13 @@ impl Session {
password: String::new(),
private_key_path,
private_key_inline,
passphrase,
last_used: None,
proxy_type: "none".to_string(),
proxy_host: String::new(),
proxy_port: None,
proxy_user: String::new(),
proxy_password: String::new(),
}
}
}
@@ -93,9 +118,27 @@ pub enum SavedWindowBounds {
},
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum TitleBarStyle {
Native,
#[default]
Integrated,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum CursorStyle {
#[default]
Default,
Blink,
Beam,
BeamBlink,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigFile {
#[serde(default)]
#[serde(default = "default_follow_system_theme")]
pub follow_system_theme: bool,
#[serde(default)]
pub theme_mode: String,
@@ -111,11 +154,17 @@ pub struct ConfigFile {
pub ui_font_size: f32,
#[serde(default)]
pub right_click_copy_paste: bool,
#[serde(default)]
pub keyword_highlight: bool,
#[serde(default = "default_ui_font_family")]
pub ui_font_family: String,
#[serde(default = "default_terminal_font_family")]
pub terminal_font_family: String,
#[serde(default)]
pub title_bar_style: TitleBarStyle,
#[serde(default)]
pub cursor_style: CursorStyle,
#[serde(default)]
pub sessions: Vec<Session>,
#[serde(default)]
pub window_bounds: Option<SavedWindowBounds>,
@@ -129,22 +178,82 @@ pub struct ConfigFile {
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>,
#[serde(default)]
pub sync_endpoint: String,
#[serde(default)]
pub sync_username: String,
#[serde(default)]
pub sync_etag: Option<String>,
#[serde(default)]
pub sync_device_id: String,
#[serde(default)]
pub sync_backend: String,
#[serde(default)]
pub sync_etag_backend: String,
#[serde(default)]
pub sync_s3_endpoint: String,
#[serde(default = "default_s3_region")]
pub sync_s3_region: String,
#[serde(default)]
pub sync_s3_bucket: String,
#[serde(default = "default_s3_object_key")]
pub sync_s3_object_key: String,
#[serde(default)]
pub use_proxy: bool,
#[serde(default = "default_read_env_proxy")]
pub read_env_proxy: bool,
#[serde(default = "default_global_proxy_type")]
pub global_proxy_type: String,
#[serde(default)]
pub global_proxy_host: String,
#[serde(default)]
pub global_proxy_port: Option<u16>,
#[serde(default)]
pub global_proxy_user: String,
#[serde(default)]
pub global_proxy_password: String,
}
fn default_read_env_proxy() -> bool {
true
}
fn default_global_proxy_type() -> String {
"socks5".to_string()
}
fn default_monitoring_position() -> String {
"Sidebar".to_string()
}
fn default_s3_region() -> String {
"us-east-1".to_string()
}
fn default_s3_object_key() -> String {
"ashell-sync.json".to_string()
}
fn default_follow_system_theme() -> bool {
true
}
fn default_locale() -> String {
"system".to_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 {
@@ -157,6 +266,53 @@ fn default_terminal_font_family() -> String {
"Maple Mono NF CN".to_string()
}
impl Default for ConfigFile {
fn default() -> Self {
Self {
follow_system_theme: default_follow_system_theme(),
theme_mode: String::new(),
light_theme_name: String::new(),
dark_theme_name: String::new(),
locale: default_locale(),
terminal_font_size: default_terminal_font_size(),
ui_font_size: default_ui_font_size(),
right_click_copy_paste: false,
keyword_highlight: false,
ui_font_family: default_ui_font_family(),
terminal_font_family: default_terminal_font_family(),
title_bar_style: TitleBarStyle::default(),
cursor_style: CursorStyle::default(),
sessions: Vec::new(),
window_bounds: None,
workspace_panels: None,
body_panels: None,
transfers: Vec::new(),
show_hidden_files: false,
monitoring_position: default_monitoring_position(),
sidebar_collapsed: false,
sftp_panel_minimized: false,
key_bindings: std::collections::HashMap::new(),
sync_endpoint: String::new(),
sync_username: String::new(),
sync_etag: None,
sync_device_id: String::new(),
sync_backend: String::new(),
sync_etag_backend: String::new(),
sync_s3_endpoint: String::new(),
sync_s3_region: default_s3_region(),
sync_s3_bucket: String::new(),
sync_s3_object_key: default_s3_object_key(),
use_proxy: false,
read_env_proxy: true,
global_proxy_type: default_global_proxy_type(),
global_proxy_host: String::new(),
global_proxy_port: None,
global_proxy_user: String::new(),
global_proxy_password: String::new(),
}
}
}
pub struct ConfigStore {
path: PathBuf,
cache: ConfigFile,
@@ -168,26 +324,62 @@ 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 mut 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()
};
if cache.sync_device_id.is_empty() {
cache.sync_device_id = Uuid::new_v4().to_string();
}
Ok(Self { path, cache })
}
pub fn in_memory() -> Self {
let cache = ConfigFile {
sync_device_id: Uuid::new_v4().to_string(),
..ConfigFile::default()
};
Self {
path: PathBuf::new(),
cache: ConfigFile::default(),
cache,
}
}
@@ -204,6 +396,79 @@ impl ConfigStore {
&self.cache.sessions
}
pub fn replace_sessions(&mut self, sessions: Vec<Session>) {
self.cache.sessions = sessions;
}
pub fn sync_endpoint(&self) -> &str {
&self.cache.sync_endpoint
}
pub fn sync_username(&self) -> &str {
&self.cache.sync_username
}
pub fn sync_etag(&self) -> Option<&str> {
(self.cache.sync_etag_backend == self.sync_backend())
.then_some(self.cache.sync_etag.as_deref())
.flatten()
}
pub fn sync_device_id(&self) -> &str {
&self.cache.sync_device_id
}
pub fn sync_backend(&self) -> &str {
if self.cache.sync_backend == "s3" {
"s3"
} else {
"webdav"
}
}
pub fn set_sync_backend(&mut self, backend: &str) {
self.cache.sync_backend = if backend == "s3" { "s3" } else { "webdav" }.to_string();
}
pub fn sync_s3_endpoint(&self) -> &str {
&self.cache.sync_s3_endpoint
}
pub fn sync_s3_region(&self) -> &str {
if self.cache.sync_s3_region.is_empty() { "us-east-1" } else { &self.cache.sync_s3_region }
}
pub fn sync_s3_bucket(&self) -> &str {
&self.cache.sync_s3_bucket
}
pub fn sync_s3_object_key(&self) -> &str {
if self.cache.sync_s3_object_key.is_empty() { "ashell-sync.json" } else { &self.cache.sync_s3_object_key }
}
pub fn set_sync_connection(&mut self, endpoint: String, username: String) {
self.cache.sync_endpoint = endpoint;
self.cache.sync_username = username;
}
pub fn set_sync_s3_connection(
&mut self,
endpoint: String,
region: String,
bucket: String,
object_key: String,
) {
self.cache.sync_s3_endpoint = endpoint;
self.cache.sync_s3_region = region;
self.cache.sync_s3_bucket = bucket;
self.cache.sync_s3_object_key = object_key;
}
pub fn set_sync_etag(&mut self, etag: Option<String>) {
self.cache.sync_etag = etag;
self.cache.sync_etag_backend = self.sync_backend().to_string();
}
pub fn tmp_dir(&self) -> Option<PathBuf> {
self.path.parent().map(|p| p.join("tmp"))
}
@@ -236,8 +501,22 @@ 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 {
&self.cache.monitoring_position
if self.cache.monitoring_position.is_empty() {
"Sidebar"
} else {
&self.cache.monitoring_position
}
}
pub fn set_monitoring_position(&mut self, pos: &str) {
@@ -336,6 +615,14 @@ impl ConfigStore {
self.cache.right_click_copy_paste = val;
}
pub fn keyword_highlight(&self) -> bool {
self.cache.keyword_highlight
}
pub fn set_keyword_highlight(&mut self, val: bool) {
self.cache.keyword_highlight = val;
}
pub fn terminal_font_family(&self) -> &str {
if self.cache.terminal_font_family.is_empty() {
"Maple Mono NF CN"
@@ -348,6 +635,65 @@ impl ConfigStore {
self.cache.terminal_font_family = family.to_string();
}
pub fn title_bar_style(&self) -> TitleBarStyle {
self.cache.title_bar_style
}
pub fn set_title_bar_style(&mut self, style: TitleBarStyle) {
self.cache.title_bar_style = style;
}
pub fn cursor_style(&self) -> CursorStyle {
self.cache.cursor_style
}
pub fn set_cursor_style(&mut self, style: CursorStyle) {
self.cache.cursor_style = style;
}
pub fn use_proxy(&self) -> bool {
self.cache.use_proxy
}
pub fn set_use_proxy(&mut self, val: bool) {
self.cache.use_proxy = val;
}
pub fn read_env_proxy(&self) -> bool {
self.cache.read_env_proxy
}
pub fn set_read_env_proxy(&mut self, val: bool) {
self.cache.read_env_proxy = val;
}
pub fn global_proxy_type(&self) -> &str {
&self.cache.global_proxy_type
}
pub fn set_global_proxy_type(&mut self, val: String) {
self.cache.global_proxy_type = val;
}
pub fn global_proxy_host(&self) -> &str {
&self.cache.global_proxy_host
}
pub fn set_global_proxy_host(&mut self, val: String) {
self.cache.global_proxy_host = val;
}
pub fn global_proxy_port(&self) -> Option<u16> {
self.cache.global_proxy_port
}
pub fn set_global_proxy_port(&mut self, val: Option<u16>) {
self.cache.global_proxy_port = val;
}
pub fn global_proxy_user(&self) -> &str {
&self.cache.global_proxy_user
}
pub fn set_global_proxy_user(&mut self, val: String) {
self.cache.global_proxy_user = val;
}
pub fn global_proxy_password(&self) -> &str {
&self.cache.global_proxy_password
}
pub fn set_global_proxy_password(&mut self, val: String) {
self.cache.global_proxy_password = val;
}
pub fn show_hidden_files(&self) -> bool {
self.cache.show_hidden_files
}
@@ -356,6 +702,22 @@ impl ConfigStore {
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)
}
@@ -378,6 +740,180 @@ 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(())
}
}
pub trait ProxyStream: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + Sync + 'static {}
impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + Sync + 'static> ProxyStream for T {}
use std::sync::OnceLock;
#[derive(Debug, Clone)]
pub struct EnvProxy {
pub proxy_type: String,
pub host: String,
pub port: Option<u16>,
pub user: String,
pub pass: String,
}
pub static ENV_PROXY: OnceLock<Option<EnvProxy>> = OnceLock::new();
pub async fn connect_proxy(session: &Session) -> Result<Box<dyn ProxyStream>> {
let target_host = &session.host;
let target_port = session.port;
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
let (proxy_type, proxy_host, proxy_port, proxy_user, proxy_password) = {
if !session.proxy_type.is_empty() && session.proxy_type != "none" {
(
session.proxy_type.clone(),
session.proxy_host.clone(),
session.proxy_port,
session.proxy_user.clone(),
session.proxy_password.clone(),
)
} else if config.cache.read_env_proxy && ENV_PROXY.get().and_then(|opt| opt.as_ref()).is_some() {
let env_p = ENV_PROXY.get().and_then(|opt| opt.as_ref()).unwrap();
(
env_p.proxy_type.clone(),
env_p.host.clone(),
env_p.port,
env_p.user.clone(),
env_p.pass.clone(),
)
} else if config.cache.use_proxy {
(
config.cache.global_proxy_type.clone(),
config.cache.global_proxy_host.clone(),
config.cache.global_proxy_port,
config.cache.global_proxy_user.clone(),
config.cache.global_proxy_password.clone(),
)
} else {
("none".to_string(), String::new(), None, String::new(), String::new())
}
};
if proxy_type != "none" && (proxy_host.is_empty() || proxy_port.is_none()) {
let addr = format!("{}:{}", target_host, target_port);
let stream = tokio::net::TcpStream::connect(&addr).await?;
return Ok(Box::new(stream));
}
match proxy_type.as_str() {
"socks5" | "socks5h" => {
let proxy_port = proxy_port.unwrap_or(1080);
let proxy_addr = format!("{}:{}", proxy_host, proxy_port);
if !proxy_user.is_empty() {
let stream = tokio_socks::tcp::Socks5Stream::connect_with_password(
proxy_addr.as_str(),
(target_host.as_str(), target_port),
&proxy_user,
&proxy_password,
)
.await
.map_err(|e| anyhow::anyhow!("SOCKS5 proxy connection failed: {}", e))?;
Ok(Box::new(stream))
} else {
let stream = tokio_socks::tcp::Socks5Stream::connect(
proxy_addr.as_str(),
(target_host.as_str(), target_port),
)
.await
.map_err(|e| anyhow::anyhow!("SOCKS5 proxy connection failed: {}", e))?;
Ok(Box::new(stream))
}
}
"http" => {
let proxy_port = proxy_port.unwrap_or(8080);
let proxy_addr = format!("{}:{}", proxy_host, proxy_port);
use tokio::io::AsyncWriteExt;
let mut stream = tokio::net::TcpStream::connect(&proxy_addr)
.await
.map_err(|e| anyhow::anyhow!("HTTP proxy connection failed: {}", e))?;
let mut request = format!(
"CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n",
target_host, target_port, target_host, target_port
);
if !proxy_user.is_empty() {
use base64::Engine as _;
let auth = format!("{}:{}", proxy_user, proxy_password);
let encoded = base64::engine::general_purpose::STANDARD.encode(auth);
request.push_str(&format!("Proxy-Authorization: Basic {}\r\n", encoded));
}
request.push_str("\r\n");
stream.write_all(request.as_bytes()).await?;
let mut response = [0u8; 1024];
let n = tokio::io::AsyncReadExt::read(&mut stream, &mut response).await?;
let resp_str = String::from_utf8_lossy(&response[..n]);
if !resp_str.contains("200") && !resp_str.contains("established") {
return Err(anyhow::anyhow!("HTTP proxy CONNECT failed: {}", resp_str));
}
Ok(Box::new(stream))
}
_ => {
let addr = format!("{}:{}", target_host, target_port);
let stream = tokio::net::TcpStream::connect(&addr).await?;
Ok(Box::new(stream))
}
}
}
pub fn active_proxy(session: &Session) -> Option<(String, String, Option<u16>)> {
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
let (proxy_type, proxy_host, proxy_port, _, _) = {
if !session.proxy_type.is_empty() && session.proxy_type != "none" {
(
session.proxy_type.clone(),
session.proxy_host.clone(),
session.proxy_port,
session.proxy_user.clone(),
session.proxy_password.clone(),
)
} else if config.cache.read_env_proxy && ENV_PROXY.get().and_then(|opt| opt.as_ref()).is_some() {
let env_p = ENV_PROXY.get().and_then(|opt| opt.as_ref()).unwrap();
(
env_p.proxy_type.clone(),
env_p.host.clone(),
env_p.port,
env_p.user.clone(),
env_p.pass.clone(),
)
} else if config.cache.use_proxy {
(
config.cache.global_proxy_type.clone(),
config.cache.global_proxy_host.clone(),
config.cache.global_proxy_port,
config.cache.global_proxy_user.clone(),
config.cache.global_proxy_password.clone(),
)
} else {
("none".to_string(), String::new(), None, String::new(), String::new())
}
};
if proxy_type != "none" && !proxy_host.is_empty() && proxy_port.is_some() {
Some((proxy_type, proxy_host, proxy_port))
} else {
None
}
}
+380 -174
View File
@@ -1,23 +1,22 @@
pub mod config;
use gpui::{
App, AppContext as _, Context, Entity, KeyDownEvent, MouseButton,
MouseDownEvent, MouseMoveEvent, SharedString, Window, px,
};
use gpui_component::{
Theme, WindowExt as _,
input::InputState,
AppContext as _, Context, Entity, KeyDownEvent, MouseButton, MouseDownEvent,
MouseMoveEvent, SharedString, Window, px,
};
use gpui_component::{Theme, WindowExt as _, input::InputState};
use rust_i18n::t;
use uuid::Uuid;
use self::config::{AuthMethod, Session};
use crate::{
Ashell, ConnectionProgress, PaneLayout, SelectorEntry, TabGroup,
Ashell, PaneLayout, SelectorEntry, TabGroup,
app::constants::{
DEFAULT_COLS, DEFAULT_ROWS,
},
backend::{local, ssh},
terminal::{BackendCommand, RenderSnapshot, TabKind, TerminalTab},
app::constants::{DEFAULT_COLS, DEFAULT_ROWS, SIDEBAR_WIDTH, TAB_BAR_HEIGHT, TERMINAL_PADDING_X, TERMINAL_PADDING_Y},
};
impl Ashell {
@@ -57,6 +56,7 @@ impl Ashell {
}
pub(crate) fn connect_ssh(&mut self, window: &mut Window, cx: &mut Context<Self>) {
tracing::info!("[ui] user initiating new ssh connection from form");
let session_name = self.session_name_input.read(cx).value().trim().to_string();
let host = self.host_input.read(cx).value().trim().to_string();
let port = self
@@ -70,6 +70,7 @@ impl Ashell {
let password = self.password_input.read(cx).value().to_string();
let key_path = self.key_path_input.read(cx).value().trim().to_string();
let key_inline = self.key_inline_input.read(cx).value().to_string();
let passphrase = self.passphrase_input.read(cx).value().to_string();
if host.is_empty() || user.is_empty() {
self.status = t!("host_and_user_required").into();
@@ -77,6 +78,17 @@ impl Ashell {
return;
}
if self.ssh_proxy_type != "none" {
let proxy_host = self.proxy_host_input.read(cx).value().trim().to_string();
let proxy_port_str = self.proxy_port_input.read(cx).value().trim().to_string();
let proxy_port = proxy_port_str.parse::<u16>().ok();
if proxy_host.is_empty() || proxy_port.is_none() {
self.status = "Proxy host and port are required".into();
cx.notify();
return;
}
}
let name = if session_name.is_empty() {
host.clone()
} else {
@@ -96,7 +108,7 @@ impl Ashell {
cx.notify();
return;
}
Session::key(host, port, user, key_path, key_inline)
Session::key(host, port, user, key_path, key_inline, passphrase)
}
};
session.name = name;
@@ -104,6 +116,17 @@ impl Ashell {
session.id = id;
}
session.last_used = existing_last_used;
session.proxy_type = self.ssh_proxy_type.clone();
session.proxy_host = self.proxy_host_input.read(cx).value().trim().to_string();
session.proxy_port = self
.proxy_port_input
.read(cx)
.value()
.trim()
.parse::<u16>()
.ok();
session.proxy_user = self.proxy_user_input.read(cx).value().trim().to_string();
session.proxy_password = self.proxy_password_input.read(cx).value().to_string();
self.config.upsert(session.clone());
if let Err(err) = self.config.save() {
tracing::warn!("failed to save config: {err:#}");
@@ -111,7 +134,9 @@ impl Ashell {
self.open_ssh_session(session, cx);
self.editing_session_id = None;
self.active_dialog = None;
window.close_dialog(cx);
cx.notify();
}
pub(crate) fn set_input_value(
@@ -133,6 +158,12 @@ impl Ashell {
Self::set_input_value(&self.password_input, "", window, cx);
Self::set_input_value(&self.key_path_input, "", window, cx);
Self::set_input_value(&self.key_inline_input, "", window, cx);
Self::set_input_value(&self.passphrase_input, "", window, cx);
self.ssh_proxy_type = "none".to_string();
Self::set_input_value(&self.proxy_host_input, "", window, cx);
Self::set_input_value(&self.proxy_port_input, "", window, cx);
Self::set_input_value(&self.proxy_user_input, "", window, cx);
Self::set_input_value(&self.proxy_password_input, "", window, cx);
}
pub(crate) fn load_session_into_form(
@@ -160,6 +191,53 @@ impl Ashell {
window,
cx,
);
Self::set_input_value(
&self.passphrase_input,
session.passphrase.clone(),
window,
cx,
);
self.ssh_proxy_type = if session.proxy_type.is_empty() {
"none".to_string()
} else {
session.proxy_type.clone()
};
Self::set_input_value(&self.proxy_host_input, session.proxy_host.clone(), window, cx);
Self::set_input_value(
&self.proxy_port_input,
session.proxy_port.map(|p| p.to_string()).unwrap_or_default(),
window,
cx,
);
Self::set_input_value(&self.proxy_user_input, session.proxy_user.clone(), window, cx);
Self::set_input_value(&self.proxy_password_input, session.proxy_password.clone(), window, cx);
}
pub(crate) fn pick_ssh_key_path(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let start_dir = directories::BaseDirs::new()
.map(|d| d.home_dir().join(".ssh"))
.unwrap_or_else(|| std::path::PathBuf::from("/"));
let file_dialog = rfd::AsyncFileDialog::new()
.set_directory(start_dir)
.pick_file();
cx.spawn_in(window, async move |this, mut cx| {
if let Some(file) = file_dialog.await {
let _ = gpui::AsyncWindowContext::update(&mut cx, |window, cx| {
let _ = this.update(cx, |this, cx| {
Self::set_input_value(
&this.key_path_input,
file.path().to_string_lossy().to_string(),
window,
cx,
);
});
});
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
pub(crate) fn open_new_ssh_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -182,6 +260,28 @@ impl Ashell {
self.show_ssh_dialog(window, cx);
}
pub(crate) fn clone_saved_session(
&mut self,
session_id: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(session) = self.config.get(&session_id).cloned() else {
self.status = "saved session not found".into();
cx.notify();
return;
};
self.load_session_into_form(&session, window, cx);
self.editing_session_id = None;
Self::set_input_value(
&self.session_name_input,
format!("{}-copy", session.name),
window,
cx,
);
self.show_ssh_dialog(window, cx);
}
pub(crate) fn terminal_cell_width(&self) -> f32 {
(self.terminal_font_size * 0.646).max(6.0)
}
@@ -236,14 +336,27 @@ impl Ashell {
cx.notify();
}
pub(crate) fn change_cursor_style(
&mut self,
style: crate::session::config::CursorStyle,
cx: &mut Context<Self>,
) {
self.cursor_style = style;
self.config.set_cursor_style(style);
if let Err(err) = self.config.save() {
tracing::warn!("failed to save cursor style: {err:#}");
}
cx.notify();
}
pub(crate) fn reset_layout(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
self.config.set_layout_state(None, None, None);
let _ = self.config.save();
self.is_layout_reset = true;
self.workspace_panels = cx.new(|_| gpui_component::resizable::ResizableState::default());
self.body_panels = cx.new(|_| gpui_component::resizable::ResizableState::default());
self.status = t!("reset_layout_success").into();
cx.notify();
}
@@ -252,7 +365,16 @@ impl Ashell {
cx.notify();
}
pub(crate) fn set_ssh_proxy_type(&mut self, proxy_type: String, cx: &mut Context<Self>) {
self.ssh_proxy_type = proxy_type;
cx.notify();
}
pub(crate) fn connect_saved_session(&mut self, session_id: String, cx: &mut Context<Self>) {
tracing::info!(
"[ui] user clicked to connect saved session '{}'",
session_id
);
let Some(session) = self.config.get(&session_id).cloned() else {
self.status = "saved session not found".into();
cx.notify();
@@ -296,12 +418,17 @@ impl Ashell {
}
}
pub(crate) fn activate_selector_selection(&mut self, window: &mut Window, cx: &mut Context<Self>) {
pub(crate) fn activate_selector_selection(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let entries = self.selector_entries();
let Some(entry) = entries.get(self.selector_selection).cloned() else {
return;
};
self.active_dialog = None;
match entry {
SelectorEntry::Local => {
self.open_local(cx);
@@ -316,6 +443,7 @@ impl Ashell {
window.close_dialog(cx);
}
}
cx.notify();
}
pub(crate) fn on_selector_key_down(
@@ -346,6 +474,12 @@ impl Ashell {
}
pub(crate) fn open_ssh_session(&mut self, session: Session, cx: &mut Context<Self>) {
tracing::info!(
"[session] opening ssh tab for session '{}' ({}@{})",
session.name,
session.user,
session.host
);
let id = Uuid::new_v4().to_string();
let backend = ssh::spawn_ssh_terminal(
self.runtime.handle(),
@@ -362,6 +496,12 @@ impl Ashell {
self.events_tx.clone(),
));
self.active_tab = Some(id.clone());
self.connection_progress = Some(crate::app::ConnectionProgress {
tab_id: id.clone(),
title: rust_i18n::t!("connecting").into(),
lines: vec![rust_i18n::t!("starting_connection").into()],
failed: false,
});
self.pane_root = PaneLayout::Single(id.clone());
self.focused_pane_path = vec![];
let group_id = Uuid::new_v4().to_string();
@@ -382,7 +522,12 @@ impl Ashell {
self.active_group = Some(group_id.clone());
self.tabs_scroll_handle.scroll_to_item(self.tabs.len() - 1);
if let Some(session_id) = self.active_session_id() {
if let Some(index) = self.config.sessions().iter().position(|s| s.id == session_id) {
if let Some(index) = self
.config
.sessions()
.iter()
.position(|s| s.id == session_id)
{
self.saved_scroll_handle.scroll_to_item(index);
}
}
@@ -396,12 +541,6 @@ impl Ashell {
self.sftp_handles.insert(group_id.clone(), sftp_handle);
self.active_tab = Some(id.clone());
self.pending_sftp_path_sync = Some("/".into());
self.connection_progress = Some(ConnectionProgress {
tab_id: id,
title: t!("connecting").into(),
lines: vec![t!("starting_connection").into()],
failed: false,
});
self.status = "ssh tab opened".into();
cx.notify();
}
@@ -415,100 +554,115 @@ impl Ashell {
cx.notify();
}
pub(crate) fn retry_connection_progress(&mut self, cx: &mut Context<Self>) {
let Some(progress) = self.connection_progress.clone() else {
/// Retry a single disconnected tab by its ID.
/// For SSH tabs: spawns a new SSH connection and restarts SFTP.
/// For local tabs: spawns a new local shell.
///
/// The existing `TerminalTab` (including its `term` scrollback history)
/// is preserved — only the backend is swapped via `set_backend()`.
pub(crate) fn retry_disconnected_tab(&mut self, tab_id: &str, cx: &mut Context<Self>) {
let Some(ix) = self.tabs.iter().position(|t| t.id == tab_id) else {
return;
};
self.connection_progress = None;
let mut groups_to_restart_sftp = std::collections::HashSet::new();
let mut retry_tabs = Vec::new();
for (ix, tab) in self.tabs.iter().enumerate() {
if !tab.connected && tab.session.is_some() {
retry_tabs.push((ix, tab.id.clone(), tab.session.clone().unwrap()));
}
}
if retry_tabs.is_empty() {
cx.notify();
if self.tabs[ix].connected || self.tabs[ix].disconnected_reason.is_none() {
return;
}
for (ix, tab_id, session) in retry_tabs {
// Close old backend
self.tabs[ix].backend.send(BackendCommand::Close);
let is_ssh = self.tabs[ix].session.is_some();
let session = self.tabs[ix].session.clone();
let new_generation = self.tabs[ix].backend_generation + 1;
let cols = self.tabs[ix].cols;
let rows = self.tabs[ix].rows;
// Spawn new backend
// Close old backend (sends Close through the shared Arc<Mutex>)
self.tabs[ix].send_backend(BackendCommand::Close);
if let Some(session) = session {
// SSH tab: spawn new SSH connection
let backend = ssh::spawn_ssh_terminal(
self.runtime.handle(),
tab_id.clone(),
tab_id.to_string(),
session.clone(),
DEFAULT_COLS,
DEFAULT_ROWS,
cols,
rows,
self.events_tx.clone(),
);
// Replace tab state in-place to reuse the UI component
self.tabs[ix] = TerminalTab::new_ssh(
tab_id.clone(),
&session,
backend,
self.events_tx.clone(),
);
// Swap the backend — the Term's internal listener shares the
// same Arc<Mutex<BackendTx>>, so user input is automatically
// routed to the new backend. Terminal history is preserved.
self.tabs[ix].set_backend(backend);
self.tabs[ix].connected = false;
self.tabs[ix].status = "connecting".into();
self.tabs[ix].disconnected_reason = None;
self.tabs[ix].backend_generation = new_generation;
self.tabs[ix].backend_initialized = false;
// Find group to restart SFTP
if let Some(group) = self.tab_groups.iter().find(|g| g.pane_root.contains(&tab_id)) {
groups_to_restart_sftp.insert(group.id.clone());
}
}
// Restart SFTP for the group containing this tab
if let Some(group) = self
.tab_groups
.iter()
.find(|g| g.pane_root.contains(tab_id))
{
let group_id = group.id.clone();
let group_session = self
.tabs
.iter()
.find(|t| group.pane_root.contains(&t.id) && t.session.is_some())
.and_then(|t| t.session.clone());
// Restart SFTP for affected groups
for group_id in groups_to_restart_sftp {
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) {
// Use the session of any tab in that group
let group_session = self.tabs.iter().find(|t| group.pane_root.contains(&t.id) && t.session.is_some()).and_then(|t| t.session.clone());
if let Some(session) = group_session {
if let Some(old_handle) = self.sftp_handles.remove(&group.id) {
if let Some(old_handle) = self.sftp_handles.remove(&group_id) {
old_handle.close();
}
let sftp_handle = crate::sftp::spawn_sftp(
self.runtime.handle(),
group.id.clone(),
group_id.clone(),
session,
self.events_tx.clone(),
);
self.sftp_handles.insert(group.id.clone(), sftp_handle);
if let Some(sftp) = group.sftp.as_mut() {
sftp.status = rust_i18n::t!("sftp_connecting").to_string();
self.sftp_handles.insert(group_id.clone(), sftp_handle);
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) {
if let Some(sftp) = group.sftp.as_mut() {
sftp.status = rust_i18n::t!("sftp_connecting").to_string();
}
}
}
}
} else {
// Local tab: spawn new local shell
match local::spawn_local_terminal(
tab_id.to_string(),
cols,
rows,
self.events_tx.clone(),
) {
Ok(backend) => {
// Swap the backend — preserves terminal history.
self.tabs[ix].set_backend(backend);
self.tabs[ix].connected = true;
self.tabs[ix].status = "local shell".into();
self.tabs[ix].disconnected_reason = None;
self.tabs[ix].backend_generation = new_generation;
self.tabs[ix].backend_initialized = false;
// Resize the new PTY to match the pane dimensions.
self.tabs[ix].send_backend(BackendCommand::Resize { cols, rows });
}
Err(err) => {
self.status = format!("failed to reopen local terminal: {err:#}").into();
cx.notify();
return;
}
}
}
self.connection_progress = Some(ConnectionProgress {
tab_id: progress.tab_id.clone(),
title: t!("connecting").into(),
lines: vec![t!("starting_connection").into()],
failed: false,
});
self.status = "ssh tabs retrying".into();
cx.notify();
}
pub(crate) fn cancel_connection_progress(&mut self, cx: &mut Context<Self>) {
if self.connection_progress.is_none() {
return;
}
self.connection_progress = None;
let tabs_to_close: Vec<_> = self.tabs.iter()
.filter(|tab| !tab.connected && tab.session.is_some())
.map(|tab| tab.id.clone())
.collect();
for id in tabs_to_close {
self.handle_tab_close(id);
self.status = if is_ssh {
"ssh tab retrying"
} else {
"local tab reopened"
}
.into();
cx.notify();
}
@@ -522,7 +676,10 @@ impl Ashell {
}
self.active_tab = Some(id.clone());
// Find which group this tab belongs to and restore its pane_root
let tab_group = self.tab_groups.iter_mut().find(|g| g.pane_root.contains(&id));
let tab_group = self
.tab_groups
.iter_mut()
.find(|g| g.pane_root.contains(&id));
if let Some(group) = tab_group {
self.pane_root = group.pane_root.clone();
self.active_group = Some(group.id.clone());
@@ -537,7 +694,12 @@ impl Ashell {
}
if self.tabs.iter().any(|t| t.id == id) {
if let Some(session_id) = self.active_session_id() {
if let Some(index) = self.config.sessions().iter().position(|s| s.id == session_id) {
if let Some(index) = self
.config
.sessions()
.iter()
.position(|s| s.id == session_id)
{
self.saved_scroll_handle.scroll_to_item(index);
}
}
@@ -553,12 +715,18 @@ impl Ashell {
}
pub(crate) fn handle_tab_close(&mut self, id: String) {
let group_ix = self.tab_groups.iter().position(|g| g.pane_root.contains(&id));
let group_ix = self
.tab_groups
.iter()
.position(|g| g.pane_root.contains(&id));
let Some(ref group) = group_ix.map(|i| self.tab_groups[i].clone()) else {
// Fallback: find and close individual tab
eprintln!("[handle_tab_close] no group found for tab '{}', closing individually", id);
tracing::info!(
"[handle_tab_close] no group found for tab '{}', closing individually",
id
);
if let Some(ix) = self.tabs.iter().position(|tab| tab.id == id) {
self.tabs[ix].backend.send(BackendCommand::Close);
self.tabs[ix].send_backend(BackendCommand::Close);
self.tabs.remove(ix);
}
return;
@@ -567,9 +735,11 @@ impl Ashell {
let pane_ids = group.pane_root.tab_ids();
let pane_ids_str: Vec<&str> = pane_ids.iter().map(|s| *s).collect();
let is_group_close = pane_ids.len() <= 1;
eprintln!(
tracing::info!(
"[handle_tab_close] id='{}' group_panes={:?} is_group_close={}",
id, pane_ids_str, is_group_close
id,
pane_ids_str,
is_group_close
);
let was_active = self.active_tab.as_deref() == Some(id.as_str());
@@ -588,19 +758,34 @@ impl Ashell {
let all_groups = &self.tab_groups;
if let Some(pos) = all_groups.iter().position(|g| g.id == group.id) {
if pos > 0 {
next_active_id = all_groups[pos - 1].pane_root.tab_ids().first().copied().map(String::from);
next_active_id = all_groups[pos - 1]
.pane_root
.tab_ids()
.first()
.copied()
.map(String::from);
} else if pos + 1 < all_groups.len() {
next_active_id = all_groups[pos + 1].pane_root.tab_ids().first().copied().map(String::from);
next_active_id = all_groups[pos + 1]
.pane_root
.tab_ids()
.first()
.copied()
.map(String::from);
}
}
}
}
if is_group_close {
// Close all tabs in the group
let tab_ids: Vec<String> = group.pane_root.tab_ids().iter().map(|s| s.to_string()).collect();
let tab_ids: Vec<String> = group
.pane_root
.tab_ids()
.iter()
.map(|s| s.to_string())
.collect();
for tab_id in &tab_ids {
if let Some(ix) = self.tabs.iter().position(|tab| tab.id == *tab_id) {
self.tabs[ix].backend.send(BackendCommand::Close);
self.tabs[ix].send_backend(BackendCommand::Close);
self.tabs.retain(|t| t.id != *tab_id);
}
}
@@ -612,10 +797,14 @@ impl Ashell {
} else {
// Just remove this tab from the group
if let Some(ix) = self.tabs.iter().position(|tab| tab.id == id) {
self.tabs[ix].backend.send(BackendCommand::Close);
self.tabs[ix].send_backend(BackendCommand::Close);
self.tabs.retain(|t| t.id != id);
}
if let Some(g) = self.tab_groups.iter_mut().find(|g| g.pane_root.contains(&id)) {
if let Some(g) = self
.tab_groups
.iter_mut()
.find(|g| g.pane_root.contains(&id))
{
g.pane_root.remove_tab(&id);
}
self.pane_root.remove_tab(&id);
@@ -640,9 +829,6 @@ impl Ashell {
return;
}
if was_active
|| self
.active_tab
@@ -651,12 +837,20 @@ impl Ashell {
{
// Activate next available pane
let new_id = next_active_id.or_else(|| {
self.pane_root.tab_ids().first().copied().map(String::from)
self.pane_root
.tab_ids()
.first()
.copied()
.map(String::from)
.or_else(|| self.tabs.first().map(|t| t.id.clone()))
});
if let Some(new_id) = new_id {
self.active_tab = Some(new_id.clone());
if let Some(g) = self.tab_groups.iter().find(|g| g.pane_root.contains(&new_id)) {
if let Some(g) = self
.tab_groups
.iter()
.find(|g| g.pane_root.contains(&new_id))
{
self.active_group = Some(g.id.clone());
self.pane_root = g.pane_root.clone();
}
@@ -677,6 +871,15 @@ impl Ashell {
window: &mut Window,
cx: &mut Context<Self>,
) {
// If the search bar is visible and the click is inside it, let the
// search bar handle the event instead of switching pane focus.
if self.search_active {
if let Some(bounds) = self.search_bar_bounds {
if bounds.contains(&event.position) {
return;
}
}
}
self.focus_handle.focus(window, cx);
// Check if click is in a different pane and focus it
let click_pos = event.position;
@@ -695,6 +898,18 @@ impl Ashell {
}
}
if event.button == MouseButton::Left {
if self.config.right_click_copy_paste() {
if let Some(text) = self.active_terminal_selection_text() {
if !text.is_empty() {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
if let Some(active_id) = &self.active_tab {
if let Some(tab) = self.tabs.iter_mut().find(|tab| &tab.id == active_id) {
tab.clear_selection();
}
}
}
}
}
self.begin_terminal_selection(event, cx);
}
cx.notify();
@@ -743,62 +958,12 @@ impl Ashell {
format!("{}@{}:{}", session.user, session.host, session.port)
}
pub(crate) fn sync_terminal_size(&mut self, window: &Window, cx: &App) {
let viewport = window.viewport_size();
let sidebar_width = self
.workspace_panels
.read(cx)
.sizes()
.first()
.map(|size| size.as_f32())
.unwrap_or(SIDEBAR_WIDTH);
let terminal_height = self
.body_panels
.read(cx)
.sizes()
.first()
.map(|size| size.as_f32())
.unwrap_or(viewport.height.as_f32() - TAB_BAR_HEIGHT - 248.0);
let width = (viewport.width.as_f32() - sidebar_width - TERMINAL_PADDING_X - 8.0)
.max(self.terminal_cell_width());
let height = (terminal_height - TERMINAL_PADDING_Y).max(self.terminal_line_height());
let total_cols = (width / self.terminal_cell_width()).floor().max(1.0) as u16;
let total_rows = (height / self.terminal_line_height()).floor().max(1.0) as u16;
Self::resize_pane_tree(&mut self.tabs, &self.pane_root, total_cols, total_rows);
}
fn resize_pane_tree(
tabs: &mut [TerminalTab],
layout: &PaneLayout,
cols: u16,
rows: u16,
) {
match layout {
PaneLayout::Single(id) => {
if let Some(tab) = tabs.iter_mut().find(|t| t.id == *id) {
tab.resize(cols.max(1), rows.max(1));
}
}
PaneLayout::Horizontal(children, _) => {
let n = children.len() as u16;
let each_rows = (rows / n).max(1);
for child in children {
Self::resize_pane_tree(tabs, child, cols, each_rows);
}
}
PaneLayout::Vertical(children, _) => {
let n = children.len() as u16;
let each_cols = (cols / n).max(1);
for child in children {
Self::resize_pane_tree(tabs, child, each_cols, rows);
}
}
}
}
pub(crate) fn split_current_pane(&mut self, direction: &str, cx: &mut Context<Self>) {
eprintln!(
tracing::info!(
"[split] direction={} pane_root={:?} focused_path={:?} active_tab={:?} tabs={}",
direction,
self.pane_root,
@@ -858,12 +1023,7 @@ impl Ashell {
self.events_tx.clone(),
);
self.sftp_handles.insert(new_id.clone(), sftp_handle);
TerminalTab::new_ssh(
new_id.clone(),
&session,
backend,
self.events_tx.clone(),
)
TerminalTab::new_ssh(new_id.clone(), &session, backend, self.events_tx.clone())
}
};
tab.resize(DEFAULT_COLS, DEFAULT_ROWS);
@@ -892,7 +1052,8 @@ impl Ashell {
_ => return,
};
self.pane_root.replace_at(&self.focused_pane_path, split_layout);
self.pane_root
.replace_at(&self.focused_pane_path, split_layout);
self.sync_pane_root_to_group();
// Update focused_pane_path: the new pane is at the indicated child index
let parent_path = self.focused_pane_path.clone();
@@ -905,7 +1066,7 @@ impl Ashell {
self.focused_pane_path = new_full_path;
self.active_tab = Some(new_id);
self.status = "pane split".into();
eprintln!(
tracing::info!(
"[split] DONE: pane_root={:?} focused_path={:?} active_tab={:?} tabs={}",
self.pane_root,
self.focused_pane_path,
@@ -923,7 +1084,16 @@ impl Ashell {
if let Some(new_path) = Self::find_adjacent_pane(&self.pane_root, &path, direction) {
self.focused_pane_path = new_path;
if let Some(id) = self.pane_root.focused_tab_id(&self.focused_pane_path) {
self.active_tab = Some(id.to_string());
let id_owned = id.to_string();
let changed = self.active_tab.as_deref() != Some(id_owned.as_str());
self.active_tab = Some(id_owned);
// Clear stale search state when switching to a different pane.
if changed && self.search_active {
self.search_query.clear();
self.search_matches.clear();
self.search_current = 0;
self.search_target_tab = None;
}
}
}
}
@@ -954,7 +1124,11 @@ impl Ashell {
}
}
fn find_adjacent_pane(layout: &PaneLayout, path: &[usize], direction: &str) -> Option<Vec<usize>> {
fn find_adjacent_pane(
layout: &PaneLayout,
path: &[usize],
direction: &str,
) -> Option<Vec<usize>> {
if path.is_empty() {
return None;
}
@@ -976,7 +1150,11 @@ impl Ashell {
if path.len() == 1 {
// Direct child level
if moves_in_this_split {
let delta: i32 = if direction == "up" || direction == "left" { -1 } else { 1 };
let delta: i32 = if direction == "up" || direction == "left" {
-1
} else {
1
};
let new_idx = idx as i32 + delta;
if new_idx >= 0 && (new_idx as usize) < children.len() {
let mut path = vec![new_idx as usize];
@@ -990,17 +1168,26 @@ impl Ashell {
}
} else {
// Recurse into child first
if let Some(mut child_path) = Self::find_adjacent_pane(&children[idx], &path[1..], direction) {
if let Some(mut child_path) =
Self::find_adjacent_pane(&children[idx], &path[1..], direction)
{
child_path.insert(0, idx);
Some(child_path)
} else if moves_in_this_split {
// Try sibling at this level
let delta: i32 = if direction == "up" || direction == "left" { -1 } else { 1 };
let delta: i32 = if direction == "up" || direction == "left" {
-1
} else {
1
};
let new_idx = idx as i32 + delta;
if new_idx >= 0 && (new_idx as usize) < children.len() {
let inner_idx = *path.get(1).unwrap_or(&0);
let mut path = vec![new_idx as usize];
path.extend(Self::leaf_at_index(&children[new_idx as usize], inner_idx));
path.extend(Self::leaf_at_index(
&children[new_idx as usize],
inner_idx,
));
Some(path)
} else {
None
@@ -1021,7 +1208,11 @@ impl Ashell {
) {
// Save current group state
if let Some(current_group_id) = self.active_group.clone() {
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == current_group_id) {
if let Some(group) = self
.tab_groups
.iter_mut()
.find(|g| g.id == current_group_id)
{
group.pane_root = self.pane_root.clone();
}
}
@@ -1064,7 +1255,8 @@ impl Ashell {
}
// Check if current system_tab_id is valid in this group
let is_current_valid = self.system_tab_id
let is_current_valid = self
.system_tab_id
.as_ref()
.map_or(false, |id| group_ssh_tabs.contains(id));
@@ -1140,15 +1332,20 @@ impl Ashell {
match (layout, path) {
(PaneLayout::Horizontal(_, _), []) => true,
(PaneLayout::Vertical(_, _), []) => false,
(PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _), [first, rest @ ..]) => {
children.get(*first).map_or(false, |c| Self::is_layout_horizontal_at(c, rest))
}
(
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _),
[first, rest @ ..],
) => children
.get(*first)
.map_or(false, |c| Self::is_layout_horizontal_at(c, rest)),
_ => false,
}
}
fn adjust_split_ratio(layout: &mut PaneLayout, path: &[usize], _child_idx: usize, delta: f32) {
if let PaneLayout::Horizontal(children, ratio) | PaneLayout::Vertical(children, ratio) = layout {
if let PaneLayout::Horizontal(children, ratio) | PaneLayout::Vertical(children, ratio) =
layout
{
if path.is_empty() {
*ratio = (*ratio + delta).clamp(0.1, 0.9);
} else {
@@ -1179,8 +1376,17 @@ impl Ashell {
}
let mut path = Vec::new();
if find_path(&self.pane_root, &tab_id, &mut path) {
let changed = self.active_tab.as_deref() != Some(tab_id.as_str());
self.focused_pane_path = path;
self.active_tab = Some(tab_id);
// Clear stale search state when switching to a different pane.
// The user can press Enter to re-search in the new pane.
if changed && self.search_active {
self.search_query.clear();
self.search_matches.clear();
self.search_current = 0;
self.search_target_tab = None;
}
}
}
}
+180 -42
View File
@@ -218,9 +218,13 @@ pub fn spawn_sftp(
.await
{
let _ = events.send(BackendEvent::SftpStatus {
tab_id,
tab_id: tab_id.clone(),
text: format!("sftp error: {err:#}"),
});
let _ = events.send(BackendEvent::Closed {
tab_id,
reason: format!("sftp error: {err:#}"),
});
}
});
SftpHandle {
@@ -258,7 +262,7 @@ async fn run_sftp(
.canonicalize(".")
.await
.unwrap_or_else(|_| "/".to_string());
let _ = events.send(BackendEvent::SftpHome {
tab_id: tab_id.clone(),
home: home.clone(),
@@ -379,16 +383,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,
});
}
}
@@ -480,16 +496,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!("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,
});
}
}
@@ -575,10 +603,13 @@ async fn run_sftp(
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() {
if commands_tx_clone
.send(SftpCommand::UploadEditedFile {
local_path: local_path.to_string_lossy().to_string(),
remote_path: remote_path.clone(),
})
.is_err()
{
break;
}
}
@@ -622,7 +653,11 @@ async fn run_sftp(
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),
text: format!(
"{} ({})",
t!("auto_saved_and_uploaded", base = base_name(&remote_path)),
now
),
});
}
Err(err) => {
@@ -643,13 +678,16 @@ async fn run_sftp(
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(),
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));
@@ -666,6 +704,7 @@ async fn run_sftp(
}
}
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(),
@@ -738,21 +777,27 @@ fn recursive_delete<'a>(
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_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}"))?;
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}"))?;
sftp.remove_file(&path)
.await
.with_context(|| format!("Failed to delete {path}"))?;
}
}
Ok(())
@@ -783,10 +828,13 @@ async fn connect_and_authenticate(
) -> Result<Arc<russh::client::Handle<SftpClientHandler>>> {
let config = Arc::new(client::Config {
inactivity_timeout: Some(std::time::Duration::from_secs(600)),
keepalive_interval: Some(std::time::Duration::from_secs(3)),
keepalive_max: 2,
..Default::default()
});
let addr = format!("{}:{}", session.host, session.port);
let mut handle = client::connect(config, addr.as_str(), SftpClientHandler)
let stream = crate::session::config::connect_proxy(session).await?;
let mut handle = client::connect_stream(config, stream, SftpClientHandler)
.await
.with_context(|| format!("connect {addr} failed"))?;
@@ -797,11 +845,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
}
};
@@ -827,6 +899,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();
@@ -837,14 +911,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())),
}
@@ -853,16 +927,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 {
@@ -1065,6 +1156,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
@@ -1158,7 +1255,15 @@ 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("."))
@@ -1264,6 +1369,12 @@ 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;
@@ -1316,9 +1427,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?;
}
@@ -1486,14 +1607,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) {
+80 -12
View File
@@ -8,8 +8,14 @@ use crate::{
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"];
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;
}
@@ -22,24 +28,29 @@ pub(crate) fn is_editable_text_file(filename: &str) -> bool {
impl Ashell {
pub(crate) fn active_sftp(&self) -> Option<&terminal::SftpUiState> {
self.active_group.as_ref()
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)
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()
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;
@@ -121,7 +132,11 @@ impl Ashell {
}
}
pub(crate) fn trigger_sftp_context_download(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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;
};
@@ -134,6 +149,7 @@ impl Ashell {
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();
@@ -158,7 +174,17 @@ impl Ashell {
match path_prompt.await {
Ok(Ok(Some(mut paths))) => {
if let Some(folder) = paths.pop() {
handle.download(remote_path, folder.to_string_lossy().to_string());
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)) => {
@@ -192,7 +218,17 @@ impl Ashell {
match path_prompt.await {
Ok(Ok(Some(mut paths))) => {
if let Some(file) = paths.pop() {
handle.upload_paths(vec![file.to_string_lossy().to_string()], remote_dir);
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)) => {
@@ -226,7 +262,17 @@ impl Ashell {
match path_prompt.await {
Ok(Ok(Some(mut paths))) => {
if let Some(folder) = paths.pop() {
handle.upload_paths(vec![folder.to_string_lossy().to_string()], remote_dir);
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)) => {
@@ -242,7 +288,12 @@ impl Ashell {
.detach();
}
pub(crate) fn toggle_sftp_entry(&mut self, path: String, checked: bool, cx: &mut Context<Self>) {
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);
@@ -267,7 +318,11 @@ impl Ashell {
}
}
pub(crate) fn download_selected_sftp_entries(&mut self, window: &mut Window, cx: &mut Context<Self>) {
pub(crate) fn download_selected_sftp_entries(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(sftp) = self.active_sftp() else {
return;
};
@@ -291,6 +346,11 @@ impl Ashell {
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,
@@ -302,6 +362,7 @@ impl Ashell {
if let Some(sftp_mut) = this.active_sftp_mut() {
sftp_mut.selected_entries.clear();
}
this.show_transfers_dialog = true;
cx.notify();
});
}
@@ -311,16 +372,23 @@ impl Ashell {
.detach();
}
pub(crate) fn upload_sftp_files_batch(&mut self, paths: Vec<String>, _cx: &mut Context<Self>) {
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();
}
}
}
+577
View File
@@ -0,0 +1,577 @@
use anyhow::{Context, Result, anyhow};
use argon2::Argon2;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use chacha20poly1305::{
XChaCha20Poly1305, XNonce,
aead::{Aead, KeyInit},
};
use hmac::{Hmac, Mac};
use rand::{RngCore, rngs::OsRng};
use reqwest::{Client, StatusCode, header};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::session::config::Session;
const SYNC_FILE_NAME: &str = "ashell-sync.json";
const FORMAT_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncPayload {
pub schema_version: u32,
pub revision: String,
pub updated_at: String,
pub device_id: String,
pub sessions: Vec<Session>,
}
impl SyncPayload {
pub fn new(device_id: String, sessions: Vec<Session>) -> Self {
Self {
schema_version: FORMAT_VERSION,
revision: Uuid::new_v4().to_string(),
updated_at: chrono::Utc::now().to_rfc3339(),
device_id,
sessions,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct EncryptedEnvelope {
format_version: u32,
kdf: String,
cipher: String,
salt: String,
nonce: String,
payload: String,
}
#[derive(Clone)]
pub struct SyncCredentials {
pub backend: SyncBackendCredentials,
pub encryption_password: String,
}
#[derive(Clone)]
pub enum SyncBackendCredentials {
WebDav {
endpoint: String,
username: String,
password: String,
},
S3 {
endpoint: String,
region: String,
bucket: String,
object_key: String,
access_key: String,
secret_key: String,
session_token: String,
},
}
#[derive(Debug, Clone)]
pub enum SyncResult {
Uploaded {
etag: Option<String>,
},
Downloaded {
payload: SyncPayload,
etag: Option<String>,
},
Failed(String),
}
pub async fn upload(
credentials: SyncCredentials,
payload: SyncPayload,
expected_etag: Option<String>,
) -> Result<Option<String>> {
validate_credentials(&credentials)?;
let body = encrypt_payload(&payload, &credentials.encryption_password)?;
match credentials.backend {
SyncBackendCredentials::WebDav {
endpoint,
username,
password,
} => upload_webdav(&endpoint, &username, &password, body, expected_etag).await,
SyncBackendCredentials::S3 {
endpoint,
region,
bucket,
object_key,
access_key,
secret_key,
session_token,
} => {
let config = S3Config {
endpoint,
region,
bucket,
object_key,
access_key,
secret_key,
session_token,
};
upload_s3(&config, body, expected_etag).await
}
}
}
async fn upload_webdav(
endpoint: &str,
username: &str,
password: &str,
body: Vec<u8>,
expected_etag: Option<String>,
) -> Result<Option<String>> {
let client = Client::new();
let mut request = client
.put(sync_url(endpoint))
.basic_auth(username, Some(password))
.header(header::CONTENT_TYPE, "application/json")
.body(body);
request = if let Some(etag) = expected_etag {
request.header(header::IF_MATCH, etag)
} else {
// An uninitialized client may only create a new remote file. This keeps
// it from silently replacing configuration uploaded by another device.
request.header(header::IF_NONE_MATCH, "*")
};
let response = request.send().await.context("send WebDAV upload")?;
if response.status() == StatusCode::PRECONDITION_FAILED
|| response.status() == StatusCode::CONFLICT
{
return Err(anyhow!(
"remote configuration changed; download it before uploading"
));
}
if !response.status().is_success() {
return Err(anyhow!("WebDAV upload failed: HTTP {}", response.status()));
}
Ok(response
.headers()
.get(header::ETAG)
.and_then(|value| value.to_str().ok())
.map(str::to_string))
}
pub async fn download(credentials: SyncCredentials) -> Result<(SyncPayload, Option<String>)> {
validate_credentials(&credentials)?;
let encryption_password = credentials.encryption_password;
let (body, etag) = match credentials.backend {
SyncBackendCredentials::WebDav {
endpoint,
username,
password,
} => download_webdav(&endpoint, &username, &password).await?,
SyncBackendCredentials::S3 {
endpoint,
region,
bucket,
object_key,
access_key,
secret_key,
session_token,
} => {
let config = S3Config {
endpoint,
region,
bucket,
object_key,
access_key,
secret_key,
session_token,
};
download_s3(&config).await?
}
};
let payload = decrypt_payload(&body, &encryption_password)?;
Ok((payload, etag))
}
async fn download_webdav(
endpoint: &str,
username: &str,
password: &str,
) -> Result<(Vec<u8>, Option<String>)> {
let response = Client::new()
.get(sync_url(endpoint))
.basic_auth(username, Some(password))
.send()
.await
.context("send WebDAV download")?;
if response.status() == StatusCode::NOT_FOUND {
return Err(anyhow!("no remote configuration exists yet"));
}
if !response.status().is_success() {
return Err(anyhow!(
"WebDAV download failed: HTTP {}",
response.status()
));
}
let etag = response
.headers()
.get(header::ETAG)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let body = response
.bytes()
.await
.context("read WebDAV response")?
.to_vec();
Ok((body, etag))
}
fn validate_credentials(credentials: &SyncCredentials) -> Result<()> {
if credentials.encryption_password.len() < 8 {
return Err(anyhow!(
"encryption password must contain at least 8 characters"
));
}
match &credentials.backend {
SyncBackendCredentials::WebDav { endpoint, .. } if endpoint.trim().is_empty() => {
Err(anyhow!("WebDAV endpoint is required"))
}
SyncBackendCredentials::S3 {
region,
bucket,
access_key,
secret_key,
..
} if region.trim().is_empty()
|| bucket.trim().is_empty()
|| access_key.trim().is_empty()
|| secret_key.is_empty() =>
{
Err(anyhow!(
"S3 region, bucket, access key and secret key are required"
))
}
_ => Ok(()),
}
}
struct S3Config {
endpoint: String,
region: String,
bucket: String,
object_key: String,
access_key: String,
secret_key: String,
session_token: String,
}
async fn upload_s3(
config: &S3Config,
body: Vec<u8>,
expected_etag: Option<String>,
) -> Result<Option<String>> {
let url = s3_url(config)?;
let mut headers = signed_s3_headers("PUT", &url, &body, config)?;
headers.insert(
header::CONTENT_TYPE,
header::HeaderValue::from_static("application/json"),
);
if let Some(etag) = expected_etag {
headers.insert(header::IF_MATCH, header_value(&etag, "S3 ETag")?);
} else {
headers.insert(header::IF_NONE_MATCH, header::HeaderValue::from_static("*"));
}
let response = Client::new()
.put(url)
.headers(headers)
.body(body)
.send()
.await
.context("send S3 upload")?;
if response.status() == StatusCode::PRECONDITION_FAILED
|| response.status() == StatusCode::CONFLICT
{
return Err(anyhow!(
"remote configuration changed; download it before uploading"
));
}
if !response.status().is_success() {
let status = response.status();
let detail = response.text().await.unwrap_or_default();
return Err(anyhow!("S3 upload failed: HTTP {status}: {detail}"));
}
Ok(response
.headers()
.get(header::ETAG)
.and_then(|value| value.to_str().ok())
.map(str::to_string))
}
async fn download_s3(config: &S3Config) -> Result<(Vec<u8>, Option<String>)> {
let url = s3_url(config)?;
let headers = signed_s3_headers("GET", &url, &[], config)?;
let response = Client::new()
.get(url)
.headers(headers)
.send()
.await
.context("send S3 download")?;
if response.status() == StatusCode::NOT_FOUND {
return Err(anyhow!("no remote configuration exists yet"));
}
if !response.status().is_success() {
let status = response.status();
let detail = response.text().await.unwrap_or_default();
return Err(anyhow!("S3 download failed: HTTP {status}: {detail}"));
}
let etag = response
.headers()
.get(header::ETAG)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let body = response.bytes().await.context("read S3 response")?.to_vec();
Ok((body, etag))
}
fn s3_url(config: &S3Config) -> Result<reqwest::Url> {
let endpoint = if config.endpoint.trim().is_empty() {
format!("https://s3.{}.amazonaws.com", config.region.trim())
} else {
config.endpoint.trim().trim_end_matches('/').to_string()
};
let key = if config.object_key.trim().is_empty() {
SYNC_FILE_NAME
} else {
config.object_key.trim().trim_start_matches('/')
};
let url = format!(
"{}/{}/{}",
endpoint,
aws_uri_encode(config.bucket.trim(), true),
aws_uri_encode(key, false)
);
reqwest::Url::parse(&url).context("parse S3 object URL")
}
fn signed_s3_headers(
method: &str,
url: &reqwest::Url,
body: &[u8],
config: &S3Config,
) -> Result<header::HeaderMap> {
let now = chrono::Utc::now();
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
let date = now.format("%Y%m%d").to_string();
let host = url
.host_str()
.ok_or_else(|| anyhow!("S3 endpoint has no host"))?;
let host = match url.port() {
Some(port) => format!("{host}:{port}"),
None => host.to_string(),
};
let payload_hash = hex_sha256(body);
let mut canonical_headers =
format!("host:{host}\nx-amz-content-sha256:{payload_hash}\nx-amz-date:{amz_date}\n");
let mut signed_headers = "host;x-amz-content-sha256;x-amz-date".to_string();
if !config.session_token.is_empty() {
canonical_headers.push_str(&format!(
"x-amz-security-token:{}\n",
config.session_token.trim()
));
signed_headers.push_str(";x-amz-security-token");
}
let canonical_request = format!(
"{method}\n{}\n\n{canonical_headers}\n{signed_headers}\n{payload_hash}",
url.path()
);
let scope = format!("{date}/{}/s3/aws4_request", config.region.trim());
let string_to_sign = format!(
"AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{}",
hex_sha256(canonical_request.as_bytes())
);
let date_key = hmac_sha256(
format!("AWS4{}", config.secret_key).as_bytes(),
date.as_bytes(),
)?;
let region_key = hmac_sha256(&date_key, config.region.trim().as_bytes())?;
let service_key = hmac_sha256(&region_key, b"s3")?;
let signing_key = hmac_sha256(&service_key, b"aws4_request")?;
let signature = hex::encode(hmac_sha256(&signing_key, string_to_sign.as_bytes())?);
let authorization = format!(
"AWS4-HMAC-SHA256 Credential={}/{scope}, SignedHeaders={signed_headers}, Signature={signature}",
config.access_key.trim()
);
let mut headers = header::HeaderMap::new();
headers.insert("x-amz-date", header_value(&amz_date, "S3 date")?);
headers.insert(
"x-amz-content-sha256",
header_value(&payload_hash, "S3 payload hash")?,
);
headers.insert(
header::AUTHORIZATION,
header_value(&authorization, "S3 authorization")?,
);
if !config.session_token.is_empty() {
headers.insert(
"x-amz-security-token",
header_value(config.session_token.trim(), "S3 session token")?,
);
}
Ok(headers)
}
fn header_value(value: &str, name: &str) -> Result<header::HeaderValue> {
header::HeaderValue::from_str(value).with_context(|| format!("invalid {name}"))
}
fn hmac_sha256(key: &[u8], value: &[u8]) -> Result<Vec<u8>> {
let mut mac =
<Hmac<Sha256> as Mac>::new_from_slice(key).map_err(|_| anyhow!("initialize S3 signer"))?;
mac.update(value);
Ok(mac.finalize().into_bytes().to_vec())
}
fn hex_sha256(value: &[u8]) -> String {
hex::encode(Sha256::digest(value))
}
fn aws_uri_encode(value: &str, encode_slash: bool) -> String {
let mut encoded = String::new();
for byte in value.bytes() {
if byte.is_ascii_alphanumeric()
|| matches!(byte, b'-' | b'_' | b'.' | b'~')
|| (!encode_slash && byte == b'/')
{
encoded.push(byte as char);
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
fn sync_url(endpoint: &str) -> String {
let endpoint = endpoint.trim();
if endpoint.ends_with('/') {
format!("{endpoint}{SYNC_FILE_NAME}")
} else if endpoint.ends_with(".json") {
endpoint.to_string()
} else {
format!("{endpoint}/{SYNC_FILE_NAME}")
}
}
fn encrypt_payload(payload: &SyncPayload, password: &str) -> Result<Vec<u8>> {
let mut salt = [0u8; 16];
let mut nonce = [0u8; 24];
OsRng.fill_bytes(&mut salt);
OsRng.fill_bytes(&mut nonce);
let key = derive_key(password, &salt)?;
let plaintext = serde_json::to_vec(payload).context("serialize sync payload")?;
let ciphertext = XChaCha20Poly1305::new((&key).into())
.encrypt(XNonce::from_slice(&nonce), plaintext.as_ref())
.map_err(|_| anyhow!("encrypt sync payload"))?;
serde_json::to_vec_pretty(&EncryptedEnvelope {
format_version: FORMAT_VERSION,
kdf: "argon2id".to_string(),
cipher: "xchacha20poly1305".to_string(),
salt: STANDARD.encode(salt),
nonce: STANDARD.encode(nonce),
payload: STANDARD.encode(ciphertext),
})
.context("serialize encrypted sync envelope")
}
fn decrypt_payload(raw: &[u8], password: &str) -> Result<SyncPayload> {
let envelope: EncryptedEnvelope =
serde_json::from_slice(raw).context("parse encrypted sync envelope")?;
if envelope.format_version != FORMAT_VERSION
|| envelope.kdf != "argon2id"
|| envelope.cipher != "xchacha20poly1305"
{
return Err(anyhow!("unsupported remote sync format"));
}
let salt = STANDARD.decode(envelope.salt).context("decode sync salt")?;
let nonce = STANDARD
.decode(envelope.nonce)
.context("decode sync nonce")?;
if nonce.len() != 24 {
return Err(anyhow!("invalid sync nonce"));
}
let ciphertext = STANDARD
.decode(envelope.payload)
.context("decode encrypted sync payload")?;
let key = derive_key(password, &salt)?;
let plaintext = XChaCha20Poly1305::new((&key).into())
.decrypt(XNonce::from_slice(&nonce), ciphertext.as_ref())
.map_err(|_| anyhow!("cannot decrypt remote configuration; check the password"))?;
let payload: SyncPayload =
serde_json::from_slice(&plaintext).context("parse decrypted sync payload")?;
if payload.schema_version != FORMAT_VERSION {
return Err(anyhow!("unsupported synchronized configuration version"));
}
Ok(payload)
}
fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; 32]> {
let mut key = [0u8; 32];
Argon2::default()
.hash_password_into(password.as_bytes(), salt, &mut key)
.map_err(|err| anyhow!("derive encryption key: {err}"))?;
Ok(key)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encrypted_payload_round_trip() {
let payload = SyncPayload::new("test-device".into(), Vec::new());
let encrypted = encrypt_payload(&payload, "correct horse battery staple").unwrap();
assert!(!String::from_utf8_lossy(&encrypted).contains("test-device"));
let decrypted = decrypt_payload(&encrypted, "correct horse battery staple").unwrap();
assert_eq!(decrypted.revision, payload.revision);
}
#[test]
fn wrong_password_is_rejected() {
let payload = SyncPayload::new("test-device".into(), Vec::new());
let encrypted = encrypt_payload(&payload, "correct horse battery staple").unwrap();
assert!(decrypt_payload(&encrypted, "incorrect password").is_err());
}
#[test]
fn endpoint_can_be_a_collection_or_file() {
assert_eq!(
sync_url("https://example.test/dav/"),
"https://example.test/dav/ashell-sync.json"
);
assert_eq!(
sync_url("https://example.test/config.json"),
"https://example.test/config.json"
);
}
#[test]
fn s3_url_uses_path_style_and_encodes_object_key() {
let config = S3Config {
endpoint: "https://s3.example.test".into(),
region: "us-east-1".into(),
bucket: "my-bucket".into(),
object_key: "configs/my file.json".into(),
access_key: "access".into(),
secret_key: "secret".into(),
session_token: String::new(),
};
assert_eq!(
s3_url(&config).unwrap().as_str(),
"https://s3.example.test/my-bucket/configs/my%20file.json"
);
}
#[test]
fn aws_uri_encoding_preserves_only_object_key_slashes() {
assert_eq!(aws_uri_encode("a b/c", false), "a%20b/c");
assert_eq!(aws_uri_encode("a/b", true), "a%2Fb");
}
}
+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
}
+155 -21
View File
@@ -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)]
@@ -131,6 +132,8 @@ pub struct TerminalElement {
font_size: Pixels,
line_height: Pixels,
cell_width: Pixels,
tab_id: String,
search_highlights: Option<std::collections::HashMap<(i32, i32), Hsla>>,
}
pub struct PrepaintState {
@@ -138,9 +141,19 @@ 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>,
@@ -219,9 +232,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.cell_width, self.line_height)
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(
@@ -256,6 +272,8 @@ impl TerminalElement {
font_size: Pixels,
line_height: Pixels,
cell_width: Pixels,
tab_id: String,
search_highlights: Option<std::collections::HashMap<(i32, i32), Hsla>>,
) -> Self {
Self {
view,
@@ -266,6 +284,8 @@ impl TerminalElement {
font_size,
line_height,
cell_width,
tab_id,
search_highlights,
}
}
@@ -331,11 +351,21 @@ 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;
// Retrieve cached keyword highlights and merge with search highlights
let mut highlights = self.snapshot.highlights.clone();
if let Some(sm) = self.search_highlights.as_ref() {
highlights.extend(sm.iter().map(|(k, v)| (*k, *v)));
}
for render_cell in &self.snapshot.cells {
let cell = &render_cell.cell;
if cell.flags.intersects(
@@ -348,11 +378,15 @@ impl TerminalElement {
selection_contains(selection, render_cell.row, render_cell.col)
});
let bg = color_to_hsla(cell.bg, false, cx);
if selected || !is_default_bg(cell.bg) {
if selected || !is_default_bg(cell.bg) || cell.flags.contains(Flags::INVERSE) {
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) {
@@ -370,7 +404,34 @@ impl TerminalElement {
continue;
}
let style = self.cell_run_style(cell, cx);
let mut style = self.cell_run_style(cell, cx);
// Apply keyword highlight color if this cell was matched.
if let Some(&hl_color) = highlights.get(&(render_cell.row, render_cell.col)) {
style.color = hl_color;
}
// 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());
@@ -402,15 +463,39 @@ impl TerminalElement {
runs.push(run);
}
(merge_rects(rects), runs)
(merge_rects(rects), runs, custom_blocks)
}
fn cursor_layout(&self, cx: &App) -> Option<CursorLayout> {
self.snapshot.cursor.map(|cursor| CursorLayout {
row: cursor.row,
col: cursor.col,
shape: cursor.shape,
color: cx.theme().primary,
use crate::session::config::CursorStyle;
let cursor_style = self.view.read(cx).cursor_style;
let show_cursor = match cursor_style {
CursorStyle::Blink | CursorStyle::BeamBlink => {
if let Ok(duration) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
(duration.as_millis() / 600) % 2 == 0
} else {
true
}
}
_ => true,
};
self.snapshot.cursor.map(|cursor| {
let mut shape = match cursor_style {
CursorStyle::Default => cursor.shape,
CursorStyle::Blink => CursorShape::Block,
CursorStyle::Beam => CursorShape::Beam,
CursorStyle::BeamBlink => CursorShape::Beam,
};
if !show_cursor {
shape = CursorShape::Hidden;
}
CursorLayout {
row: cursor.row,
col: cursor.col,
shape,
color: cx.theme().primary,
}
})
}
}
@@ -458,7 +543,33 @@ 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);
// Save the precise GPUI-rendered bounds of this terminal element.
// This is 100% accurate because it is recorded during layout prepaint.
let view = self.view.clone();
let tab_id = self.tab_id.clone();
let _ = view.update(cx, |this, cx| {
let old_bounds = this.terminal_bounds.insert(tab_id.clone(), bounds);
// Sync PTY size unconditionally on every prepaint layout pass to ensure
// absolute synchronization with GPUI layout regardless of intermediate events.
let line_height = this.terminal_line_height();
let cell_width = this.terminal_cell_width();
let w = bounds.size.width.as_f32();
let h = bounds.size.height.as_f32();
let cols = (w / cell_width).floor().max(1.0) as u16;
let rows = (h / line_height).floor().max(1.0) as u16;
if let Some(tab) = this.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.resize(cols, rows);
}
if old_bounds != Some(bounds) {
cx.notify();
}
});
PrepaintState {
bounds,
metrics: TerminalMetrics {
@@ -467,6 +578,7 @@ impl Element for TerminalElement {
},
rects,
runs,
custom_blocks,
cursor: self.cursor_layout(cx),
}
}
@@ -481,19 +593,41 @@ impl Element for TerminalElement {
window: &mut Window,
cx: &mut App,
) {
// Compute a vertical offset to center the text grid vertically,
// distributing the leftover pixel remainder evenly to the top and bottom.
let grid_height = prepaint.metrics.line_height * (prepaint.bounds.size.height.as_f32() / prepaint.metrics.line_height.as_f32()).floor();
let y_offset = ((prepaint.bounds.size.height - grid_height) / 2.0).max(px(0.0));
let draw_origin = point(prepaint.bounds.origin.x, prepaint.bounds.origin.y + y_offset);
for rect in &prepaint.rects {
rect.paint(prepaint.bounds.origin, prepaint.metrics, window);
rect.paint(draw_origin, prepaint.metrics, window);
}
for run in &prepaint.runs {
run.paint(prepaint.bounds.origin, prepaint.metrics, window, cx);
run.paint(draw_origin, prepaint.metrics, window, cx);
}
for block in &prepaint.custom_blocks {
let x = draw_origin.x.as_f32()
+ block.col as f32 * prepaint.metrics.cell_width.as_f32();
let y = draw_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,
element_bounds: Bounds::new(draw_origin, prepaint.bounds.size),
cell_width: prepaint.metrics.cell_width.as_f32(),
line_height: prepaint.metrics.line_height.as_f32(),
},
@@ -503,8 +637,8 @@ impl Element for TerminalElement {
if let Some(marked_text) = self.marked_text.as_ref().filter(|text| !text.is_empty()) {
if let Some(cursor) = prepaint.cursor {
let pos = point(
prepaint.bounds.origin.x + prepaint.metrics.cell_width * cursor.col as f32,
prepaint.bounds.origin.y + prepaint.metrics.line_height * cursor.row as f32,
draw_origin.x + prepaint.metrics.cell_width * cursor.col as f32,
draw_origin.y + prepaint.metrics.line_height * cursor.row as f32,
);
let mut base_style = self.base_text_style(cx);
base_style.underline = Some(UnderlineStyle {
@@ -551,8 +685,8 @@ impl Element for TerminalElement {
{
return;
}
let x = prepaint.bounds.origin.x + prepaint.metrics.cell_width * cursor.col as f32;
let y = prepaint.bounds.origin.y + prepaint.metrics.line_height * cursor.row as f32;
let x = draw_origin.x + prepaint.metrics.cell_width * cursor.col as f32;
let y = draw_origin.y + prepaint.metrics.line_height * cursor.row as f32;
match cursor.shape {
CursorShape::Hidden => {}
CursorShape::Beam => {
+632
View File
@@ -0,0 +1,632 @@
use std::collections::HashMap;
use gpui::Hsla;
use crate::terminal::RenderCell;
trait HslaExt {
fn into_rgba_like(self, r: u8, g: u8, b: u8) -> Self;
}
impl HslaExt for Hsla {
fn into_rgba_like(self, r: u8, g: u8, b: u8) -> Self {
let rf = r as f32 / 255.0;
let gf = g as f32 / 255.0;
let bf = b as f32 / 255.0;
let max = rf.max(gf).max(bf);
let min = rf.min(gf).min(bf);
let l = (max + min) / 2.0;
if max == min {
return Hsla { h: 0.0, s: 0.0, l, a: 1.0 };
}
let d = max - min;
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
let h = if max == rf {
((gf - bf) / d + if gf < bf { 6.0 } else { 0.0 }) / 6.0
} else if max == gf {
((bf - rf) / d + 2.0) / 6.0
} else {
((rf - gf) / d + 4.0) / 6.0
};
Hsla { h, s, l, a: 1.0 }
}
}
#[derive(Debug, Clone)]
struct HighlightColors {
// Log levels
error: Hsla, // ERROR, ERR
critical: Hsla, // PANIC, FATAL, EMERGENCY, CRITICAL
warning: Hsla, // WARNING, WARN
info: Hsla, // INFO, NOTICE
debug: Hsla, // DEBUG, TRACE, DBG
alert: Hsla, // ALERT
// Status indicators
success: Hsla, // SUCCESS, OK, PASS, DONE, COMPLETED
failure: Hsla, // FAILED, FAIL, FAILURE
pending: Hsla, // PENDING, WAITING, PROCESSING
running: Hsla, // RUNNING, ACTIVE, EXECUTING
stopped: Hsla, // STOPPED, INACTIVE, HALTED, IDLE
skipped: Hsla, // SKIPPED, SKIP
// Network
network_up: Hsla, // UP, ONLINE, CONNECTED
network_down: Hsla, // DOWN, OFFLINE, UNREACHABLE
timeout: Hsla, // TIMEOUT, TIMED OUT
refused: Hsla, // REFUSED, REJECTED, DENIED
// Security & Auth
security: Hsla, // SSH, SSL, TLS, CERTIFICATE
auth: Hsla, // AUTHENTICATED, AUTHORIZED, LOGIN
danger: Hsla, // ROOT, SUDO, PASSWORD, SECRET
// Operations
started: Hsla, // START, BOOT, STARTING
stopped_op: Hsla, // STOP, SHUTDOWN, STOPPING
restart: Hsla, // RESTART, RESTARTING
deploy: Hsla, // DEPLOY, DEPLOYED, DEPLOYMENT
crashed: Hsla, // CRASH, CRASHED, SIGSEGV
// Resources
memory: Hsla, // MEMORY, RAM, SWAP, HEAP
cpu: Hsla, // CPU, PROCESSOR, CORE
disk: Hsla, // DISK, STORAGE, PARTITION, MOUNT
// HTTP codes
http_2xx: Hsla, // 200-299 Success
http_3xx: Hsla, // 300-399 Redirect
http_4xx: Hsla, // 400-499 Client Error
http_5xx: Hsla, // 500-599 Server Error
// Dev / Exceptions
exception: Hsla, // Exception, Traceback, Error type
deprecated: Hsla, // DEPRECATED, TODO, FIXME
// Existing
network: Hsla, // IP addresses
url: Hsla, // http://, https://
port: Hsla, // :22, :443, etc.
}
fn hsla(r: u8, g: u8, b: u8) -> Hsla {
Hsla {
h: 0.0,
s: 0.0,
l: 0.0,
a: 1.0,
}
.into_rgba_like(r, g, b)
}
fn highlight_colors() -> HighlightColors {
HighlightColors {
// Log levels
error: hsla(224, 96, 96), // #E06060 red
critical: hsla(255, 50, 50), // #FF3232 bright red
warning: hsla(232, 201, 122), // #E8C97A yellow
info: hsla(108, 180, 238), // #6CB4EE blue
debug: hsla(130, 140, 155), // #828C9B gray
alert: hsla(213, 126, 234), // #D57EEA bright magenta
// Status
success: hsla(126, 198, 153), // #7EC699 green
failure: hsla(232, 168, 124), // #E8A87C orange
pending: hsla(232, 201, 122), // #E8C97A yellow
running: hsla( 86, 206, 234), // #56CEEA cyan
stopped: hsla(160, 165, 175), // #A0A5AF gray
skipped: hsla(199, 146, 234), // #C792EA purple
// Network
network_up: hsla(126, 198, 153), // #7EC699 green
network_down: hsla(224, 96, 96), // #E06060 red
timeout: hsla(213, 126, 234), // #D57EEA magenta
refused: hsla(245, 160, 80), // #F5A050 orange
// Security
security: hsla( 86, 206, 234), // #56CEEA cyan
auth: hsla(126, 198, 153), // #7EC699 green
danger: hsla(180, 50, 50), // #B43232 dark red
// Operations
started: hsla(126, 198, 153), // #7EC699 green
stopped_op: hsla(224, 96, 96), // #E06060 red
restart: hsla(232, 201, 122), // #E8C97A yellow
deploy: hsla(100, 210, 140), // #64D28C bright green
crashed: hsla(255, 50, 50), // #FF3232 bright red
// Resources
memory: hsla(199, 146, 234), // #C792EA purple
cpu: hsla( 86, 206, 234), // #56CEEA cyan
disk: hsla(108, 180, 238), // #6CB4EE blue
// HTTP codes
http_2xx: hsla(126, 198, 153), // #7EC699 green
http_3xx: hsla( 86, 206, 234), // #56CEEA cyan
http_4xx: hsla(232, 201, 122), // #E8C97A yellow
http_5xx: hsla(224, 96, 96), // #E06060 red
// Dev
exception: hsla(224, 96, 96), // #E06060 red
deprecated: hsla(245, 160, 80), // #F5A050 orange
// Existing
network: hsla(199, 146, 234), // #C792EA purple
url: hsla( 86, 212, 199), // #56D4C7 teal
port: hsla(130, 170, 200), // #82AAC8 muted teal
}
}
/// Highlight all occurrences of keyword list in `text`, writing to `map`.
/// Case-insensitive, matches inside larger words (e.g. "my_ERROR" highlights "ERROR").
/// Each keyword only matches once per position (no overlapping highlights).
fn highlight_keywords(
map: &mut HashMap<(i32, i32), Hsla>,
text: &str,
byte_to_col: &[i32],
row_i32: i32,
keywords: &[&str],
color: Hsla,
) {
for &kw in keywords {
let kw_lower: Vec<u8> = kw.bytes().map(|b| b.to_ascii_lowercase()).collect();
let text_bytes = text.as_bytes();
let text_lower: Vec<u8> = text_bytes.iter().map(|b| b.to_ascii_lowercase()).collect();
let mut start = 0;
while start + kw_lower.len() <= text_lower.len() {
if text_lower[start..].starts_with(&kw_lower) {
let abs = start;
let start_col = byte_to_col[abs];
let end_col = byte_to_col[(abs + kw.len() - 1).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(color);
}
start = abs + kw.len();
} else {
start += 1;
}
}
}
}
/// Highlight HTTP status codes (200, 301, 404, 500, etc.)
/// Only matches specific common HTTP codes, not all 3-digit numbers.
fn highlight_http_codes(
map: &mut HashMap<(i32, i32), Hsla>,
text: &str,
byte_to_col: &[i32],
row_i32: i32,
colors: &HighlightColors,
) {
let bytes = text.as_bytes();
let len = bytes.len();
// Specific HTTP codes to highlight
const HTTP_CODES: &[(u16, bool)] = &[
// 2xx
(200, true), (201, true), (202, true), (204, true), (206, true),
// 3xx
(301, true), (302, true), (304, true), (307, true), (308, true),
// 4xx
(400, true), (401, true), (403, true), (404, true), (405, true),
(408, true), (409, true), (410, true), (422, true), (429, true),
// 5xx
(500, true), (502, true), (503, true), (504, true),
];
for i in 0..len.saturating_sub(2) {
if !bytes[i].is_ascii_digit() || !bytes[i + 1].is_ascii_digit() || !bytes[i + 2].is_ascii_digit() {
continue;
}
let code: u16 = ((bytes[i] - b'0') as u16) * 100
+ ((bytes[i + 1] - b'0') as u16) * 10
+ ((bytes[i + 2] - b'0') as u16);
// Only match specific codes
if !HTTP_CODES.iter().any(|&(c, _)| c == code) {
continue;
}
// Must be at a boundary (not part of a longer number)
let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
let after_ok = i + 3 >= len || !bytes[i + 3].is_ascii_digit();
if !before_ok || !after_ok {
continue;
}
let color = match code {
200..=299 => colors.http_2xx,
300..=399 => colors.http_3xx,
400..=499 => colors.http_4xx,
500..=599 => colors.http_5xx,
_ => continue,
};
let start_col = byte_to_col[i];
let end_col = byte_to_col[(i + 2).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(color);
}
}
}
pub fn highlight_cells(
cells: &[RenderCell],
rows: usize,
) -> HashMap<(i32, i32), Hsla> {
let colors = highlight_colors();
let mut row_chars: Vec<Vec<(i32, char)>> = vec![Vec::with_capacity(128); rows];
for rc in cells {
if rc.row < 0 || (rc.row as usize) >= rows {
continue;
}
row_chars[rc.row as usize].push((rc.col, rc.cell.c));
}
for row in row_chars.iter_mut() {
row.sort_by_key(|&(col, _)| col);
}
let mut map = HashMap::new();
let mut chars_buf = String::with_capacity(128);
let mut byte_to_col: Vec<i32> = Vec::with_capacity(128);
for (row_idx, row) in row_chars.iter().enumerate() {
if row.is_empty() {
continue;
}
let row_i32 = row_idx as i32;
chars_buf.clear();
byte_to_col.clear();
for &(col, c) in row {
chars_buf.push(c);
while byte_to_col.len() < chars_buf.len() {
byte_to_col.push(col);
}
}
let text = chars_buf.as_str();
// ── 1. Critical errors (highest priority) ──────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["PANIC", "EMERGENCY", "FATAL", "SEGFAULT", "CRITICAL",
"OOM", "OUT OF MEMORY", "KERNEL PANIC", "CORE DUMPED", "BUS ERROR"],
colors.critical,
);
// ── 2. Error keywords ──────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["ERROR", "ERR"], colors.error);
// ── 3. Alert ───────────────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["ALERT"], colors.alert);
// ── 4. Warning keywords ────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["WARNING", "WARN"], colors.warning);
// ── 5. Info keywords ───────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["INFO", "INFORMATION", "NOTICE"], colors.info);
// ── 6. Debug keywords ──────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["DEBUG", "DBG", "TRACE"], colors.debug);
// ── 7. Success status ──────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["SUCCESS", "SUCCEEDED", "SUCCESSFUL", "PASSED", "PASS",
"OK", "DONE", "COMPLETED", "FINISHED", "COMPLETE"],
colors.success,
);
// ── 8. Failure status ──────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["FAILED", "FAILURE", "FAIL", "NOT OK"],
colors.failure,
);
// ── 9. Pending / Waiting ───────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["PENDING", "WAITING", "PROCESSING", "IN PROGRESS", "QUEUED"],
colors.pending,
);
// ── 10. Running / Active ───────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["RUNNING", "ACTIVE", "EXECUTING", "IN_PROGRESS", "LIVE"],
colors.running,
);
// ── 11. Stopped / Inactive ─────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["STOPPED", "INACTIVE", "HALTED", "IDLE", "PAUSED"],
colors.stopped,
);
// ── 12. Skipped ────────────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["SKIPPED", "SKIP", "SKIPPING"], colors.skipped);
// ── 13. Network UP ─────────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["UP", "ONLINE", "CONNECTED", "REACHABLE", "LISTENING",
"ESTABLISHED", "LINK UP"],
colors.network_up,
);
// ── 14. Network DOWN ───────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["DOWN", "OFFLINE", "UNREACHABLE", "DISCONNECTED",
"NOT LISTENING", "LINK DOWN", "NO CARRIER"],
colors.network_down,
);
// ── 15. Timeout ────────────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["TIMEOUT", "TIMED OUT", "TIMEOUTS", "ETIMEDOUT", "SLOW", "LATENCY"],
colors.timeout,
);
// ── 16. Refused / Denied ───────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["REFUSED", "REJECTED", "DENIED", "PERMISSION DENIED",
"ACCESS DENIED", "FORBIDDEN", "BLOCKED", "DROP"],
colors.refused,
);
// ── 17. Security / Protocol ────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["SSH", "SSHD", "SSL", "TLS", "HTTPS", "CERTIFICATE", "CERT",
"FIREWALL", "IPTABLES", "ACL", "WAF"],
colors.security,
);
// ── 18. Authentication ─────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["AUTHENTICATED", "ACCEPTED", "AUTHORIZED", "LOGIN", "LOGOUT",
"LOGGED IN", "LOGGED OUT", "SESSION"],
colors.auth,
);
// ── 19. Danger (root/sudo/secrets) ─────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["ROOT", "SUDO", "UID=0", "PASSWORD", "SECRET", "TOKEN",
"API_KEY", "APIKEY", "PRIVATE KEY", "CREDENTIALS"],
colors.danger,
);
// ── 20. Operations: Start ──────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["STARTED", "START", "STARTING", "BOOT", "BOOTING", "LAUNCHED", "LAUNCH"],
colors.started,
);
// ── 21. Operations: Stop ───────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["STOPPED", "STOP", "STOPPING", "SHUTDOWN", "SHUTTING DOWN", "TERMINATED"],
colors.stopped_op,
);
// ── 22. Operations: Restart ────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["RESTARTED", "RESTART", "RESTARTING", "RELOAD", "RELOADED", "RELOADING"],
colors.restart,
);
// ── 23. Operations: Deploy ─────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["DEPLOYED", "DEPLOYMENT", "DEPLOYING", "DEPLOY",
"ROLLBACK", "ROLLED BACK", "ROLLING BACK", "UPGRADE", "UPGRADED"],
colors.deploy,
);
// ── 24. Operations: Crash ──────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["CRASH", "CRASHED", "CRASHING", "SIGSEGV", "SIGABRT", "SIGKILL",
"DIED", "EXITED", "EXIT CODE", "CORE DUMP"],
colors.crashed,
);
// ── 25. Resources: Memory ──────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["MEMORY", "RAM", "HEAP", "STACK", "SWAP", "MEM"],
colors.memory,
);
// ── 26. Resources: CPU ─────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["CPU", "PROCESSOR", "CORE", "CORES", "THREAD", "THREADS", "LOAD"],
colors.cpu,
);
// ── 27. Resources: Disk ────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["DISK", "STORAGE", "PARTITION", "MOUNT", "FILESYSTEM",
"INODE", "IOPS", "READ", "WRITE"],
colors.disk,
);
// ── 28. Dev: Exceptions ────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["EXCEPTION", "TRACEBACK", "THROW", "THROWN", "STACKTRACE",
"TYPEERROR", "VALUEERROR", "KEYERROR", "ATTRIBUTEERROR",
"INDEXERROR", "RUNTIMEERROR", "IOERROR", "OSERROR",
"NULLPOINTER", "NPE", "SEGFAULT"],
colors.exception,
);
// ── 29. Dev: Deprecated / TODO ─────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["DEPRECATED", "TODO", "FIXME", "HACK", "XXX", "WORKAROUND", "TEMPORARY"],
colors.deprecated,
);
// ── 30. HTTP status codes ──────────────────────────────
highlight_http_codes(&mut map, text, &byte_to_col, row_i32, &colors);
// ── 31. IP addresses ───────────────────────────────────
for m in find_ip_addresses(text) {
let ip_len = find_ip_len(&text[m..]);
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + ip_len - 1).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.network);
}
}
// ── 32. URLs ───────────────────────────────────────────
for m in find_urls(text) {
let url_len = find_url_len(&text[m..]);
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + url_len - 1).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.url);
}
}
// ── 33. Port numbers ───────────────────────────────────
for m in find_ports(text) {
let port_len = find_port_len(&text[m..]);
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + port_len - 1).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.port);
}
}
}
map
}
fn find_ip_len(text: &str) -> usize {
let bytes = text.as_bytes();
let mut dots = 0u8;
let mut digits = 0u8;
let mut len = 0usize;
for &b in bytes {
match b {
b'0'..=b'9' => {
digits += 1;
if digits > 3 {
return 0;
}
}
b'.' => {
if digits == 0 {
return 0;
}
dots += 1;
if dots > 3 {
return 0;
}
digits = 0;
}
_ => break, // Stop at first non-digit/non-dot (including '/')
}
len += 1;
}
if dots == 3 && digits > 0 {
len
} else {
0
}
}
fn find_ip_addresses(text: &str) -> Vec<usize> {
let mut positions = Vec::new();
let bytes = text.as_bytes();
let len = bytes.len();
for i in 0..len {
if bytes[i].is_ascii_digit()
&& (i == 0 || is_boundary(bytes[i - 1] as char))
{
let remaining = &text[i..];
let ip_len = find_ip_len(remaining);
if ip_len > 0 {
let ip_str = &remaining[..ip_len];
let valid = ip_str
.split('.')
.all(|octet| octet.parse::<u8>().is_ok());
if valid {
positions.push(i);
}
}
}
}
positions
}
fn is_boundary(c: char) -> bool {
!c.is_ascii_alphanumeric() && c != '_'
}
fn find_urls(text: &str) -> Vec<usize> {
let mut positions = Vec::new();
let mut start = 0;
while let Some(pos) = text[start..].find("http") {
let abs = start + pos;
let remaining = &text[abs..];
if remaining.starts_with("https://") || remaining.starts_with("http://") {
if abs == 0 || is_boundary(text.as_bytes()[abs - 1] as char) {
positions.push(abs);
}
}
start = abs + 4;
}
positions
}
fn find_url_len(text: &str) -> usize {
text.find(|c: char| c.is_ascii_whitespace())
.unwrap_or(text.len())
}
fn find_ports(text: &str) -> Vec<usize> {
let mut positions = Vec::new();
let bytes = text.as_bytes();
let len = bytes.len();
for i in 0..len {
if bytes[i] == b':'
&& i + 1 < len
&& bytes[i + 1].is_ascii_digit()
&& (i == 0 || is_boundary(bytes[i - 1] as char) || bytes[i - 1] == b' ')
{
let mut j = i + 1;
while j < len && bytes[j].is_ascii_digit() {
j += 1;
}
let port_str = &text[i + 1..j];
if let Ok(port) = port_str.parse::<u16>() {
if port > 0 {
let after_ok = j >= len || is_boundary(bytes[j] as char);
if after_ok {
positions.push(i);
}
}
}
}
}
positions
}
fn find_port_len(text: &str) -> usize {
if !text.starts_with(':') {
return 0;
}
let mut len = 1;
for b in text.as_bytes()[1..].iter() {
if b.is_ascii_digit() {
len += 1;
} else {
break;
}
}
len
}
+202 -17
View File
@@ -3,16 +3,19 @@ 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,
ClipboardItem, Context, Focusable as _, KeyDownEvent, MouseButton, MouseDownEvent,
MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollDelta, ScrollWheelEvent, Window, px,
};
use crate::{
Ashell,
Ashell, TerminalBacktabKey, TerminalTabKey,
terminal::{BackendCommand, encode_key},
TerminalBacktabKey, TerminalTabKey,
};
thread_local! {
static LAST_DRAG_SCROLL: std::cell::Cell<Option<std::time::Instant>> = std::cell::Cell::new(None);
}
impl Ashell {
pub(crate) fn on_terminal_key_down(
&mut self,
@@ -20,6 +23,17 @@ impl Ashell {
window: &mut Window,
cx: &mut Context<Self>,
) {
// If the search input is focused, skip terminal key processing
// so the input can handle text entry, paste, etc. normally.
if self
.search_input
.read(cx)
.focus_handle(cx)
.is_focused(window)
{
return;
}
// Pane navigation: Alt + h/j/k/l
if event.keystroke.modifiers.alt
&& !event.keystroke.modifiers.shift
@@ -98,6 +112,38 @@ impl Ashell {
}
}
// If the active tab is disconnected and user presses Enter, reconnect
if event.keystroke.key == "enter"
&& !event.keystroke.modifiers.shift
&& !event.keystroke.modifiers.control
&& !event.keystroke.modifiers.alt
&& !event.keystroke.modifiers.platform
{
if let Some(progress) = &self.connection_progress {
if progress.failed {
self.retry_connection_progress(cx);
window.prevent_default();
cx.stop_propagation();
return;
}
}
let active_id = self.active_tab.clone();
if let Some(active_id) = active_id {
let is_disconnected = self
.tabs
.iter()
.find(|t| t.id == active_id)
.is_some_and(|tab| tab.disconnected_reason.is_some());
if is_disconnected {
self.retry_disconnected_tab(&active_id, cx);
window.prevent_default();
cx.stop_propagation();
return;
}
}
}
if event.prefer_character_input {
if let Some(text) = event.keystroke.key_char.as_deref() {
if !text.is_empty()
@@ -124,7 +170,7 @@ impl Ashell {
tab.clear_selection();
if let Some(bytes) = encode_key(&event.keystroke, tab.app_cursor_mode(), false) {
tab.backend.send(BackendCommand::Input(bytes));
tab.send_backend(BackendCommand::Input(bytes));
window.prevent_default();
cx.stop_propagation();
cx.notify();
@@ -162,13 +208,13 @@ impl Ashell {
}
tab.clear_selection();
tab.backend.send(BackendCommand::Input(bytes));
tab.send_backend(BackendCommand::Input(bytes));
window.prevent_default();
cx.stop_propagation();
cx.notify();
}
fn active_terminal_selection_text(&self) -> Option<String> {
pub(crate) fn active_terminal_selection_text(&self) -> Option<String> {
let active_id = self.active_tab.as_ref()?;
self.tabs
.iter()
@@ -176,7 +222,12 @@ impl Ashell {
.and_then(|tab| tab.selection_text())
}
fn paste_into_terminal(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
pub(crate) 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;
};
@@ -244,8 +295,7 @@ impl Ashell {
}
tab.clear_selection();
self.terminal_marked_text = None;
tab.backend
.send(BackendCommand::Input(text.as_bytes().to_vec()));
tab.send_backend(BackendCommand::Input(text.as_bytes().to_vec()));
window.invalidate_character_coordinates();
cx.notify();
}
@@ -287,7 +337,11 @@ impl Ashell {
}
}
pub(crate) fn begin_terminal_selection(&mut self, event: &MouseDownEvent, cx: &mut Context<Self>) {
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,
@@ -334,7 +388,54 @@ impl Ashell {
let Some(active_id) = self.active_tab.clone() else {
return;
};
let snapshot = match self.active_snapshot() {
Some(s) => s,
None => return,
};
let max_row = snapshot.rows.saturating_sub(1);
let mut scroll_delta = 0i32;
if max_row >= 6 {
if row <= 2 || row >= max_row.saturating_sub(2) {
let now = std::time::Instant::now();
let should_scroll = LAST_DRAG_SCROLL.with(|last| {
if let Some(last_time) = last.get() {
if now.duration_since(last_time) >= std::time::Duration::from_millis(80) {
last.set(Some(now));
true
} else {
false
}
} else {
last.set(Some(now));
true
}
});
if should_scroll {
if row == 0 {
scroll_delta = 2;
} else if row == 1 {
scroll_delta = 1;
} else if row == 2 {
scroll_delta = 1;
} else if row == max_row {
scroll_delta = -2;
} else if row == max_row.saturating_sub(1) {
scroll_delta = -1;
} else if row == max_row.saturating_sub(2) {
scroll_delta = -1;
}
}
} else {
LAST_DRAG_SCROLL.with(|last| last.set(None));
}
}
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
if scroll_delta != 0 {
tab.scroll_history(scroll_delta);
}
tab.update_selection(row, col, side);
cx.notify();
}
@@ -394,19 +495,103 @@ impl Ashell {
window: &mut Window,
cx: &mut Context<Self>,
) {
let delta_lines = match event.delta {
ScrollDelta::Lines(point) => point.y.round() as i32,
ScrollDelta::Pixels(point) => {
(point.y.as_f32() / self.terminal_line_height()).round() as i32
// Platform modifier (Cmd on macOS, Ctrl on Windows/Linux) + scroll → zoom terminal font size
if event.modifiers.platform {
let delta = match event.delta {
ScrollDelta::Lines(point) => point.y as f32 * 20.0,
ScrollDelta::Pixels(point) => point.y.as_f32(),
};
self.terminal_zoom_accumulator += delta;
let step = 20.0;
if self.terminal_zoom_accumulator.abs() >= step {
let zoom_steps = (self.terminal_zoom_accumulator / step).trunc();
self.terminal_zoom_accumulator -= zoom_steps * step;
self.change_terminal_font_size(zoom_steps * 0.5, cx);
}
};
if delta_lines == 0 {
window.prevent_default();
cx.stop_propagation();
return;
}
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.send_backend(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.send_backend(crate::terminal::BackendCommand::Input(bytes));
}
window.prevent_default();
cx.stop_propagation();
return;
}
tab.scroll_history(delta_lines);
window.prevent_default();
cx.stop_propagation();
+200 -28
View File
@@ -1,12 +1,14 @@
pub mod custom_blocks;
pub mod element;
pub mod input;
pub mod highlight;
use std::sync::mpsc::Sender;
use alacritty_terminal::{
event::{Event, EventListener},
grid::{Dimensions, Scroll},
index::{Column, Point, Side},
index::{Column, Line, Point, Side},
selection::{Selection, SelectionRange, SelectionType},
term::{Config, Term, TermMode, cell::Cell, point_to_viewport, viewport_to_point},
vte::ansi::{CursorShape, Processor},
@@ -27,6 +29,7 @@ pub enum TabKind {
pub enum BackendCommand {
Input(Vec<u8>),
Resize { cols: u16, rows: u16 },
SampleMetrics,
Close,
}
@@ -88,6 +91,7 @@ pub enum BackendEvent {
tab_id: String,
title: String,
},
SyncFinished(crate::sync::SyncResult),
}
#[derive(Clone)]
@@ -115,12 +119,22 @@ pub struct TerminalTab {
pub kind: TabKind,
pub status: String,
pub connected: bool,
pub disconnected_reason: Option<String>,
/// Incremented each time the tab is reconnected. Used to ignore stale
/// `BackendEvent::Closed` from the previous backend after a retry.
pub backend_generation: u32,
/// Set to `true` when the current backend sends its first `Output` or
/// `Connected` event. Used to skip stale `Closed` events that arrive
/// before the new backend has started producing output.
pub backend_initialized: bool,
pub session: Option<Session>,
processor: Processor,
term: Term<TerminalListener>,
cols: u16,
rows: u16,
pub backend: BackendTx,
pub cols: u16,
pub rows: u16,
pub backend: std::sync::Arc<std::sync::Mutex<BackendTx>>,
pub scroll_pixel_y: f32,
pub(crate) highlight_cache: std::cell::RefCell<Option<(Vec<RenderCell>, std::collections::HashMap<(i32, i32), gpui::Hsla>)>>,
}
#[derive(Clone, Copy)]
@@ -130,7 +144,7 @@ pub struct CursorState {
pub shape: CursorShape,
}
#[derive(Clone)]
#[derive(Clone, PartialEq)]
pub struct RenderCell {
pub row: i32,
pub col: i32,
@@ -146,6 +160,7 @@ pub struct RenderSnapshot {
pub history_size: usize,
pub rows: usize,
pub cols: usize,
pub highlights: std::collections::HashMap<(i32, i32), gpui::Hsla>,
}
#[derive(Clone, Copy)]
@@ -215,18 +230,24 @@ impl TerminalTab {
backend: BackendTx,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Self {
let shared_backend = std::sync::Arc::new(std::sync::Mutex::new(backend));
Self {
id: id.clone(),
title,
kind,
status,
connected: matches!(kind, TabKind::Local),
disconnected_reason: None,
backend_generation: 0,
backend_initialized: true,
session: None,
processor: Processor::new(),
term: new_term(100, 30, backend.clone(), id, events),
term: new_term(100, 30, shared_backend.clone(), id, events),
cols: 100,
rows: 30,
backend,
backend: shared_backend,
scroll_pixel_y: 0.0,
highlight_cache: std::cell::RefCell::new(None),
}
}
@@ -234,11 +255,36 @@ impl TerminalTab {
self.processor.advance(&mut self.term, bytes);
}
/// Send a command to the backend. Thread-safe via the shared Arc<Mutex>.
pub fn send_backend(&self, command: BackendCommand) {
if let Ok(backend) = self.backend.lock() {
backend.send(command);
}
}
/// Replace the backend with a new one. The `Term`'s internal listener
/// shares the same `Arc`, so user input is automatically routed to the
/// new backend. The old backend must be closed by the caller.
pub fn set_backend(&self, new_backend: BackendTx) {
if let Ok(mut backend) = self.backend.lock() {
*backend = new_backend;
}
}
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.send_backend(BackendCommand::Resize { cols, rows });
}
}
pub fn cursor_state(&self) -> Option<CursorState> {
@@ -295,17 +341,72 @@ impl TerminalTab {
});
}
// Get highlights from cache or recompute, only if keyword_highlight is enabled.
let is_enabled = crate::session::config::ConfigStore::load()
.map(|c| c.keyword_highlight())
.unwrap_or(false);
let highlights = if is_enabled {
let mut cache = self.highlight_cache.borrow_mut();
let cache_valid = cache.as_ref().is_some_and(|(cached_cells, _)| {
cached_cells == &cells
});
if cache_valid {
cache.as_ref().unwrap().1.clone()
} else {
let computed = self::highlight::highlight_cells(&cells, rows as usize);
*cache = Some((cells.clone(), computed.clone()));
computed
}
} else {
std::collections::HashMap::new()
};
RenderSnapshot {
cells,
cursor: self.cursor_state(),
selection: viewport_selection_from_range(content.display_offset, &content.selection),
selection: viewport_selection_from_range(
content.display_offset,
self.rows as usize,
self.cols as usize,
&content.selection,
),
display_offset: content.display_offset,
history_size: self.term.grid().history_size(),
rows: self.rows as usize,
cols: self.cols as usize,
highlights,
}
}
/// Return `(grid_line_base, rows_data)` for the **entire** terminal buffer
/// including scrollback history. `grid_line_base` is the grid line index of
/// the first row (typically `-history_size`). Each entry in `rows_data` is
/// a sorted `Vec<(col, char)>` for that row.
pub fn full_grid_rows(&self) -> (i32, Vec<Vec<(i32, char)>>) {
let grid = self.term.grid();
let history = grid.history_size() as i32;
let screen = grid.screen_lines() as i32;
let total = history + screen;
let cols = self.cols as i32;
let start_line = -history;
let mut rows_data: Vec<Vec<(i32, char)>> = Vec::with_capacity(total as usize);
for line_idx in start_line..(start_line + total) {
let line = Line(line_idx);
let mut cells: Vec<(i32, char)> = Vec::new();
for col_idx in 0..cols {
let point = Point::new(line, Column(col_idx as usize));
let c = grid[point].c;
if c != ' ' && c != '\0' {
cells.push((col_idx, c));
}
}
rows_data.push(cells);
}
(start_line, rows_data)
}
pub fn scroll_history(&mut self, delta: i32) {
if delta != 0 {
self.term.scroll_display(Scroll::Delta(delta));
@@ -370,18 +471,29 @@ impl TerminalTab {
}
pub fn paste_text(&mut self, text: &str) {
let bracketed = self.term.mode().contains(TermMode::BRACKETED_PASTE);
let paste_text = text
.replace('\x1b', "")
.replace("\r\n", "\r")
.replace('\n', "\r");
self.backend
.send(BackendCommand::Input(paste_text.into_bytes()));
let mut bytes = Vec::new();
if bracketed {
bytes.extend_from_slice(b"\x1b[200~");
}
bytes.extend_from_slice(paste_text.as_bytes());
if bracketed {
bytes.extend_from_slice(b"\x1b[201~");
}
self.send_backend(BackendCommand::Input(bytes));
}
}
fn viewport_selection_from_range(
display_offset: usize,
rows: usize,
cols: usize,
selection: &Option<SelectionRange>,
) -> Option<ViewportSelection> {
let SelectionRange {
@@ -389,14 +501,34 @@ fn viewport_selection_from_range(
end,
is_block,
} = selection.as_ref().copied()?;
let start = point_to_viewport(display_offset, start)?;
let end = point_to_viewport(display_offset, end)?;
let top_point = viewport_to_point(display_offset, Point::new(0, Column(0)));
let bottom_point = viewport_to_point(display_offset, Point::new(rows.saturating_sub(1), Column(0)));
let top_line = top_point.line;
let bottom_line = bottom_point.line;
let start_vp = if start.line < top_line {
Point::new(0, Column(0))
} else if start.line > bottom_line {
Point::new(rows.saturating_sub(1), Column(cols.saturating_sub(1)))
} else {
point_to_viewport(display_offset, start).unwrap_or(Point::new(0, Column(0)))
};
let end_vp = if end.line < top_line {
Point::new(0, Column(0))
} else if end.line > bottom_line {
Point::new(rows.saturating_sub(1), Column(cols.saturating_sub(1)))
} else {
point_to_viewport(display_offset, end).unwrap_or(Point::new(rows.saturating_sub(1), Column(cols.saturating_sub(1))))
};
Some(ViewportSelection {
start_row: start.line,
start_col: start.column.0,
end_row: end.line,
end_col: end.column.0,
start_row: start_vp.line,
start_col: start_vp.column.0,
end_row: end_vp.line,
end_col: end_vp.column.0,
is_block,
})
}
@@ -404,16 +536,18 @@ fn viewport_selection_from_range(
#[derive(Clone)]
struct TerminalListener {
tab_id: String,
backend: BackendTx,
backend: std::sync::Arc<std::sync::Mutex<BackendTx>>,
events: std::sync::mpsc::Sender<BackendEvent>,
}
impl EventListener for TerminalListener {
fn send_event(&self, event: Event) {
match event {
Event::PtyWrite(output) => self
.backend
.send(BackendCommand::Input(output.into_bytes())),
Event::PtyWrite(output) => {
if let Ok(backend) = self.backend.lock() {
backend.send(BackendCommand::Input(output.into_bytes()));
}
}
Event::TextAreaSizeRequest(format) => {
let size = alacritty_terminal::event::WindowSize {
num_lines: 30,
@@ -421,8 +555,9 @@ impl EventListener for TerminalListener {
cell_width: 8,
cell_height: 16,
};
self.backend
.send(BackendCommand::Input(format(size).into_bytes()));
if let Ok(backend) = self.backend.lock() {
backend.send(BackendCommand::Input(format(size).into_bytes()));
}
}
Event::Title(title) => {
let _ = self.events.send(BackendEvent::TerminalTitleChanged {
@@ -438,7 +573,7 @@ impl EventListener for TerminalListener {
fn new_term(
cols: u16,
rows: u16,
backend: BackendTx,
backend: std::sync::Arc<std::sync::Mutex<BackendTx>>,
tab_id: String,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Term<TerminalListener> {
@@ -665,15 +800,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,