Compare commits

..
100 Commits
Author SHA1 Message Date
TomZz 47ec91d099 bump version to 0.5.2-fix1 2026-10-03 17:46:13 +08:00
TomZz e2501ca764 fix(config): 修复终端与界面字号在特定数值下重启被重置的问题 (#117) 2026-10-03 17:30:01 +08:00
TomZz a34a673b52 bump version to 0.5.2 2026-10-03 17:19:57 +08:00
TomZz 0708ac7baa fix(app): 优化系统监控与会话选择,修复分屏 SFTP 及窗口激活问题 (#119) 2026-10-03 17:19:24 +08:00
TomZz ff4079a60f bump version to 0.5.1 2026-08-29 22:49:18 +08:00
TomZz 9d0868dd7c Merge pull request #116 from realmx/fix/terminal-tab-activity-url-boundaries
修复终端标签活动状态与 URL 高亮边界
2026-08-29 22:47:07 +08:00
TomZz f63b8b418c feat(terminal): support switching tabs when focusing adjacent pane at layout boundaries 2026-08-29 22:40:09 +08:00
Realm e1d39d8dc4 fix(terminal): 对齐标签活动状态并修正链接边界
- 标签 Loading 仅响应后台标签最近两秒的真实输出
- 排除 OSC 控制序列并补充终端活动状态回归测试
- 修正 URL 在中文标点和未配对括号后的高亮范围
2026-08-29 19:55:58 +08:00
TomZz c9f722071a build(ci): add dedicated macos-arm64 preview build workflow 2026-08-28 03:41:30 +08:00
TomZz 2e279e206f feat(ui): add keybinding usage tips and shortcut conflict guidance to about page 2026-08-28 03:37:26 +08:00
Realm 7b410afea7 fix(app): resolve connection export modal and terminal history glitches (#115)
- Fix CSV connection export confirmation dialog not opening within settings page
- Remove redundant connect button on connection rows in sidebar edit mode
- Enhance terminal syntax highlighting with background color, inverse, dim, and CJK wide char support
- Improve command history tracking accuracy using buffer-relative coordinates and residue filtering
2026-08-28 03:12:54 +08:00
TomZz b76f9c7375 bump version to 0.5.0 2026-08-27 01:37:30 +08:00
TomZz 28fc1a6b50 fix(theme): fallback to embedded Maple font when system UI fonts are missing (#92)
- Add resolve_safe_ui_font to gracefully resolve UI fonts across all platforms.
- If default system UI fonts (.SystemUIFont) or requested custom fonts cannot be resolved (e.g. on minimal Linux distros like Gentoo/Arch without desktop fonts installed), automatically fallback to embedded Maple Mono NF CN font.
- Prevents GPUI runtime text system panic: 'failed to resolve font .SystemUIFont or any of the fallbacks'.
2026-08-27 01:20:47 +08:00
TomZz 52741e3c32 refactor(ui): redesign sidebar header, connection row click interaction, and tab bar dropdown
- Redesign sidebar layout and management mode:
  - Add explicit "Edit" / "Done" toggle button in the sidebar header to toggle connection management mode cleanly.
  - Remove top sidebar Import/Export buttons and move them into the Settings dialog ("配置文件与备份" / Config File & Backup page).
  - Remove the bottom right Switch toggle widget from sidebar layout.
  - Add a full-width "+ 新增分组" button below the connection filter search box in Edit Mode.
- Improve connection item click UX:
  - Make the entire connection row clickable in non-edit mode to immediately launch saved sessions.
  - Hide redundant external link icon button in non-edit mode for a cleaner sidebar appearance.
  - Allow user resizing of sidebar width down to 200px while maintaining default 310px.
- Fix tab bar hover panic & dropdown navigation:
  - Remove duplicate `.hover(...)` call on tab close button to resolve GPUI runtime debug assert panic ("hover style already set").
  - Update tab bar '+' dropdown menu second item to "打开连接" (Open Connection), triggering the Session Selector Dialog (`show_selector_dialog`).
  - Add corresponding localization strings in English and Simplified Chinese.
2026-08-27 01:00:20 +08:00
Realm 3ef6e8cfb8 fix(terminal): 修复 SSH 历史命令残留与光标定位 (#114) 2026-08-27 00:09:40 +08:00
Realm 6ccb9ff601 feat(ui): 优化侧边栏连接管理与窗口激活通知 (#113) 2026-08-27 00:09:38 +08:00
Realm 168a20e157 fix: 完善终端拖拽、Windows 细节与细节修正 (#112) 2026-08-27 00:01:03 +08:00
Realm ee98a933e6 feat(ui): 重构工作区交互与系统菜单 (#111) 2026-08-27 00:01:03 +08:00
Realm b4551ee1cc feat(terminal): 扩展跨平台通知协议与折叠管理 (#110) 2026-08-27 00:01:02 +08:00
Realm 233cde12e8 build/ci/docs: 拆分构建、打包与更新日志 (#109) 2026-08-27 00:01:02 +08:00
Realm b1efaa1d8f feat: 增加连接分组管理 (#108) 2026-08-27 00:00:58 +08:00
Realm 4607b2c995 拆分工作区 UI 与对话框基础 (#107)
* feat: 拆分终端与会话基础能力

* feat: 拆分应用状态与布局基础

* feat: 拆分工作区 UI 与对话框基础
2026-08-22 22:00:59 +08:00
Realm f6816da18b 拆分应用状态与布局基础 (#106)
* feat: 拆分终端与会话基础能力

* feat: 拆分应用状态与布局基础
2026-08-22 21:54:56 +08:00
Realm 350dcbc5b4 feat: 拆分终端与会话基础能力 (#105) 2026-08-21 14:24:17 +08:00
TomZz 44348d4d75 feat: support mapping F1-F12 keys to ANSI escape sequences (resolves #91) 2026-07-23 00:26:28 +08:00
TomZz 9163d3a970 bump version to 0.4.12 2026-07-23 00:12:10 +08:00
TomZz d85c1f4f55 perf: fix sluggishness when switching focus between panes 2026-07-23 00:06:54 +08:00
TomZz 61e7fb7b0d feat: optimize ssh keepalive timeout settings (fixes #86) 2026-07-22 23:58:17 +08:00
TomZz 1bb922f955 feat: add local configuration export and import settings 2026-07-22 23:58:17 +08:00
TomZz a06d1c668d feat: support unbinding shortcuts by pressing Backspace 2026-07-22 23:58:17 +08:00
TomZz 3b9ee02cce feat: fix Windows flashing, UI thread blocking, and idle redraws (fixes #90) 2026-07-22 23:58:17 +08:00
TomZz e7ca7bdc23 feat: add support for serial port connections with virtual PTY fallback and automatic newline translation 2026-07-20 17:06:21 +08:00
TomZz 84eccb5521 bump version to 0.4.11 2026-07-15 03:24:39 +08:00
TomZz 4da9949c84 perf: avoid reading and decrypting config file from disk repeatedly on every render frame 2026-07-15 03:23:36 +08:00
TomZz 85fb354f80 fix: add a 16-second connection timeout to tcp and proxy connections 2026-07-15 03:23:36 +08:00
TomZz 584f962298 feat: implement manual path sync from terminal to file manager (fixes #83) 2026-07-15 03:23:36 +08:00
TomZz a32fd9437d style: run cargo fmt 2026-07-15 02:26:16 +08:00
TomZz 4c292c060c fix: clear connection_progress on tab close to resolve stuck modal (fixes #82) 2026-07-15 02:20:08 +08:00
TomZz c0a954ea0c feat: implement hardware-encrypted configuration file 2026-07-15 02:20:06 +08:00
TomZz ab41ccbd6b bump version to 0.4.10 2026-07-08 19:55:29 +08:00
TomZz 43717e4378 fix(ssh): improve ssh config parser robustness and revert gitignore changes 2026-07-08 19:51:40 +08:00
tg.shi 31218f7a37 feat(ssh): connect via ~/.ssh/config entries
Add AuthMethod::Config: parse ~/.ssh/config and list concrete host
entries in the connection dialog. Selecting an entry pre-fills
host/port/user/identity file and connects.

- Extract shared key utilities into session::ssh_keys
  (private_keys_with_algs, normalize_inline_private_key,
  authenticate_with_default_keys, session_has_explicit_key), reused by
  backend::ssh and sftp to remove duplication.
- Add session::ssh_config parser for ~/.ssh/config (Host/HostName/User/
  Port/IdentityFile), skipping wildcard patterns and Match/Include.
- Key auth now falls back to default keys (~/.ssh/id_*) when no explicit
  key path or inline content is provided.
- Preserve escaped \r\n normalization in normalize_inline_private_key
  when refactoring into the shared module.
2026-07-08 11:50:51 +08:00
TomZz af5c2831f6 fix: remove redundant window drag area to fix window control overlay (resolves #73) 2026-07-04 12:11:42 +08:00
TomZz a8669738e6 fix: relocate window title bar dragging to tab bar container (resolves #72) 2026-07-03 19:55:22 +08:00
RabitLogic 2dad1fa5a1 fix: enable window title bar dragging on Linux via manual mouse tracking and deb packaging (#68) 2026-07-03 16:51:18 +08:00
TomZz e2bf76f7b2 bump version to v0.4.9 2026-07-03 04:56:05 +08:00
TomZz a654695d7b feat: support terminal URL highlighting on hover, click-to-open and multi-line wrapping 2026-07-03 04:36:42 +08:00
TomZz cb078ef572 feat: add setting to lock window layout (resolves #69) 2026-07-02 23:39:03 +08:00
TomZz c0aa21a994 fix: enable window title bar dragging in integrated mode (resolves #67) 2026-07-02 23:15:07 +08:00
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
51 changed files with 28047 additions and 3470 deletions
+12 -6
View File
@@ -2,9 +2,9 @@ name: CI
on:
push:
branches: ["main"]
branches: ["main", "master"]
pull_request:
branches: ["main"]
branches: ["main", "master"]
jobs:
build:
@@ -14,18 +14,24 @@ jobs:
fail-fast: false
matrix:
include:
- os: windows-latest
- os: windows-2022
target: x86_64-pc-windows-msvc
name: windows-x86_64
name: windows-x64
- os: windows-2022
target: i686-pc-windows-msvc
name: windows-x86
- os: windows-2022
target: aarch64-pc-windows-msvc
name: windows-arm64
- 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
name: macos-arm64
- os: macos-14 # Intel (cross-compiled from Apple Silicon to avoid queue delays)
target: x86_64-apple-darwin
name: macos-x86_64
name: macos-x64
steps:
- uses: actions/checkout@v4
+358 -51
View File
@@ -1,42 +1,80 @@
name: Release
# Build native binaries for Windows / Linux / macOS.
# Build native installers and portable archives for Windows / Linux / macOS.
# - Push a tag like `v0.2.3` -> builds all platforms and attaches the archives
# to a GitHub Release.
# - Run manually (workflow_dispatch) -> builds all platforms and uploads them as
# downloadable workflow artifacts (no release created).
# - Push a `build/**` branch -> builds preview installers as workflow artifacts.
# - Run manually (workflow_dispatch) -> builds all platforms by default, or only
# the selected target, and uploads them as downloadable workflow artifacts.
on:
push:
branches: ["build/**"]
tags: ["v*"]
workflow_dispatch:
inputs:
platform:
description: "Build target"
required: true
type: choice
default: all
options:
- all
- windows-x64
- macos-arm64
permissions:
contents: write # needed to create / update the GitHub Release
jobs:
build:
if: >-
github.ref != 'refs/heads/build/windows-x64' &&
github.ref != 'refs/heads/build/macos-arm64' &&
(github.event_name != 'workflow_dispatch' || inputs.platform == 'all')
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
- os: windows-2022
target: x86_64-pc-windows-msvc
name: windows-x86_64
name: windows-x64
kind: windows
arch: x64
bin: ashell.exe
- os: windows-2022
target: i686-pc-windows-msvc
name: windows-x86
kind: windows
arch: x86
bin: ashell.exe
- os: windows-2022
target: aarch64-pc-windows-msvc
name: windows-arm64
kind: windows
arch: arm64
bin: ashell.exe
- os: ubuntu-22.04 # build on the oldest supported runner for broader glibc compatibility
target: x86_64-unknown-linux-gnu
name: linux-x86_64
name: linux-x64
kind: linux
arch: x64
bin: ashell
- os: macos-14 # Apple Silicon
target: aarch64-apple-darwin
name: macos-aarch64
name: macos-arm64
kind: macos
arch: arm64
binary_arch: arm64
bin: ashell
- os: macos-14 # Intel (cross-compiled from Apple Silicon to avoid queue delays)
target: x86_64-apple-darwin
name: macos-x86_64
name: macos-x64
kind: macos
arch: x64
binary_arch: x86_64
bin: ashell
steps:
@@ -68,28 +106,46 @@ jobs:
- name: Build (release)
run: cargo build --release --target ${{ matrix.target }}
- name: Package
- name: Set package metadata
shell: bash
run: |
set -eu
VERSION="${GITHUB_REF_NAME:-dev}"
STAGE="ashell-${VERSION}-${{ matrix.name }}"
set -euo pipefail
VERSION="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n 1)"
if [ -z "$VERSION" ]; then
echo "Unable to read the package version from Cargo.toml" >&2
exit 1
fi
if [ "${{ runner.os }}" = "macOS" ]; then
# ── Build a proper .app bundle ────────────────────────────────────
# 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.
if [ "$GITHUB_REF_TYPE" = "tag" ]; then
PACKAGE_VERSION="${GITHUB_REF_NAME#v}"
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
echo "Tag version $PACKAGE_VERSION does not match Cargo.toml version $VERSION" >&2
exit 1
fi
else
PACKAGE_VERSION="${VERSION}-dev.${GITHUB_SHA:0:7}"
fi
APP="ashell.app"
CONTENTS="$APP/Contents"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> "$GITHUB_ENV"
echo "PACKAGE_BASENAME=ashell-v${PACKAGE_VERSION}-${{ matrix.name }}" >> "$GITHUB_ENV"
cp "target/${{ matrix.target }}/release/ashell" "$CONTENTS/MacOS/"
cp "assets/icons/ashell.icns" "$CONTENTS/Resources/ashell.icns"
- name: Package macOS app, DMG, and portable archive
if: matrix.kind == 'macos'
shell: bash
run: |
set -euo pipefail
APP_ROOT="$RUNNER_TEMP/${PACKAGE_BASENAME}-app"
DMG_ROOT="$RUNNER_TEMP/${PACKAGE_BASENAME}-dmg"
APP="$APP_ROOT/ashell.app"
CONTENTS="$APP/Contents"
VERSION_NUM="${VERSION#v}"
cat > "$CONTENTS/Info.plist" <<EOF
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources" "$DMG_ROOT" dist
cp "target/${{ matrix.target }}/release/ashell" "$CONTENTS/MacOS/ashell"
chmod 755 "$CONTENTS/MacOS/ashell"
cp "assets/icons/ashell.icns" "$CONTENTS/Resources/ashell.icns"
cat > "$CONTENTS/Info.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -110,9 +166,9 @@ jobs:
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${VERSION_NUM}</string>
<string>${VERSION}</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>${GITHUB_RUN_NUMBER}</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>NSHighResolutionCapable</key>
@@ -121,35 +177,284 @@ jobs:
</plist>
EOF
printf 'APPL????' > "$CONTENTS/PkgInfo"
printf 'APPL????' > "$CONTENTS/PkgInfo"
plutil -lint "$CONTENTS/Info.plist"
# Ad-hoc code-sign the assembled bundle.
codesign --force --deep --sign - "$APP"
codesign --verify --verbose "$APP"
# Release builds remain ad-hoc signed until signing credentials are configured.
codesign --force --deep --sign - "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"
# Package with ditto (Apple's tool) to preserve signature/metadata
ditto -c -k --keepParent "$APP" "${STAGE}.zip"
echo "ASSET=${STAGE}.zip" >> "$GITHUB_ENV"
ditto -c -k --sequesterRsrc --keepParent \
"$APP" "dist/${PACKAGE_BASENAME}-portable.zip"
ditto "$APP" "$DMG_ROOT/ashell.app"
ln -s /Applications "$DMG_ROOT/Applications"
hdiutil create \
-volname "ashell ${VERSION}" \
-srcfolder "$DMG_ROOT" \
-ov \
-format UDZO \
"dist/${PACKAGE_BASENAME}.dmg"
elif [ "${{ runner.os }}" = "Windows" ]; then
mkdir "$STAGE"
cp "target/${{ matrix.target }}/release/${{ matrix.bin }}" "$STAGE/"
7z a "${STAGE}.zip" "$STAGE" >/dev/null
echo "ASSET=${STAGE}.zip" >> "$GITHUB_ENV"
else
# Linux
mkdir "$STAGE"
cp "target/${{ matrix.target }}/release/${{ matrix.bin }}" "$STAGE/"
tar -czf "${STAGE}.tar.gz" "$STAGE"
echo "ASSET=${STAGE}.tar.gz" >> "$GITHUB_ENV"
EXPECTED_ARCH="${{ matrix.binary_arch }}"
ACTUAL_ARCHS="$(lipo -archs "$CONTENTS/MacOS/ashell")"
if [ "$ACTUAL_ARCHS" != "$EXPECTED_ARCH" ]; then
echo "Expected $EXPECTED_ARCH binary, found $ACTUAL_ARCHS" >&2
exit 1
fi
shasum -a 256 dist/*
- name: Package Windows installer and portable archive
if: matrix.kind == 'windows'
shell: pwsh
env:
ASHELL_ARCH: ${{ matrix.arch }}
ASHELL_BINARY_PATH: ${{ github.workspace }}\target\${{ matrix.target }}\release\ashell.exe
ASHELL_OUTPUT_DIR: ${{ github.workspace }}\dist
run: |
$ErrorActionPreference = "Stop"
New-Item -ItemType Directory -Path "dist" -Force | Out-Null
$isccCommand = Get-Command "ISCC.exe" -ErrorAction SilentlyContinue
if ($null -ne $isccCommand) {
$iscc = $isccCommand.Source
} else {
$iscc = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
}
if (-not (Test-Path $iscc)) {
throw "Inno Setup compiler was not found"
}
& $iscc "packaging\windows\ashell.iss"
if ($LASTEXITCODE -ne 0) {
throw "Inno Setup failed with exit code $LASTEXITCODE"
}
$installer = "dist\${env:PACKAGE_BASENAME}-setup.exe"
if (-not (Test-Path $installer)) {
throw "Expected installer was not created: $installer"
}
$stage = Join-Path $env:RUNNER_TEMP $env:PACKAGE_BASENAME
New-Item -ItemType Directory -Path $stage | Out-Null
Copy-Item $env:ASHELL_BINARY_PATH (Join-Path $stage "ashell.exe")
Compress-Archive -Path $stage -DestinationPath "dist\${env:PACKAGE_BASENAME}-portable.zip"
Get-FileHash -Algorithm SHA256 "dist\*"
- name: Package Linux portable archive
if: matrix.kind == 'linux'
shell: bash
run: |
set -euo pipefail
STAGE="$RUNNER_TEMP/$PACKAGE_BASENAME"
mkdir -p "$STAGE" dist
cp "target/${{ matrix.target }}/release/${{ matrix.bin }}" "$STAGE/"
tar -C "$RUNNER_TEMP" -czf "dist/${PACKAGE_BASENAME}.tar.gz" "$PACKAGE_BASENAME"
sha256sum dist/*
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.name }}
path: ${{ env.ASSET }}
path: dist/*
if-no-files-found: error
retention-days: 14
build-windows-x64:
if: >-
github.ref == 'refs/heads/build/windows-x64' ||
(github.event_name == 'workflow_dispatch' && inputs.platform == 'windows-x64')
name: windows-x64
runs-on: windows-2022
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
with:
key: x86_64-pc-windows-msvc
- name: Build (release)
run: cargo build --release --target x86_64-pc-windows-msvc
- name: Set package metadata
shell: bash
run: |
set -euo pipefail
VERSION="$(sed -n 's/^version = "\([^\"]*\)"/\1/p' Cargo.toml | head -n 1)"
if [ -z "$VERSION" ]; then
echo "Unable to read the package version from Cargo.toml" >&2
exit 1
fi
PACKAGE_VERSION="${VERSION}-dev.${GITHUB_SHA:0:7}"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> "$GITHUB_ENV"
echo "PACKAGE_BASENAME=ashell-v${PACKAGE_VERSION}-windows-x64" >> "$GITHUB_ENV"
- name: Package Windows installer and portable archive
shell: pwsh
env:
ASHELL_ARCH: x64
ASHELL_BINARY_PATH: ${{ github.workspace }}\target\x86_64-pc-windows-msvc\release\ashell.exe
ASHELL_OUTPUT_DIR: ${{ github.workspace }}\dist
run: |
$ErrorActionPreference = "Stop"
New-Item -ItemType Directory -Path "dist" -Force | Out-Null
$isccCommand = Get-Command "ISCC.exe" -ErrorAction SilentlyContinue
if ($null -ne $isccCommand) {
$iscc = $isccCommand.Source
} else {
$iscc = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
}
if (-not (Test-Path $iscc)) {
throw "Inno Setup compiler was not found"
}
& $iscc "packaging\windows\ashell.iss"
if ($LASTEXITCODE -ne 0) {
throw "Inno Setup failed with exit code $LASTEXITCODE"
}
$installer = "dist\${env:PACKAGE_BASENAME}-setup.exe"
if (-not (Test-Path $installer)) {
throw "Expected installer was not created: $installer"
}
$stage = Join-Path $env:RUNNER_TEMP $env:PACKAGE_BASENAME
New-Item -ItemType Directory -Path $stage | Out-Null
Copy-Item $env:ASHELL_BINARY_PATH (Join-Path $stage "ashell.exe")
Compress-Archive -Path $stage -DestinationPath "dist\${env:PACKAGE_BASENAME}-portable.zip"
Get-FileHash -Algorithm SHA256 "dist\*"
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: windows-x64
path: dist/*
if-no-files-found: error
retention-days: 14
build-macos-arm64:
if: >-
github.ref == 'refs/heads/build/macos-arm64' ||
(github.event_name == 'workflow_dispatch' && inputs.platform == 'macos-arm64')
name: macos-arm64
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
with:
key: aarch64-apple-darwin
- name: Build (release)
run: cargo build --release --target aarch64-apple-darwin
- name: Set package metadata
shell: bash
run: |
set -euo pipefail
VERSION="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n 1)"
if [ -z "$VERSION" ]; then
echo "Unable to read the package version from Cargo.toml" >&2
exit 1
fi
PACKAGE_VERSION="${VERSION}-dev.${GITHUB_SHA:0:7}"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> "$GITHUB_ENV"
echo "PACKAGE_BASENAME=ashell-v${PACKAGE_VERSION}-macos-arm64" >> "$GITHUB_ENV"
- name: Package macOS app, DMG, and portable archive
shell: bash
run: |
set -euo pipefail
APP_ROOT="$RUNNER_TEMP/${PACKAGE_BASENAME}-app"
DMG_ROOT="$RUNNER_TEMP/${PACKAGE_BASENAME}-dmg"
APP="$APP_ROOT/ashell.app"
CONTENTS="$APP/Contents"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources" "$DMG_ROOT" dist
cp "target/aarch64-apple-darwin/release/ashell" "$CONTENTS/MacOS/ashell"
chmod 755 "$CONTENTS/MacOS/ashell"
cp "assets/icons/ashell.icns" "$CONTENTS/Resources/ashell.icns"
cat > "$CONTENTS/Info.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>ashell</string>
<key>CFBundleIconFile</key>
<string>ashell.icns</string>
<key>CFBundleIdentifier</key>
<string>dev.ashell.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>ashell</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${VERSION}</string>
<key>CFBundleVersion</key>
<string>${GITHUB_RUN_NUMBER}</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
EOF
printf 'APPL????' > "$CONTENTS/PkgInfo"
plutil -lint "$CONTENTS/Info.plist"
codesign --force --deep --sign - "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"
ditto -c -k --sequesterRsrc --keepParent \
"$APP" "dist/${PACKAGE_BASENAME}-portable.zip"
ditto "$APP" "$DMG_ROOT/ashell.app"
ln -s /Applications "$DMG_ROOT/Applications"
hdiutil create \
-volname "ashell ${VERSION}" \
-srcfolder "$DMG_ROOT" \
-ov \
-format UDZO \
"dist/${PACKAGE_BASENAME}.dmg"
EXPECTED_ARCH="arm64"
ACTUAL_ARCHS="$(lipo -archs "$CONTENTS/MacOS/ashell")"
if [ "$ACTUAL_ARCHS" != "$EXPECTED_ARCH" ]; then
echo "Expected $EXPECTED_ARCH binary, found $ACTUAL_ARCHS" >&2
exit 1
fi
shasum -a 256 dist/*
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: macos-arm64
path: dist/*
if-no-files-found: error
retention-days: 14
publish:
name: Publish Release
@@ -161,11 +466,12 @@ jobs:
uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Attach to GitHub Release
uses: softprops/action-gh-release@v2
with:
files: dist/**/*
files: dist/*
generate_release_notes: true
fail_on_unmatched_files: true
@@ -187,12 +493,13 @@ jobs:
with:
pattern: macos-*
path: dist
merge-multiple: true
- 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)
SHA_ARM=$(sha256sum "dist/ashell-v${VERSION}-macos-arm64.dmg" | cut -d' ' -f1)
SHA_INTEL=$(sha256sum "dist/ashell-v${VERSION}-macos-x64.dmg" | cut -d' ' -f1)
mkdir -p tap/Casks
CASK_FILE="tap/Casks/ashell.rb"
@@ -203,11 +510,11 @@ jobs:
on_arm do
sha256 "${SHA_ARM}"
url "https://github.com/rust-kotlin/ashell/releases/download/v#{version}/ashell-v#{version}-macos-aarch64.zip"
url "https://github.com/rust-kotlin/ashell/releases/download/v#{version}/ashell-v#{version}-macos-arm64.dmg"
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"
url "https://github.com/rust-kotlin/ashell/releases/download/v#{version}/ashell-v#{version}-macos-x64.dmg"
end
name "ashell"
+2 -1
View File
@@ -1,2 +1,3 @@
/target/
dist/
target/
.DS_Store
+39
View File
@@ -0,0 +1,39 @@
# 更新日志
本文档记录 ashell 的重要功能变更。
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循
[语义化版本](https://semver.org/lang/zh-CN/)。
## [v0.5.0] - 2026-08-27
### 新增
- 支持 ANSI 转义序列中的 F1–F12 功能键映射,解决在终端工具(如 `htop`、`mc`、`vim` 等)中按 F1–F12 无法响应的问题 (#91)。
- 连接管理新增分组功能,支持创建分组、重命名、删除分组、折叠展开与按组归类管理连接 (#108)。
- 扩展终端通知协议支持,兼容 OSC 9、OSC 99、OSC 777 和 BEL (响铃),支持 macOS Dock 与 Windows 任务栏未读徽标,后台标签页显示闪烁提醒,点击通知可自动唤醒窗口并切换至对应标签页 (#110)。
- 新增系统级应用菜单,提供包含关于、文件、编辑、视图、窗口和帮助等常用操作 (#111)。
### 变更
- 重新设计侧边栏连接管理交互:侧边栏头部增加显式的“编辑 / 完成”模式切换按钮,编辑模式下在搜索框下方提供全宽“+ 新增分组”按钮,并移除侧边栏右下角切换 Switch 开关。
- 优化侧边栏连接项点击体验:非编辑模式下点击整行即可直接发起连接并隐藏冗余外链图标;允许侧边栏宽度自定义拖拽调小至 200px。
- 将 CSV 导入与导出入口位置由侧边栏头部整合移至设置对话框的“配置文件与备份”页面中。
- 优化标签栏加号下拉菜单:将第二个菜单项更新为“打开连接”,点击后直接弹出连接选择器对话框。
- 支持侧边栏完全折叠隐藏,折叠后隐藏连接列表并最大化终端界面绘制区域。
### 修复
- 修复 SSH 历史命令输入时的残留字符与光标定位偏差问题 (#114)。
- 优化 Windows 平台下终端拖拽与窗口细节表现 (#112)。
### 兼容性与稳定性
- 代码架构模块化重构:将核心代码拆分为 `session`、`terminal`、`app`、`backend`、`sftp` 与 `sync` 模块,提升可维护性 (#105-#107)。
- 优化 GitHub Actions CI 构建工作流 (#109)。
### 注意事项
- CSV 导出的密码为明文格式,请妥善保管并在迁移后及时删除导出的 CSV 文件。
Generated
+468 -59
View File
@@ -265,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"
@@ -303,24 +315,40 @@ dependencies = [
[[package]]
name = "ashell"
version = "0.4.5"
version = "0.5.2-fix1"
dependencies = [
"alacritty_terminal",
"anyhow",
"argon2",
"async-trait",
"base64",
"chacha20poly1305",
"chrono",
"csv",
"directories",
"encoding_rs",
"flate2",
"futures",
"gpui",
"gpui-component",
"gpui-component-assets",
"gpui_platform",
"hex",
"hmac",
"image",
"libc",
"mac-notification-sys",
"menu",
"notify 6.1.1",
"notify-rust",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"portable-pty",
"rand 0.8.6",
"raw-window-handle",
"reqwest",
"rfd",
"russh",
"russh-keys",
@@ -328,18 +356,25 @@ dependencies = [
"rust-i18n 3.1.5",
"serde",
"serde_json",
"serialport",
"sha2",
"ssh-key",
"sys-locale",
"sysinfo 0.33.1",
"tar",
"tauri-winrt-notification",
"tempfile",
"thiserror 1.0.69",
"time",
"tokio",
"tokio-socks",
"tracing",
"tracing-appender",
"tracing-subscriber",
"uuid",
"walkdir",
"windows 0.57.0",
"winreg 0.52.0",
"winres",
"zip",
]
@@ -842,11 +877,19 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
source = "git+https://github.com/Dicklesworthstone/rust-block?rev=b39ae859d1ee8e8cb5eef6a516471f1578d26b96#b39ae859d1ee8e8cb5eef6a516471f1578d26b96"
[[package]]
name = "block-buffer"
@@ -1091,6 +1134,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"
@@ -1201,9 +1257,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",
]
@@ -1546,6 +1601,27 @@ dependencies = [
"typenum",
]
[[package]]
name = "csv"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
dependencies = [
"csv-core",
"itoa",
"ryu",
"serde_core",
]
[[package]]
name = "csv-core"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
dependencies = [
"memchr",
]
[[package]]
name = "ctor"
version = "1.0.7"
@@ -1698,7 +1774,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",
@@ -2682,7 +2758,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",
@@ -2712,14 +2788,13 @@ dependencies = [
"gpui_macros",
"gpui_shared_string",
"gpui_util",
"heapless",
"http_client",
"image",
"inventory",
"itertools 0.14.0",
"log",
"lyon",
"mach2",
"mach2 0.5.0",
"media",
"metal",
"num_cpus",
@@ -2832,7 +2907,7 @@ 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",
@@ -2883,7 +2958,7 @@ dependencies = [
[[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",
@@ -2909,7 +2984,7 @@ dependencies = [
"itertools 0.14.0",
"libc",
"log",
"mach2",
"mach2 0.5.0",
"media",
"metal",
"objc",
@@ -2928,7 +3003,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",
@@ -2939,7 +3014,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",
@@ -2952,7 +3027,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",
@@ -2962,7 +3037,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",
@@ -2971,7 +3046,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",
@@ -2995,7 +3070,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",
@@ -3024,7 +3099,7 @@ dependencies = [
[[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",
@@ -3273,7 +3348,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",
@@ -3336,6 +3411,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -3344,13 +3420,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",
@@ -3638,6 +3717,16 @@ dependencies = [
"rustversion",
]
[[package]]
name = "io-kit-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
dependencies = [
"core-foundation-sys",
"mach2 0.4.3",
]
[[package]]
name = "io-surface"
version = "0.16.1"
@@ -3907,6 +3996,26 @@ dependencies = [
"libc",
]
[[package]]
name = "libudev"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0"
dependencies = [
"libc",
"libudev-sys",
]
[[package]]
name = "libudev-sys"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "linebender_resource_handle"
version = "0.1.1"
@@ -4054,6 +4163,29 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mac-notification-sys"
version = "0.6.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
dependencies = [
"cc",
"log",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
"time",
"uuid",
]
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]]
name = "mach2"
version = "0.5.0"
@@ -4146,7 +4278,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",
@@ -4194,7 +4326,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",
]
@@ -4352,6 +4484,17 @@ dependencies = [
"pin-utils",
]
[[package]]
name = "nix"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
dependencies = [
"bitflags 1.3.2",
"cfg-if",
"libc",
]
[[package]]
name = "nix"
version = "0.29.0"
@@ -4457,6 +4600,18 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "notify-rust"
version = "4.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fa3b9f2364a09bd359aa0206702882e208437450866a374d5372d64aece4029"
dependencies = [
"futures-lite 2.6.1",
"log",
"mac-notification-sys",
"tauri-winrt-notification",
]
[[package]]
name = "notify-types"
version = "1.0.1"
@@ -4684,8 +4839,8 @@ dependencies = [
"block2 0.5.1",
"libc",
"objc2 0.5.2",
"objc2-core-data",
"objc2-core-image",
"objc2-core-data 0.2.2",
"objc2-core-image 0.2.2",
"objc2-foundation 0.2.2",
"objc2-quartz-core 0.2.2",
]
@@ -4698,6 +4853,26 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.12.1",
"block2 0.6.2",
"libc",
"objc2 0.6.4",
"objc2-cloud-kit",
"objc2-core-data 0.3.2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image 0.3.2",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "objc2-cloud-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags 2.12.1",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
]
@@ -4714,6 +4889,17 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-core-data"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"bitflags 2.12.1",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
@@ -4725,6 +4911,19 @@ dependencies = [
"objc2 0.6.4",
]
[[package]]
name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.12.1",
"dispatch2",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-io-surface",
]
[[package]]
name = "objc2-core-image"
version = "0.2.2"
@@ -4737,6 +4936,41 @@ dependencies = [
"objc2-metal 0.2.2",
]
[[package]]
name = "objc2-core-image"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
dependencies = [
"objc2 0.6.4",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.12.1",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.12.1",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -4760,6 +4994,19 @@ name = "objc2-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.12.1",
"block2 0.6.2",
"libc",
"objc2 0.6.4",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.12.1",
"objc2 0.6.4",
@@ -5014,6 +5261,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"
@@ -5079,7 +5337,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",
@@ -5452,28 +5710,6 @@ dependencies = [
"toml_edit 0.25.12+spec-1.1.0",
]
[[package]]
name = "proc-macro-error-attr2"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "proc-macro-error2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
dependencies = [
"proc-macro-error-attr2",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -5542,6 +5778,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.39.4"
@@ -5861,7 +6106,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",
]
@@ -5901,6 +6146,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"
@@ -6477,7 +6760,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",
@@ -6803,6 +7086,25 @@ dependencies = [
"serial-core",
]
[[package]]
name = "serialport"
version = "4.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3"
dependencies = [
"bitflags 2.12.1",
"cfg-if",
"core-foundation 0.10.0",
"core-foundation-sys",
"io-kit-sys",
"libudev",
"mach2 0.4.3",
"nix 0.26.4",
"scopeguard",
"unescaper",
"windows-sys 0.52.0",
]
[[package]]
name = "sha1"
version = "0.10.6"
@@ -7126,10 +7428,7 @@ dependencies = [
[[package]]
name = "stacksafe-macro"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "172175341049678163e979d9107ca3508046d4d2a7c6682bee46ac541b17db69"
dependencies = [
"proc-macro-error2",
"quote",
"syn",
]
@@ -7216,7 +7515,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",
@@ -7466,6 +7765,17 @@ dependencies = [
"xattr 1.6.1",
]
[[package]]
name = "tauri-winrt-notification"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f89f5fb70d6f62381f5d9b2ba9008196150b40b75f3068eb24faeddf1c686871"
dependencies = [
"quick-xml 0.31.0",
"windows 0.56.0",
"windows-version",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -7854,6 +8164,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"
@@ -8021,6 +8349,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "unescaper"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4"
dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "unicase"
version = "2.9.0"
@@ -8182,7 +8519,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",
@@ -8198,7 +8535,7 @@ dependencies = [
"itertools 0.14.0",
"libc",
"log",
"mach2",
"mach2 0.5.0",
"nix 0.29.0",
"percent-encoding",
"regex",
@@ -8221,7 +8558,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",
@@ -8615,6 +8952,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"
@@ -8824,6 +9170,16 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132"
dependencies = [
"windows-core 0.56.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.57.0"
@@ -8900,6 +9256,18 @@ dependencies = [
"windows-core 0.62.2",
]
[[package]]
name = "windows-core"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6"
dependencies = [
"windows-implement 0.56.0",
"windows-interface 0.56.0",
"windows-result 0.1.2",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.57.0"
@@ -8973,6 +9341,17 @@ dependencies = [
"windows-threading 0.2.1",
]
[[package]]
name = "windows-implement"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-implement"
version = "0.57.0"
@@ -9006,6 +9385,17 @@ dependencies = [
"syn",
]
[[package]]
name = "windows-interface"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.57.0"
@@ -9277,6 +9667,15 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-version"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
@@ -9442,6 +9841,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "winreg"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
dependencies = [
"cfg-if",
"windows-sys 0.48.0",
]
[[package]]
name = "winreg"
version = "0.55.0"
@@ -10098,7 +10507,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",
@@ -10127,7 +10536,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",
@@ -10138,7 +10547,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"
+56 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ashell"
version = "0.4.5"
version = "0.5.2-fix1"
edition = "2024"
authors = ["ashell contributors"]
description = "A GPUI Component based SSH and local terminal client"
@@ -9,10 +9,13 @@ 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"] }
csv = "1"
directories = "5"
encoding_rs = "0.8"
flate2 = "1"
gpui = { git = "https://github.com/zed-industries/zed" }
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "runtime_shaders", "wayland", "x11"] }
@@ -31,6 +34,7 @@ serde_json = "1"
ssh-key = "0.6"
sysinfo = "0.33"
tar = "0.4"
tempfile = "3"
thiserror = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "net", "time", "fs"] }
tracing = "0.1"
@@ -44,6 +48,52 @@ 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"
serialport = "4"
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
sha2 = "0.10"
[dev-dependencies]
libc = "0.2"
[target.'cfg(target_os = "windows")'.dependencies]
raw-window-handle = "0.6"
winrt-notification = { package = "tauri-winrt-notification", version = "=0.2.1" }
windows = { version = "0.57", features = [
"Win32_Foundation",
"Win32_System_Com",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
"UI_Notifications",
] }
winreg = "0.52"
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
desktop-notify = { package = "notify-rust", version = "=4.11.5", default-features = false }
[target.'cfg(target_os = "macos")'.dependencies]
mac-notification-sys = "=0.6.15"
objc2 = "0.6.4"
objc2-app-kit = { version = "0.3.2", features = ["NSApplication", "NSDockTile"] }
objc2-foundation = { version = "0.3.2", features = ["NSString", "NSUserNotification"] }
[package.metadata.deb]
maintainer = "ashell contributors"
license-file = ["LICENSE"]
extended-description = "A GPUI Component based SSH and local terminal client"
section = "terminal"
priority = "optional"
assets = [
["target/release/ashell", "usr/bin/", "755"],
["assets/ashell.desktop", "usr/share/applications/", "644"],
["assets/icons/ashell.png", "usr/share/icons/hicolor/256x256/apps/", "644"],
]
[profile.release]
opt-level = 3
@@ -53,3 +103,8 @@ strip = true
[build-dependencies]
winres = "0.1.12"
# Remove these overrides after GPUI moves off block 0.1 and stacksafe 0.1.
[patch.crates-io]
block = { git = "https://github.com/Dicklesworthstone/rust-block", rev = "b39ae859d1ee8e8cb5eef6a516471f1578d26b96" }
stacksafe-macro = { path = "patches/stacksafe-macro" }
+80 -3
View File
@@ -20,7 +20,11 @@ v0.4 builds on the v0.3 foundation and focuses on more capable workspace operati
## Download
You can download the latest pre-compiled releases for macOS, Windows, and Linux from the [GitHub Releases page](https://github.com/rust-kotlin/ashell/releases/latest).
You can download the latest installers from the [GitHub Releases page](https://github.com/rust-kotlin/ashell/releases/latest):
- macOS: `.dmg` installers for x64 and arm64, plus portable `.zip` archives.
- Windows: `Setup.exe` installers for x64, x86, and arm64, plus portable `.zip` archives.
- Linux: a portable x64 `.tar.gz` archive.
## Mac Installation Guide
@@ -43,14 +47,27 @@ brew upgrade ashell --cask
### 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.
1. Download the `.dmg` matching your processor from the [Releases page](https://github.com/rust-kotlin/ashell/releases/latest).
2. Open the DMG and drag `ashell.app` into **Applications**.
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
```
## Windows Installation Guide
Download the appropriate installer from the [Releases page](https://github.com/rust-kotlin/ashell/releases/latest):
| System architecture | Installer suffix |
| --- | --- |
| Intel/AMD 64-bit | `windows-x64-setup.exe` |
| Intel/AMD 32-bit | `windows-x86-setup.exe` |
| Windows on ARM | `windows-arm64-setup.exe` |
The installer can optionally create a desktop shortcut and registers ashell in the Start menu and the Windows uninstall list.
The installer is not yet signed with a commercial code-signing certificate, so Windows SmartScreen may display a security warning.
## Features
The current version provides a fully-featured GPUI-native workspace:
@@ -73,6 +90,33 @@ To run the application locally:
cargo run --release
```
## Codex and Terminal Completion Notifications
ashell supports generic OSC 9 completion notifications. For Codex CLI, add the following to
`~/.codex/config.toml`:
```toml
[tui]
notifications = ["agent-turn-complete", "approval-requested"]
notification_method = "osc9"
notification_condition = "unfocused"
```
When an eligible notification originates in the background or a non-visible tab, ashell shows a
native notification and a flashing bell on the corresponding tab. Unread notifications also add a
red badge to the macOS Dock icon or a red overlay to the Windows taskbar icon. Clicking the native
notification activates ashell and switches to the originating terminal tab; viewing that tab clears
its unread state. No reminder is shown when ashell is active and the notification originates in the
currently visible tab. An unselected tab shows a loading indicator beside its title for two seconds
after receiving terminal output. Continued output refreshes the indicator, while the selected tab
does not show it.
You can test a generic OSC 9 notification with:
```bash
printf '\033]9;Task completed\007'
```
## Package macOS App
```bash
@@ -82,6 +126,39 @@ open target/release/ashell.app
The packaging script creates a standard `.app` bundle. It does not attach an entitlements file, and after signing, it verifies that `com.apple.security.app-sandbox` is not present (meaning it runs non-sandboxed).
## Package Linux (Debian/Ubuntu)
### Prerequisites
```bash
sudo apt install pkg-config libfontconfig1-dev
cargo install cargo-deb
```
### Build .deb
```bash
cargo build --release
cargo deb
```
The `.deb` file will be generated at:
```
target/debian/ashell_0.4.8-1_amd64.deb
```
### Install
```bash
sudo dpkg -i target/debian/ashell_0.4.8-1_amd64.deb
```
After installation, you can launch from the application menu or by running `ashell` in the terminal. The `.deb` package includes:
- `/usr/bin/ashell` — Main binary
- `/usr/share/applications/ashell.desktop` — Desktop entry
- `/usr/share/icons/hicolor/256x256/apps/ashell.png` — Application icon
## License
This project is licensed under the [GPL-3.0-or-later License](LICENSE).
+84 -3
View File
@@ -20,7 +20,11 @@ v0.4 在 v0.3 打下的基础上,重点带来了更完整的工作区操作能
## 下载
您可以从 [GitHub Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载 macOS、Windows 和 Linux 版本的最新预编译程序。
您可以从 [GitHub Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载最新安装包:
- macOS:x64 和 arm64 的 `.dmg`,同时提供便携 `.zip`。
- Windows:x64、x86 和 arm64 的 `Setup.exe`,同时提供便携 `.zip`。
- Linux:x64 便携 `.tar.gz`。
## Mac 安装指南
@@ -43,14 +47,27 @@ brew upgrade ashell --cask
### 方法 2: 手动下载
1. 从 [Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载并解压。
2. 将 `ashell.app` 拖入或移动到 **应用程序 (Applications)** 目录。
1. 从 [Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载与处理器匹配的 `.dmg`。
2. 打开 DMG,将 `ashell.app` 拖入 **Applications** 目录。
3. 由于应用采用本地签名,初次启动时如果系统提示“App 已损坏,无法打开”,请打开终端(Terminal)并执行以下命令:
```bash
sudo xattr -cr /Applications/ashell.app
```
## Windows 安装指南
从 [Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载对应安装程序:
| 系统架构 | 安装包名称后缀 |
| --- | --- |
| Intel/AMD 64 位 | `windows-x64-setup.exe` |
| Intel/AMD 32 位 | `windows-x86-setup.exe` |
| Windows on ARM | `windows-arm64-setup.exe` |
运行安装程序后,可选择是否创建桌面快捷方式;应用也会出现在开始菜单和系统卸载列表中。
当前安装包尚未使用商业代码签名证书,Windows SmartScreen 可能显示安全提醒。
## 功能特性
当前版本提供了一个功能完备的 GPUI 原生工作区:
@@ -73,6 +90,37 @@ sudo xattr -cr /Applications/ashell.app
cargo run --release
```
## 终端通知与任务提醒
ashell 支持 [OSC 9](https://iterm2.com/documentation-escape-codes.html)、
[OSC 99](https://sw.kovidgoyal.net/kitty/desktop-notifications/)、
[OSC 777](https://wezterm.org/escape-sequences.html) 和终端响铃(BEL),通知能力不绑定
Codex 或其它特定工具。只要 CLI、脚本或远程程序发送受支持的终端控制序列,本地终端、
SSH 和串口标签页都会将其转换为 macOS 或 Windows 系统通知,无需修改工具配置或伪装
其它终端。
OSC 9 携带通知正文;OSC 777 和 OSC 99 可以同时携带标题与正文,OSC 99 还支持
`always`、`unfocused` 和 `invisible` 显示时机。BEL 本身不含正文,因此仅显示通用提醒,
不会读取或推断当前终端画面中的内容。
符合显示时机的通知来自后台或非当前可见标签时,ashell 会显示系统通知,标签按钮以
闪动铃铛标记未读状态,并在 macOS Dock 或 Windows 任务栏图标上显示红色徽标。点击
系统通知会激活 ashell 并切换到对应的终端标签,查看对应标签后自动清除未读状态。
如果应用已激活且通知来自当前可见标签,则不重复提醒。未选中的标签在最近 2 秒内收到
终端输出时,会在标题左侧显示 Loading 动画;持续输出会刷新显示时间,当前标签不会显示。
可以分别使用下面的命令测试四种提醒:
```bash
sleep 3; printf '\033]9;Task completed\007'
sleep 3; printf '\033]777;notify;Deploy;Production is ready\033\\'
sleep 3; printf '\033]99;i=ashell-test:d=0;Build completed\033\\'; printf '\033]99;i=ashell-test:p=body;Artifacts are ready\033\\'
sleep 3; printf '\007'
```
执行后在 3 秒内切换到其他应用。macOS 第一次收到通知时可能会询问权限;如果仍未显示,
请在“系统设置 > 通知 > ashell”中确认已允许通知。
## 打包 macOS 应用
```bash
@@ -82,6 +130,39 @@ open target/release/ashell.app
该打包脚本会创建一个标准的 `.app` 应用程序包。它没有附加 entitlements 文件,并且在签名后会验证是否不存在 `com.apple.security.app-sandbox`(这意味着它在非沙盒模式下运行)。
## 打包 Linux (Debian/Ubuntu)
### 前置条件
```bash
sudo apt install pkg-config libfontconfig1-dev
cargo install cargo-deb
```
### 构建 .deb 包
```bash
cargo build --release
cargo deb
```
生成的 `.deb` 文件位于:
```
target/debian/ashell_0.4.9-1_amd64.deb
```
### 安装
```bash
sudo dpkg -i target/debian/ashell_0.4.9-1_amd64.deb
```
安装后可通过应用菜单或命令行 `ashell` 启动。`.deb` 包包含以下内容:
- `/usr/bin/ashell` — 主程序
- `/usr/share/applications/ashell.desktop` — 桌面入口
- `/usr/share/icons/hicolor/256x256/apps/ashell.png` — 应用图标
## 协议
本项目采用 [GPL-3.0-or-later 协议](LICENSE) 开源。
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Name=Ashell
Comment=A GPUI Component based SSH and local terminal client
Exec=ashell
Icon=ashell
Terminal=false
Type=Application
Categories=System;TerminalEmulator;Utility;
StartupWMClass=ashell
+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"
}
}
}
}
]
}
+242 -24
View File
@@ -1,7 +1,21 @@
remote_files: "REMOTE FILES"
open_ssh_tab_sftp: "Open an SSH tab to browse SFTP files."
sftp_connecting: "sftp connecting..."
refresh: "Refresh"
sftp_more_actions: "More remote file actions"
sftp_more_actions_short: "More"
reveal_current_directory: "Reveal current directory"
collapse_all_directories: "Collapse all directories"
parent_directory: "Parent directory"
create_or_upload: "Create or upload"
add: "Add"
directories: "DIRECTORIES"
expand_directory: "Expand directory"
collapse_directory: "Collapse directory"
retry_directory: "Retry loading directory"
loading_directory: "Loading directory..."
directory_load_failed: "Failed to load directory: %{reason}"
empty_directory: "This directory is empty"
selected_items: "%{count} selected"
upload_file: "Upload File"
upload_folder: "Upload Folder"
hidden: "Hidden"
@@ -11,9 +25,35 @@ download_count: "Download (%{count})"
name: "NAME"
size: "SIZE"
modified: "MODIFIED"
new_ssh_connection: "New SSH Connection"
new_connection: "New Connection"
edit_connection: "Edit Connection"
create_or_edit_ssh_session: "Create or edit an SSH session"
new_serial_connection: "New Serial Connection"
edit_serial_connection: "Edit Serial Connection"
serial_port: "Serial Port Path"
baud_rate: "Baud Rate"
session_name: "Session Name"
connection_group: "Group"
connection_group_name: "Group name"
new_connection_group: "New Group"
rename_connection_group: "Rename Group"
delete_connection_group: "Delete Group"
ungrouped: "Ungrouped"
move_to_group: "Move"
group_name_required: "Group name is required."
group_name_exists: "A group with this name already exists."
connection_group_created: "Created group %{name}"
connection_group_renamed: "Renamed group to %{name}"
connection_group_deleted: "Deleted group and moved %{count} connections to Ungrouped"
connections_moved_to_group: "Moved %{count} connections to %{name}"
new_folder: "New Folder"
rename: "Rename"
new_name: "New name"
name_required: "Name is required."
invalid_remote_name: "Name cannot be '.', '..', or contain '/'."
rename_success: "Renamed to %{name}"
rename_failed: "Rename failed: %{err}"
sftp_connection_unavailable: "The SFTP connection is unavailable."
create_folder_success: "Created folder: %{name}"
create_folder_failed: "Failed to create folder: %{err}"
delete_selected: "Delete"
@@ -23,21 +63,23 @@ system_path_warning: "WARNING: You are attempting to delete critical system path
delete_success: "Successfully deleted %{count} items"
delete_failed: "Failed to delete: %{err}"
deleting_paths: "Deleting %{count} items..."
confirm: "Confirm"
local_terminal: "Local Terminal"
open_local_shell_tab: "Open a new local shell tab"
connect: "Connect"
edit: "Edit"
done: "Done"
delete: "Delete"
host: "Host"
port: "Port"
user: "User"
password: "Password"
key: "Key"
private_key_path: "Private Key Path"
private_key_data: "Private Key Data"
save_and_connect: "Save & Connect"
ssh_config: "Config(~/.ssh/config)"
ssh_config_empty: "No entries found in ~/.ssh/config"
save: "Save"
cancel: "Cancel"
more: "More"
host_and_user_required: "Host and user are required"
language: "Language"
follow_system: "Follow System"
@@ -47,35 +89,105 @@ use_light_mode: "Use Light Mode"
use_dark_mode: "Use Dark Mode"
english: "English"
chinese: "简体中文"
idle_no_session: "idle / No session"
system: "SYSTEM"
cpu: "CPU"
mem: "MEM"
swap: "SWAP"
net: "NET"
disk: "DISK"
global_load: "global load"
live: "Live"
idle: "idle"
saved: "SAVED"
add_ssh: "+ ssh"
system_info: "SYSTEM"
memory: "Memory"
cpu_percent: "CPU"
memory_usage_short: "Memory"
process: "Process"
system_processes: "System Processes"
filter_processes: "Filter processes"
refresh_processes: "Refresh process list"
no_processes: "No process data"
no_matching_processes: "No matching processes"
process_details: "Process details"
copy_process: "Copy process information"
process_pid: "PID"
process_command: "Command"
process_summary: "%{name} · PID %{pid}"
network_ports: "Network Ports"
refresh_ports: "Refresh port list"
no_ports: "No network ports"
filter_ports: "Filter ports"
no_matching_ports: "No matching ports"
loading_ports: "Loading port list..."
protocol: "Protocol"
address: "Address"
state: "State"
loading_processes: "Loading process list..."
terminate_process: "Terminate Process"
confirm_terminate_process: "Terminate Process?"
confirm_terminate_process_desc: "Send a termination signal to PID %{pid}? Unsaved data may be lost."
terminating_process: "Sending termination signal to process %{pid}..."
process_terminated: "Termination signal sent to process %{pid}"
process_terminate_failed: "Failed to terminate process %{pid}: %{reason}"
connection_management: "CONNECTIONS"
manage_connections: "Manage connections"
import_connections: "Import"
export_connections: "Export"
import_export_connections: "Import & Export Connections"
new_connection_short: "New"
open_connection: "Open Connection"
filter_connections: "Filter connections"
no_connections: "No connections"
no_matching_connections: "No matching connections"
connections_exported: "Exported %{count} connections"
connections_import_summary: "Import complete: %{added} added, %{updated} updated, %{skipped} skipped"
connections_export_failed: "Export failed: %{reason}"
connections_import_failed: "Import failed: %{reason}"
connections_export_no_ssh: "No SSH connections are available to export"
connections_export_plaintext_title: "Export plaintext passwords?"
connections_export_plaintext_warning: "The CSV will contain passwords for %{count} SSH connections in plaintext. Private keys and passphrases are not included. Store it securely and remove it after migration."
connections_export_plaintext_warning_with_skipped: "The CSV will contain passwords for %{count} SSH connections in plaintext. Private keys and passphrases are not included, and %{skipped} non-SSH connections will not be exported. Store it securely and remove it after migration."
delete_selected_connections: "Delete"
connections_deleted: "Deleted %{count} connections"
delete_selected_commands: "Delete selected commands"
commands_deleted: "Deleted %{count} commands"
command_history: "Command History"
command_history_short: "History"
command_history_empty: "No command history"
filter_command_history: "Filter command history"
no_matching_commands: "No matching commands"
copy_command: "Copy command"
execute_command: "Execute command"
close_command_history: "Close command history"
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"
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"
position_hidden: "Hidden"
local_host: "local host"
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"
ssh_reconnect_pending: "Waiting for reconnect confirmation"
reconnect_ssh_title: "Reconnect"
reconnect_ssh_desc: "%{name} is not connected. Reconnect now?"
reconnect_ssh: "Reconnect"
preview_failed: "Preview failed: %{err}"
downloading_file: "Downloading %{base}..."
@@ -101,8 +213,8 @@ no_transfers_yet: "No transfers yet"
downloading: "Downloading"
paused: "Paused"
completed: "Completed"
terminal_attention_required: "Terminal requires attention"
failed: "Failed"
cancelled: "Cancelled"
interrupted: "Interrupted"
session: "Session"
zombie: "Zombie"
@@ -111,39 +223,106 @@ zombie_reason: "This transfer was interrupted because the application exited. Th
software_builtin: "Built-in"
retry: "Retry"
right_click_copy_paste: "Right-click Copy/Paste"
copy_paste_hint: "Regardless of this switch, you can always use %{key} + C/V to copy/paste."
keyword_highlight: "Terminal Keyword Highlighting"
remember_tabs: "Remember Tabs"
lock_layout: "Lock Window Layout"
lock_layout_hint: "Lock the position of the sidebar and the SFTP panel."
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}"
edit_file_tooltip: "Edit directly in ashell and save changes to the remote server."
terminal_encoding: "Terminal Encoding"
local_terminal_encoding: "Local Terminal Encoding"
local_terminal_encoding_changed: "Local terminal encoding changed to %{encoding}."
default_local_terminal: "Default Local Terminal"
local_terminal_windows_powershell: "Windows PowerShell"
local_terminal_powershell_7: "PowerShell 7"
local_terminal_cmd: "Command Prompt"
local_terminal_git_bash: "Git Bash"
local_terminal_not_installed: "Not installed"
local_terminal_shell_fallback: "The selected local terminal is unavailable; switched back to %{terminal}."
local_terminal_shell_changed: "Default local terminal changed to %{terminal}; new terminals will use it."
file_encoding: "File Encoding"
terminal_encoding_changed: "Terminal encoding changed to %{encoding}; new input and output will use it."
file_encoding_changed: "File encoding changed to %{encoding}."
encoding_decode_warning: "Some bytes are invalid in %{encoding}. Try another encoding."
encoding_encode_failed: "Some characters cannot be represented in %{encoding}. Choose another encoding before saving."
loading_file_content: "Loading file content..."
saving_file_content: "Saving file..."
editor_content_too_large: "Content exceeds the %{max} in-app editing limit."
unsaved_changes: "Unsaved changes"
file_content_loaded: "File loaded"
open_remote_file_failed: "Failed to open remote file: %{err}"
remote_file_saved: "Remote file saved"
save_remote_file_failed: "Failed to save remote file: %{err}"
sync_cwd_tooltip: "Sync path from active terminal"
sync_cwd: "Sync Path"
scroll: "Scroll ↓"
clone: "Clone"
about_feedback_hint: "If you encounter a problem or have a suggestion, please report it on our GitHub project."
about_description: "A GPUI Component based SSH and local terminal client"
about_tips_title: "Shortcuts & Usage Tips:"
about_tip_backspace: "Unbinding Shortcuts: Press Backspace while recording a shortcut in Settings -> Keybindings to clear/unbind it."
about_tip_close_shortcuts: "Default Close Shortcuts: macOS uses ⌘+Q (Quit App) / ⌘+W (Close Tab); Windows/Linux uses Alt+F4 or Ctrl+Q (Quit App) / Ctrl+W (Close Tab)."
about_tip_conflict_shortcuts: "Potential Shortcut Conflicts: On Linux/Windows, default Ctrl shortcuts (e.g. Ctrl+W to close tab, Ctrl+L to focus pane) will intercept terminal Shell bindings (e.g. Ctrl+W to backward-kill-word, Ctrl+L to clear screen). Rebind to Ctrl+Shift+W or press Backspace to unbind in Settings -> Keybindings."
version: "Version"
close: "Close"
menu_about_ashell: "About Ashell"
menu_services: "Services"
menu_hide_ashell: "Hide Ashell"
menu_hide_others: "Hide Others"
menu_show_all: "Show All"
menu_quit_ashell: "Quit Ashell"
menu_file: "File"
menu_edit: "Edit"
menu_view: "View"
menu_help: "Help"
menu_close_window: "Close Window"
menu_undo: "Undo"
menu_redo: "Redo"
menu_cut: "Cut"
menu_copy: "Copy"
menu_paste: "Paste"
menu_select_all: "Select All"
menu_toggle_sidebar: "Toggle Sidebar"
menu_toggle_full_screen: "Enter Full Screen"
menu_minimize: "Minimize"
menu_zoom: "Zoom"
menu_project_website: "Ashell Project Website"
settings_general: "General"
settings_key_bindings: "Key Bindings"
settings_help: "Help"
settings_about: "About"
settings_group_appearance: "Appearance"
settings_group_font: "Font"
settings_group_terminal: "Terminal"
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"
none: "None"
settings_open_settings: "Settings"
settings_open_session: "Open Session"
settings_tab_list: "Tab List"
settings_open_transfers: "Open Transfer History"
files_downloading: "%{count} files downloading"
files_uploading: "%{count} files uploading"
panel_minimize: "Minimize Panel"
panel_minimize_short: "Collapse"
panel_expand: "Expand Panel"
settings_new_ssh: "New SSH"
panel_expand_short: "Expand"
settings_new_connection: "New Connection"
settings_open_search: "Search"
settings_quit_application: "Quit Application"
search: "Search"
no_results: "No results"
settings_toggle_sidebar: "Toggle Sidebar"
settings_toggle_sftp_zoom: "Toggle SFTP Zoom"
settings_toggle_sftp_zoom: "Toggle SFTP Panel Size"
settings_focus_pane_left: "Focus Pane Left"
settings_focus_pane_right: "Focus Pane Right"
settings_focus_pane_up: "Focus Pane Up"
@@ -153,4 +332,43 @@ 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'
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_host: "Proxy Host"
global_proxy_port: "Proxy Port"
global_proxy_user: "Proxy Username"
global_proxy_password: "Proxy Password"
save_proxy: "Save Proxy"
settings_config_file: "Config File"
settings_backup_local_desc: "Export or import your unencrypted configuration file locally."
backup_export: "Export Config"
backup_import: "Import Config"
+244 -28
View File
@@ -1,7 +1,21 @@
remote_files: "远程文件"
open_ssh_tab_sftp: "打开一个 SSH 会话面板以浏览 SFTP 文件。"
open_ssh_tab_sftp: "打开 SSH 标签页以浏览 SFTP 文件。"
sftp_connecting: "正在连接 SFTP..."
refresh: "刷新"
sftp_more_actions: "更多远程文件操作"
sftp_more_actions_short: "更多"
reveal_current_directory: "定位当前目录"
collapse_all_directories: "折叠全部目录"
parent_directory: "返回上级目录"
create_or_upload: "新建或上传"
add: "添加"
directories: "目录"
expand_directory: "展开目录"
collapse_directory: "折叠目录"
retry_directory: "重新加载目录"
loading_directory: "正在加载目录..."
directory_load_failed: "目录加载失败:%{reason}"
empty_directory: "当前目录为空"
selected_items: "已选择 %{count} 项"
upload_file: "上传文件"
upload_folder: "上传文件夹"
hidden: "显示隐藏文件"
@@ -11,9 +25,35 @@ download_count: "下载 (%{count})"
name: "名称"
size: "大小"
modified: "修改时间"
new_ssh_connection: "新建 SSH 连接"
new_connection: "新建连接"
edit_connection: "编辑连接"
create_or_edit_ssh_session: "创建或编辑一个 SSH 会话"
new_serial_connection: "新建串口连接"
edit_serial_connection: "编辑串口连接"
serial_port: "串口设备路径"
baud_rate: "波特率"
session_name: "会话名称"
connection_group: "分组"
connection_group_name: "分组名称"
new_connection_group: "新建分组"
rename_connection_group: "重命名分组"
delete_connection_group: "删除分组"
ungrouped: "未分组"
move_to_group: "移动"
group_name_required: "分组名称不能为空。"
group_name_exists: "已存在同名分组。"
connection_group_created: "已创建分组 %{name}"
connection_group_renamed: "已重命名分组为 %{name}"
connection_group_deleted: "已删除分组,并将 %{count} 个连接移至未分组"
connections_moved_to_group: "已将 %{count} 个连接移动到 %{name}"
new_folder: "新建文件夹"
rename: "重命名"
new_name: "新名称"
name_required: "名称不能为空。"
invalid_remote_name: "名称不能是“.”、“..”或包含“/”。"
rename_success: "已重命名为 %{name}"
rename_failed: "重命名失败:%{err}"
sftp_connection_unavailable: "SFTP 连接不可用。"
create_folder_success: "成功创建文件夹: %{name}"
create_folder_failed: "创建文件夹失败: %{err}"
delete_selected: "删除"
@@ -25,20 +65,21 @@ delete_failed: "删除失败: %{err}"
deleting_paths: "正在删除 %{count} 个项..."
confirm: "确认"
local_terminal: "本地终端"
open_local_shell_tab: "打开一个新的本地终端页面"
open_local_shell_tab: "打开一个新的本地终端标签页"
connect: "连接"
edit: "编辑"
done: "完成"
delete: "删除"
host: "主机 (Host)"
port: "端口 (Port)"
user: "用户 (User)"
password: "密码 (Password)"
key: "密钥 (Key)"
private_key_path: "私钥路径"
private_key_data: "私钥内容"
save_and_connect: "保存并连接"
ssh_config: "配置(~/.ssh/config)"
ssh_config_empty: "~/.ssh/config 中未找到条目"
save: "保存"
cancel: "取消"
more: "更多"
host_and_user_required: "主机名和用户名不能为空"
language: "显示语言"
follow_system: "跟随系统"
@@ -48,35 +89,105 @@ use_light_mode: "使用浅色模式"
use_dark_mode: "使用深色模式"
english: "English"
chinese: "简体中文"
idle_no_session: "空闲 / 未连接会话"
system: "系统"
cpu: "CPU"
mem: "内存"
swap: "虚拟内存"
net: "网络"
disk: "磁盘"
global_load: "全局负载"
live: "实时"
idle: "空闲"
saved: "已保存会话"
add_ssh: "+ SSH"
system_info: "系统信息"
memory: "内存"
cpu_percent: "CPU"
memory_usage_short: "内存"
process: "进程"
system_processes: "系统进程"
filter_processes: "筛选进程"
refresh_processes: "刷新进程列表"
no_processes: "暂无进程数据"
no_matching_processes: "没有匹配的进程"
process_details: "进程详细信息"
copy_process: "复制进程信息"
process_pid: "PID"
process_command: "命令"
process_summary: "%{name} · PID %{pid}"
network_ports: "网络端口"
refresh_ports: "刷新端口列表"
no_ports: "暂无网络端口"
filter_ports: "筛选端口"
no_matching_ports: "没有匹配的端口"
loading_ports: "正在加载端口列表..."
protocol: "协议"
address: "地址"
state: "状态"
loading_processes: "正在加载进程列表..."
terminate_process: "终止进程"
confirm_terminate_process: "确认终止进程?"
confirm_terminate_process_desc: "确定要向 PID %{pid} 发送终止信号吗?未保存的数据可能会丢失。"
terminating_process: "正在向进程 %{pid} 发送终止信号..."
process_terminated: "已向进程 %{pid} 发送终止信号"
process_terminate_failed: "终止进程 %{pid} 失败:%{reason}"
connection_management: "连接"
manage_connections: "管理连接"
import_connections: "导入连接"
export_connections: "导出连接"
import_export_connections: "导入与导出连接"
new_connection_short: "新建"
open_connection: "打开连接"
filter_connections: "筛选连接"
no_connections: "暂无连接"
no_matching_connections: "没有匹配的连接"
connections_exported: "已导出 %{count} 个连接"
connections_import_summary: "导入完成:新增 %{added} 个,更新 %{updated} 个,跳过 %{skipped} 个"
connections_export_failed: "导出失败:%{reason}"
connections_import_failed: "导入失败:%{reason}"
connections_export_no_ssh: "没有可导出的 SSH 连接"
connections_export_plaintext_title: "导出明文密码?"
connections_export_plaintext_warning: "CSV 将以明文保存 %{count} 个 SSH 连接的密码,不包含私钥和私钥口令。请妥善保管,并在迁移后及时移除。"
connections_export_plaintext_warning_with_skipped: "CSV 将以明文保存 %{count} 个 SSH 连接的密码,不包含私钥和私钥口令;另有 %{skipped} 个非 SSH 连接不会导出。请妥善保管,并在迁移后及时移除。"
delete_selected_connections: "删除"
connections_deleted: "已删除 %{count} 个连接"
delete_selected_commands: "删除选中命令"
commands_deleted: "已删除 %{count} 条命令"
command_history: "历史命令"
command_history_short: "历史"
command_history_empty: "暂无历史命令"
filter_command_history: "筛选历史命令"
no_matching_commands: "没有匹配的命令"
copy_command: "复制命令"
execute_command: "执行命令"
close_command_history: "关闭历史命令"
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: "终端字体"
monitoring_position: "系统监控显示位置"
cursor_style: "光标样式"
cursor_style_default: "默认"
cursor_style_blink: "闪烁"
cursor_style_beam: "细线"
cursor_style_beam_blink: "细线闪烁"
monitoring_position: "监控显示位置"
position_bottom: "底部"
position_sidebar: "侧边栏"
position_hidden: "不显示"
local_host: "本地主机"
local_terminal_opened: "已打开本地终端"
connecting: "正在连接"
starting_connection: "开始连接..."
connection_failed: "连接失败"
session_disconnected: "会话已断开: %{reason}"
press_enter_to_reconnect: "按 Enter 重新连接"
ssh_reconnect_pending: "等待确认后重新连接"
reconnect_ssh_title: "重新连接"
reconnect_ssh_desc: "%{name} 当前未连接,是否现在重新连接?"
reconnect_ssh: "重新连接"
preview_failed: "预览失败: %{err}"
downloading_file: "正在下载 %{base}..."
@@ -102,8 +213,8 @@ no_transfers_yet: "暂无传输记录"
downloading: "正在下载"
paused: "已暂停"
completed: "已完成"
terminal_attention_required: "终端中有需要关注的新状态"
failed: "失败"
cancelled: "已取消"
interrupted: "已中断"
session: "会话"
zombie: "僵尸任务"
@@ -112,40 +223,106 @@ zombie_reason: "此传输任务因程序退出而中断,传输进程已不存
software_builtin: "软件内置"
retry: "重试"
right_click_copy_paste: "右键复制/粘贴"
copy_paste_hint: "不管是否开启此开关,都可以使用 %{key} + C/V 快捷键进行复制和粘贴。"
keyword_highlight: "终端关键词高亮"
remember_tabs: "记忆标签页"
lock_layout: "锁定窗口布局"
lock_layout_hint: "锁定侧边栏和 SFTP 面板的位置。"
copy_paste_hint: "选中后左键点击也可复制。"
settings_copy: "复制选区"
settings_paste: "粘贴剪贴板"
edit_file: "编辑文件"
edit_file_tooltip: "下载到临时目录,使用默认编辑器打开,并在保存后自动上传。"
auto_saved_and_uploaded: "已自动保存并上传: %{base}"
edit_file_tooltip: "直接在 ashell 内编辑,并将修改保存到远程服务器。"
terminal_encoding: "终端编码"
local_terminal_encoding: "本地终端编码"
local_terminal_encoding_changed: "本地终端编码已切换为 %{encoding}。"
default_local_terminal: "默认本地终端"
local_terminal_windows_powershell: "Windows PowerShell"
local_terminal_powershell_7: "PowerShell 7"
local_terminal_cmd: "命令提示符"
local_terminal_git_bash: "Git Bash"
local_terminal_not_installed: "未安装"
local_terminal_shell_fallback: "所选本地终端不可用,已改回 %{terminal}。"
local_terminal_shell_changed: "默认本地终端已切换为 %{terminal},新终端生效。"
file_encoding: "文件编码"
terminal_encoding_changed: "终端编码已切换为 %{encoding},新的输入和输出将使用此编码。"
file_encoding_changed: "文件编码已切换为 %{encoding}。"
encoding_decode_warning: "部分字节无法使用 %{encoding} 正确解码,请尝试其它编码。"
encoding_encode_failed: "部分字符无法使用 %{encoding} 表示,请更换编码后再保存。"
loading_file_content: "正在加载文件内容..."
saving_file_content: "正在保存文件..."
editor_content_too_large: "内容超过应用内编辑器的 %{max} 限制。"
unsaved_changes: "有未保存的修改"
file_content_loaded: "文件已加载"
open_remote_file_failed: "打开远程文件失败:%{err}"
remote_file_saved: "远程文件已保存"
save_remote_file_failed: "保存远程文件失败:%{err}"
sync_cwd_tooltip: "从当前终端同步路径"
sync_cwd: "同步路径"
scroll: "滑动查看 ↓"
clone: "克隆"
about_feedback_hint: "如果遇到问题或者有什么建议,请前往我们的 GitHub 项目中反馈。"
about_description: "基于 GPUI Component 的 SSH 与本地终端客户端"
about_tips_title: "快捷键与使用提示:"
about_tip_backspace: "快捷键解绑:在“设置 -> 快捷键”中录制按键时按 Backspace (退格键) 即可置空/解绑该快捷键。"
about_tip_close_shortcuts: "默认关闭快捷键:macOS 为 ⌘+Q (退出应用) / ⌘+W (关闭标签);Windows / Linux 为 Alt+F4 或 Ctrl+Q (退出应用) / Ctrl+W (关闭标签)。"
about_tip_conflict_shortcuts: "潜在快捷键冲突:Linux / Windows 默认修饰键为 Ctrl,应用默认快捷键(如 Ctrl+W 关闭标签、Ctrl+L 切换窗格等)会挡住终端 Shell 的原生快捷键(如 Bash/Zsh 中 Ctrl+W 清除单词、Ctrl+L 清屏)。建议在“设置 -> 快捷键”中改为 Ctrl+Shift+W 或按 Backspace 置空解绑。"
version: "版本"
close: "关闭"
menu_about_ashell: "关于 Ashell"
menu_services: "服务"
menu_hide_ashell: "隐藏 Ashell"
menu_hide_others: "隐藏其它应用"
menu_show_all: "全部显示"
menu_quit_ashell: "退出 Ashell"
menu_file: "文件"
menu_edit: "编辑"
menu_view: "视图"
menu_help: "帮助"
menu_close_window: "关闭窗口"
menu_undo: "撤销"
menu_redo: "重做"
menu_cut: "剪切"
menu_copy: "复制"
menu_paste: "粘贴"
menu_select_all: "全选"
menu_toggle_sidebar: "切换侧边栏"
menu_toggle_full_screen: "进入全屏"
menu_minimize: "最小化"
menu_zoom: "缩放"
menu_project_website: "Ashell 项目主页"
settings_general: "常规"
settings_key_bindings: "快捷键"
settings_help: "帮助"
settings_about: "关于"
settings_group_appearance: "外观"
settings_group_font: "字体"
settings_group_terminal: "终端"
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: "打开设置"
none: "无"
settings_open_settings: "设置"
settings_open_session: "打开会话"
settings_tab_list: "标签列表"
settings_open_transfers: "打开传输历史"
files_downloading: "%{count} 个文件正在下载"
files_uploading: "%{count} 个文件正在上传"
panel_minimize: "折叠面板"
panel_minimize_short: "折叠"
panel_expand: "展开面板"
settings_new_ssh: "新建 SSH"
settings_toggle_sidebar: "缩放侧边栏"
settings_toggle_sftp_zoom: "缩放 SFTP 面板"
panel_expand_short: "展开"
settings_new_connection: "新建连接"
settings_open_search: "搜索"
settings_quit_application: "退出应用"
search: "搜索"
no_results: "无结果"
settings_toggle_sidebar: "切换侧边栏"
settings_toggle_sftp_zoom: "切换 SFTP 面板大小"
settings_focus_pane_left: "向左切换焦点"
settings_focus_pane_right: "向右切换焦点"
settings_focus_pane_up: "向上切换焦点"
@@ -155,4 +332,43 @@ settings_split_pane_right: "向右拆分面板"
settings_split_pane_up: "向上拆分面板"
settings_split_pane_down: "向下拆分面板"
settings_close_pane: "关闭当前面板"
keybind_conflict: '"%{key}" 已被「%{action}」使用,请选择其他快捷键'
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_host: "代理地址"
global_proxy_port: "代理端口"
global_proxy_user: "代理用户名"
global_proxy_password: "代理密码"
save_proxy: "保存代理设置"
settings_config_file: "配置文件"
settings_backup_local_desc: "在本地导出或导入你未加密的配置文件。"
backup_export: "导出配置"
backup_import: "导入配置"
+417
View File
@@ -0,0 +1,417 @@
; *** Inno Setup version 6.5.0+ Chinese Simplified messages ***
;
; To download user-contributed translations of this file, go to:
; https://jrsoftware.org/files/istrans/
;
; Note: When translating this text, do not add periods (.) to the end of
; messages that didn't have them already, because on those messages Inno
; Setup adds the periods automatically (appending a period would result in
; two periods being displayed).
;
; Maintainer: Zhenghan Yang (Kira)
; Email: 847320916@QQ.com
; Github: https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
; Encoding: UTF-8
; Translation based on network resource
;
[LangOptions]
; The following three entries are very important. Be sure to read and
; understand the '[LangOptions] section' topic in the help file.
LanguageName=简体中文
; About LanguageID, to reference link:
; https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c
LanguageID=$0804
; LanguageCodePage should always be set if possible, even if this file is Unicode
; For English it's set to zero anyway because English only uses ASCII characters
LanguageCodePage=936
; If the language you are translating to requires special font faces or
; sizes, uncomment any of the following entries and change them accordingly.
;DialogFontName=
;DialogFontSize=9
;DialogFontBaseScaleWidth=7
;DialogFontBaseScaleHeight=15
;WelcomeFontName=Segoe UI
;WelcomeFontSize=14
[Messages]
; *** Application titles
SetupAppTitle=安装
SetupWindowTitle=安装 - %1
UninstallAppTitle=卸载
UninstallAppFullTitle=%1 卸载
; *** Misc. common
InformationTitle=信息
ConfirmTitle=确认
ErrorTitle=错误
; *** SetupLdr messages
SetupLdrStartupMessage=现在将安装 %1。您想要继续吗?
LdrCannotCreateTemp=无法创建临时文件。安装程序已中止
LdrCannotExecTemp=无法执行临时目录中的文件。安装程序已中止
HelpTextNote=
; *** Startup error messages
LastErrorMessage=%1。%n%n错误 %2: %3
SetupFileMissing=安装目录中缺少文件 %1。请修正这个问题或者获取程序的新副本。
SetupFileCorrupt=安装文件已损坏。请获取程序的新副本。
SetupFileCorruptOrWrongVer=安装文件已损坏,或是与这个安装程序的版本不兼容。请修正这个问题或获取新的程序副本。
InvalidParameter=无效的命令行参数:%n%n%1
SetupAlreadyRunning=安装程序已在运行。
WindowsVersionNotSupported=此程序不支持当前计算机运行的 Windows 版本。
WindowsServicePackRequired=此程序需要 %1 服务包 %2 或更高版本。
NotOnThisPlatform=此程序不能在 %1 上运行。
OnlyOnThisPlatform=此程序只能在 %1 上运行。
OnlyOnTheseArchitectures=此程序只能安装到为下列处理器架构设计的 Windows 版本中:%n%n%1
WinVersionTooLowError=此程序需要 %1 版本 %2 或更高。
WinVersionTooHighError=此程序不能安装于 %1 版本 %2 或更高。
AdminPrivilegesRequired=在安装此程序时您必须以管理员身份登录。
PowerUserPrivilegesRequired=在安装此程序时您必须以管理员身份或高级用户组身份登录。
SetupAppRunningError=安装程序检测到 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
UninstallAppRunningError=卸载程序检测到 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
; *** Startup questions
PrivilegesRequiredOverrideTitle=选择安装程序安装模式
PrivilegesRequiredOverrideInstruction=选择安装模式
PrivilegesRequiredOverrideText1=%1 可以为所有用户安装(需要管理员权限),或仅为您安装。
PrivilegesRequiredOverrideText2=%1 可以仅为您安装,或为所有用户安装(需要管理员权限)。
PrivilegesRequiredOverrideAllUsers=为所有用户安装(&A)
PrivilegesRequiredOverrideAllUsersRecommended=为所有用户安装(&A)(推荐)
PrivilegesRequiredOverrideCurrentUser=仅为我安装(&M)
PrivilegesRequiredOverrideCurrentUserRecommended=仅为我安装(&M)(推荐)
; *** Misc. errors
ErrorCreatingDir=安装程序无法创建目录“%1”
ErrorTooManyFilesInDir=无法在目录“%1”中创建文件,因为里面包含太多文件。
; *** Setup common messages
ExitSetupTitle=退出安装程序
ExitSetupMessage=安装程序尚未完成。如果现在退出,将不会安装该程序。%n%n您之后可以再次运行安装程序完成安装。%n%n现在退出安装程序吗?
AboutSetupMenuItem=关于安装程序(&A)...
AboutSetupTitle=关于安装程序
AboutSetupMessage=%1 版本 %2%n%3%n%n%1 主页:%n%4
AboutSetupNote=
TranslatorNote=简体中文翻译由 Kira(847320916@qq.com)维护。项目地址:https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
; *** Buttons
ButtonBack=< 上一步(&B)
ButtonNext=下一步(&N) >
ButtonInstall=安装(&I)
ButtonOK=确定
ButtonCancel=取消
ButtonYes=是(&Y)
ButtonYesToAll=全是(&A)
ButtonNo=否(&N)
ButtonNoToAll=全否(&O)
ButtonFinish=完成(&F)
ButtonBrowse=浏览(&B)...
ButtonWizardBrowse=浏览(&R)...
ButtonNewFolder=新建文件夹(&M)
; *** "Select Language" dialog messages
SelectLanguageTitle=选择安装语言
SelectLanguageLabel=选择安装时使用的语言。
; *** Common wizard text
ClickNext=点击“下一步”继续,或点击“取消”退出安装程序。
BeveledLabel=
BrowseDialogTitle=浏览文件夹
BrowseDialogLabel=在下面的列表中选择一个文件夹,然后点击“确定”。
NewFolderName=新建文件夹
; *** "Welcome" wizard page
WelcomeLabel1=欢迎使用 [name] 安装向导
WelcomeLabel2=即将在您的计算机上安装 [name/ver]。%n%n建议您在继续安装前关闭所有其他应用程序。
; *** "Password" wizard page
WizardPassword=密码
PasswordLabel1=此安装程序需要密码验证。
PasswordLabel3=请输入密码,然后点击“下一步”继续。密码区分大小写。
PasswordEditLabel=密码(&P):
IncorrectPassword=您输入的密码不正确,请重新输入。
; *** "License Agreement" wizard page
WizardLicense=许可协议
LicenseLabel=请在继续安装前阅读以下重要信息。
LicenseLabel3=请阅读下列许可协议。在继续安装前您必须同意这些协议条款。
LicenseAccepted=我同意此协议(&A)
LicenseNotAccepted=我不同意此协议(&D)
; *** "Information" wizard pages
WizardInfoBefore=信息
InfoBeforeLabel=请在继续安装前阅读以下重要信息。
InfoBeforeClickLabel=准备好继续安装后,点击“下一步”。
WizardInfoAfter=信息
InfoAfterLabel=请在继续安装前阅读以下重要信息。
InfoAfterClickLabel=准备好继续安装后,点击“下一步”。
; *** "User Information" wizard page
WizardUserInfo=用户信息
UserInfoDesc=请输入您的信息。
UserInfoName=用户名(&U):
UserInfoOrg=组织(&O):
UserInfoSerial=序列号(&S):
UserInfoNameRequired=请输入用户名。
; *** "Select Destination Location" wizard page
WizardSelectDir=选择目标位置
SelectDirDesc=您想将 [name] 安装在哪里?
SelectDirLabel3=安装程序将安装 [name] 到下面的文件夹中。
SelectDirBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
DiskSpaceGBLabel=至少需要有 [gb] GB 的可用磁盘空间。
DiskSpaceMBLabel=至少需要有 [mb] MB 的可用磁盘空间。
CannotInstallToNetworkDrive=安装程序无法安装到一个网络驱动器。
CannotInstallToUNCPath=安装程序无法安装到一个 UNC 路径。
InvalidPath=您必须输入一个带驱动器盘符的完整路径,例如:%n%nC:\App%n%n或UNC路径:%n%n\\server\share
InvalidDrive=您选定的驱动器或 UNC 共享不存在或不能访问。请选择其他位置。
DiskSpaceWarningTitle=磁盘空间不足
DiskSpaceWarning=安装程序至少需要 %1 KB 的可用空间才能安装,但选定驱动器只有 %2 KB 的可用空间。%n%n您确定要继续吗?
DirNameTooLong=文件夹名称或路径太长。
InvalidDirName=文件夹名称无效。
BadDirName32=文件夹名称不能包含下列任何字符:%n%n%1
DirExistsTitle=文件夹已存在
DirExists=文件夹:%n%n%1%n%n已经存在。您确定安装到这个文件夹中吗?
DirDoesntExistTitle=文件夹不存在
DirDoesntExist=文件夹:%n%n%1%n%n不存在。您想要创建此文件夹吗?
; *** "Select Components" wizard page
WizardSelectComponents=选择组件
SelectComponentsDesc=您想安装哪些程序组件?
SelectComponentsLabel2=选中您想安装的组件;取消您不想安装的组件。然后点击“下一步”继续。
FullInstallation=完全安装
; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language)
CompactInstallation=简洁安装
CustomInstallation=自定义安装
NoUninstallWarningTitle=组件已存在
NoUninstallWarning=安装程序检测到下列组件已安装在您的计算机中:%n%n%1%n%n取消选中这些组件不会卸载它们。%n%n您确定要继续吗?
ComponentSize1=%1 KB
ComponentSize2=%1 MB
ComponentsDiskSpaceGBLabel=当前选择的组件需要至少 [gb] GB 的磁盘空间。
ComponentsDiskSpaceMBLabel=当前选择的组件需要至少 [mb] MB 的磁盘空间。
; *** "Select Additional Tasks" wizard page
WizardSelectTasks=选择附加任务
SelectTasksDesc=您想要安装程序执行哪些附加任务?
SelectTasksLabel2=选择您想要安装程序在安装 [name] 时执行的附加任务,然后点击“下一步”。
; *** "Select Start Menu Folder" wizard page
WizardSelectProgramGroup=选择开始菜单文件夹
SelectStartMenuFolderDesc=安装程序应该在哪里放置程序的快捷方式?
SelectStartMenuFolderLabel3=安装程序将在下列“开始”菜单文件夹中创建程序的快捷方式。
SelectStartMenuFolderBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
MustEnterGroupName=您必须输入一个文件夹名称。
GroupNameTooLong=文件夹名称或路径太长。
InvalidGroupName=文件夹名称无效。
BadGroupName=文件夹名称不能包含下列任何字符:%n%n%1
NoProgramGroupCheck2=不创建开始菜单文件夹(&D)
; *** "Ready to Install" wizard page
WizardReady=准备安装
ReadyLabel1=安装程序准备就绪,现在可以开始安装 [name] 到您的计算机。
ReadyLabel2a=点击“安装”继续此安装程序。如果您想重新查看或修改任何设置,点击“上一步”。
ReadyLabel2b=点击“安装”继续此安装程序。
ReadyMemoUserInfo=用户信息:
ReadyMemoDir=目标位置:
ReadyMemoType=安装类型:
ReadyMemoComponents=已选择组件:
ReadyMemoGroup=开始菜单文件夹:
ReadyMemoTasks=附加任务:
; *** TDownloadWizardPage wizard page and DownloadTemporaryFile
DownloadingLabel2=正在下载文件...
ButtonStopDownload=停止下载(&S)
StopDownload=您确定要停止下载吗?
ErrorDownloadAborted=下载已中止。
ErrorDownloadFailed=下载失败:%1 %2。
ErrorDownloadSizeFailed=获取大小失败:%1 %2。
ErrorProgress=无效的进度:%1 / %2。
ErrorFileSize=文件大小错误:预期 %1,实际 %2。
; *** TExtractionWizardPage wizard page and ExtractArchive
ExtractingLabel=正在提取文件...
ButtonStopExtraction=停止提取(&S)
StopExtraction=您确定要停止提取吗?
ErrorExtractionAborted=提取已中止。
ErrorExtractionFailed=提取失败:%1
; *** Archive extraction failure details
ArchiveIncorrectPassword=密码不正确。
ArchiveIsCorrupted=压缩包已损坏。
ArchiveUnsupportedFormat=不支持的压缩包格式。
; *** "Preparing to Install" wizard page
WizardPreparing=正在准备安装
PreparingDesc=安装程序正在准备安装 [name] 到您的计算机。
PreviousInstallNotCompleted=先前的程序安装或卸载未完成,需要您重启计算机以完成该安装。%n%n在重启计算机后,再次运行安装程序以完成 [name] 的安装。
CannotContinue=安装程序不能继续。请点击“取消”退出。
ApplicationsFound=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。
ApplicationsFound2=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。安装完成后,安装程序将尝试重新启动这些应用程序。
CloseApplications=自动关闭应用程序(&A)
DontCloseApplications=不要关闭应用程序(&D)
ErrorCloseApplications=安装程序无法自动关闭所有应用程序。建议您在继续之前,关闭所有在使用需要由安装程序更新的文件的应用程序。
PrepareToInstallNeedsRestart=安装程序必须重启您的计算机。计算机重启后,请再次运行安装程序以完成 [name] 的安装。%n%n要立即重启吗?
; *** "Installing" wizard page
WizardInstalling=正在安装
InstallingLabel=安装程序正在安装 [name] 到您的计算机,请稍候。
; *** "Setup Completed" wizard page
FinishedHeadingLabel=完成 [name] 安装向导
FinishedLabelNoIcons=安装程序已在您的计算机中安装了 [name]。
FinishedLabel=安装程序已在您的计算机中安装了 [name]。您可以通过已安装的快捷方式运行此应用程序。
ClickFinish=点击“完成”退出安装程序。
FinishedRestartLabel=为完成 [name] 的安装,安装程序必须重新启动您的计算机。要立即重启吗?
FinishedRestartMessage=为完成 [name] 的安装,安装程序必须重新启动您的计算机。%n%n要立即重启吗?
ShowReadmeCheck=是,我想查阅自述文件
YesRadio=是,立即重启计算机(&Y)
NoRadio=否,稍后重启计算机(&N)
; used for example as 'Run MyProg.exe'
RunEntryExec=运行 %1
; used for example as 'View Readme.txt'
RunEntryShellExec=查阅 %1
; *** "Setup Needs the Next Disk" stuff
ChangeDiskTitle=安装程序需要下一张磁盘
SelectDiskLabel2=请插入磁盘 %1 并点击“确定”。%n%n如果这个磁盘中的文件可以在下列文件夹之外的文件夹中找到,请输入正确的路径或点击“浏览”。
PathLabel=路径(&P):
FileNotInDir2=“%2”中找不到文件“%1”。请插入正确的磁盘或选择其他文件夹。
SelectDirectoryLabel=请指定下一张磁盘的位置。
; *** Installation phase messages
SetupAborted=安装程序未完成安装。%n%n请修正这个问题并重新运行安装程序。
AbortRetryIgnoreSelectAction=选择操作
AbortRetryIgnoreRetry=重试(&T)
AbortRetryIgnoreIgnore=忽略错误并继续(&I)
AbortRetryIgnoreCancel=取消安装
RetryCancelSelectAction=选择操作
RetryCancelRetry=重试(&T)
RetryCancelCancel=取消
; *** Installation status messages
StatusClosingApplications=正在关闭应用程序...
StatusCreateDirs=正在创建目录...
StatusExtractFiles=正在提取文件...
StatusDownloadFiles=正在下载文件...
StatusCreateIcons=正在创建快捷方式...
StatusCreateIniEntries=正在创建 INI 条目...
StatusCreateRegistryEntries=正在创建注册表条目...
StatusRegisterFiles=正在注册文件...
StatusSavingUninstall=正在保存卸载信息...
StatusRunProgram=正在完成安装...
StatusRestartingApplications=正在重启应用程序...
StatusRollback=正在撤销更改...
; *** Misc. errors
ErrorInternal2=内部错误:%1。
ErrorFunctionFailedNoCode=%1 失败。
ErrorFunctionFailed=%1 失败;错误代码 %2。
ErrorFunctionFailedWithMessage=%1 失败;错误代码 %2。%n%3
ErrorExecutingProgram=无法执行文件:%n%1
; *** Registry errors
ErrorRegOpenKey=打开注册表项时出错:%n%1\%2
ErrorRegCreateKey=创建注册表项时出错:%n%1\%2
ErrorRegWriteKey=写入注册表项时出错:%n%1\%2
; *** INI errors
ErrorIniEntry=在文件“%1”中创建 INI 条目时出错。
; *** File copying errors
FileAbortRetryIgnoreSkipNotRecommended=跳过此文件(&S)(不推荐)
FileAbortRetryIgnoreIgnoreNotRecommended=忽略错误并继续(&I)(不推荐)
SourceIsCorrupted=源文件已损坏。
SourceDoesntExist=源文件“%1”不存在。
SourceVerificationFailed=源文件验证失败:%1
VerificationSignatureDoesntExist=签名文件“%1”不存在。
VerificationSignatureInvalid=签名文件“%1”无效。
VerificationKeyNotFound=签名文件“%1”使用了未知的密钥。
VerificationFileNameIncorrect=文件名不正确。
VerificationFileTagIncorrect=文件标签不正确。
VerificationFileSizeIncorrect=文件大小不正确。
VerificationFileHashIncorrect=文件哈希值不正确。
ExistingFileReadOnly2=无法替换已存在的文件,它是只读的。
ExistingFileReadOnlyRetry=移除只读属性并重试(&R)
ExistingFileReadOnlyKeepExisting=保留已存在的文件(&K)
ErrorReadingExistingDest=尝试读取已存在的文件时出错:
FileExistsSelectAction=选择操作
FileExists2=文件已经存在。
FileExistsOverwriteExisting=覆盖已存在的文件(&O)
FileExistsKeepExisting=保留已存在的文件(&K)
FileExistsOverwriteOrKeepAll=为接下来的冲突文件执行此操作(&D)
ExistingFileNewerSelectAction=选择操作
ExistingFileNewer2=已存在的文件比安装程序将要安装的文件还要新。
ExistingFileNewerOverwriteExisting=覆盖已存在的文件(&O)
ExistingFileNewerKeepExisting=保留已存在的文件(&K)(推荐)
ExistingFileNewerOverwriteOrKeepAll=为接下来的冲突文件执行此操作(&D)
ErrorChangingAttr=尝试更改下列已存在的文件属性时出错:
ErrorCreatingTemp=尝试在目标目录创建文件时出错:
ErrorReadingSource=尝试读取下列源文件时出错:
ErrorCopying=尝试复制下列文件时出错:
ErrorDownloading=尝试下载文件时出错:
ErrorExtracting=尝试提取压缩包时出错:
ErrorReplacingExistingFile=尝试替换已存在的文件时出错:
ErrorRestartReplace=重启并替换失败:
ErrorRenamingTemp=尝试重命名下列目标目录中的一个文件时出错:
ErrorRegisterServer=无法注册 DLL/OCX:%1
ErrorRegSvr32Failed=RegSvr32 失败;退出代码 %1。
ErrorRegisterTypeLib=无法注册类型库:%1
; *** Uninstall display name markings
; used for example as 'My Program (32-bit)'
UninstallDisplayNameMark=%1 (%2)
; used for example as 'My Program (32-bit, All users)'
UninstallDisplayNameMarks=%1 (%2, %3)
UninstallDisplayNameMark32Bit=32 位
UninstallDisplayNameMark64Bit=64 位
UninstallDisplayNameMarkAllUsers=所有用户
UninstallDisplayNameMarkCurrentUser=当前用户
; *** Post-installation errors
ErrorOpeningReadme=尝试打开自述文件时出错。
ErrorRestartingComputer=安装程序无法重启计算机,请手动重启。
; *** Uninstaller messages
UninstallNotFound=文件“%1”不存在。无法卸载。
UninstallOpenError=文件“%1”不能被打开。无法卸载
UninstallUnsupportedVer=此版本的卸载程序无法识别卸载日志文件“%1”的格式。无法卸载。
UninstallUnknownEntry=卸载日志中遇到一个未知条目(%1)。
ConfirmUninstall=您确认要完全移除 %1 及其所有组件吗?
UninstallOnlyOnWin64=仅允许在 64 位 Windows 中卸载此程序。
OnlyAdminCanUninstall=仅使用管理员权限的用户能完成此卸载。
UninstallStatusLabel=正在从您的计算机中移除 %1,请稍候。
UninstalledAll=已顺利从您的计算机中移除 %1。
UninstalledMost=%1 卸载完成。%n%n有部分内容未能被删除,但您可以手动删除它们。
UninstalledAndNeedsRestart=为完成 %1 的卸载,需要重启您的计算机。%n%n要立即重启吗?
UninstallDataCorrupted=文件“%1”已损坏。无法卸载。
; *** Uninstallation phase messages
ConfirmDeleteSharedFileTitle=删除共享文件?
ConfirmDeleteSharedFile2=系统表示下列共享文件已不再有任何程序使用。您希望卸载程序删除此共享文件吗?%n%n如果仍有程序正在使用此文件,删除后这些程序可能无法正常运行。如果您不能确定,请选择“否”,保留此文件在系统中不会造成任何损害。
SharedFileNameLabel=文件名:
SharedFileLocationLabel=位置:
WizardUninstalling=卸载状态
StatusUninstalling=正在卸载 %1...
; *** Shutdown block reasons
ShutdownBlockReasonInstallingApp=正在安装 %1。
ShutdownBlockReasonUninstallingApp=正在卸载 %1。
; The custom messages below aren't used by Setup itself, but if you make
; use of them in your scripts, you'll want to translate them.
[CustomMessages]
NameAndVersion=%1 版本 %2
AdditionalIcons=附加快捷方式:
CreateDesktopIcon=创建桌面快捷方式(&D)
CreateQuickLaunchIcon=创建快速启动栏快捷方式(&Q)
ProgramOnTheWeb=%1 网站
UninstallProgram=卸载 %1
LaunchProgram=运行 %1
AssocFileExtension=将 %2 文件扩展名与 %1 建立关联(&A)
AssocingFileExtension=正在将 %2 文件扩展名与 %1 建立关联...
AutoStartProgramGroupDescription=启动:
AutoStartProgram=自动启动 %1
AddonHostProgramNotFound=您选择的文件夹中无法找到 %1。%n%n您确定要继续吗?
+82
View File
@@ -0,0 +1,82 @@
#define MyAppName "ashell"
#define MyAppUserModelId "dev.ashell.app"
#define MyAppVersion GetEnv("PACKAGE_VERSION")
#define MyAppArch GetEnv("ASHELL_ARCH")
#define MyAppExeSource GetEnv("ASHELL_BINARY_PATH")
#define MyOutputDir GetEnv("ASHELL_OUTPUT_DIR")
#define MyOutputBaseName GetEnv("PACKAGE_BASENAME")
#if MyAppVersion == ""
#error PACKAGE_VERSION is required
#endif
#if MyAppExeSource == ""
#error ASHELL_BINARY_PATH is required
#endif
#if MyOutputDir == ""
#error ASHELL_OUTPUT_DIR is required
#endif
#if MyOutputBaseName == ""
#error PACKAGE_BASENAME is required
#endif
#if MyAppArch == "x64"
#define MyAppId "dev.ashell.app.x64"
#elif MyAppArch == "x86"
#define MyAppId "dev.ashell.app.x86"
#elif MyAppArch == "arm64"
#define MyAppId "dev.ashell.app.arm64"
#else
#error Unsupported ASHELL_ARCH value
#endif
[Setup]
AppId={#MyAppId}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher=ashell contributors
AppPublisherURL=https://github.com/rust-kotlin/ashell
AppSupportURL=https://github.com/rust-kotlin/ashell/issues
AppUpdatesURL=https://github.com/rust-kotlin/ashell/releases
DefaultDirName={localappdata}\Programs\ashell
DefaultGroupName=ashell
DisableProgramGroupPage=yes
PrivilegesRequired=lowest
OutputDir={#MyOutputDir}
OutputBaseFilename={#MyOutputBaseName}-setup
SetupIconFile=..\..\assets\icons\ashell.ico
UninstallDisplayIcon={app}\ashell.exe
LicenseFile=..\..\LICENSE
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
MinVersion=10.0
CloseApplications=yes
RestartApplications=no
#if MyAppArch == "x64"
ArchitecturesAllowed=x64compatible and not arm64
ArchitecturesInstallIn64BitMode=x64compatible
#elif MyAppArch == "x86"
ArchitecturesAllowed=x86compatible and not x64compatible and not arm64
ArchitecturesInstallIn64BitMode=
#elif MyAppArch == "arm64"
ArchitecturesAllowed=arm64
ArchitecturesInstallIn64BitMode=arm64
#endif
[Languages]
Name: "en"; MessagesFile: "compiler:Default.isl"
Name: "zhcn"; MessagesFile: "ChineseSimplified.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "{#MyAppExeSource}"; DestDir: "{app}"; DestName: "ashell.exe"; Flags: ignoreversion
[Icons]
Name: "{autoprograms}\ashell"; Filename: "{app}\ashell.exe"; AppUserModelID: "{#MyAppUserModelId}"
Name: "{autodesktop}\ashell"; Filename: "{app}\ashell.exe"; AppUserModelID: "{#MyAppUserModelId}"; Tasks: desktopicon
[Run]
Filename: "{app}\ashell.exe"; Description: "{cm:LaunchProgram,ashell}"; Flags: nowait postinstall skipifsilent
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "stacksafe-macro"
version = "0.1.4"
edition = "2021"
rust-version = "1.80.0"
description = "Procedural macro implementation for the stacksafe crate."
license = "Apache-2.0"
repository = "https://github.com/fast/stacksafe"
publish = false
[lib]
proc-macro = true
[dependencies]
quote = "1"
syn = { version = "2", features = ["full"] }
+73
View File
@@ -0,0 +1,73 @@
// Copyright 2025 FastLabs Developers
// Licensed under the Apache License, Version 2.0.
//! Procedural macro implementation for the `stacksafe` crate.
//!
//! This local compatibility patch preserves the 0.1.4 API while replacing
//! the unmaintained `proc-macro-error2` dependency with native `syn` errors.
use proc_macro::TokenStream;
use quote::{quote, ToTokens};
use syn::{parse_macro_input, parse_quote, ItemFn, Path, ReturnType, Type};
#[proc_macro_attribute]
pub fn stacksafe(args: TokenStream, item: TokenStream) -> TokenStream {
let mut crate_path: Option<Path> = None;
let arg_parser = syn::meta::parser(|meta| {
if meta.path.is_ident("crate") {
crate_path = Some(meta.value()?.parse()?);
Ok(())
} else {
Err(meta.error(format!(
"unknown attribute parameter `{}`",
meta.path
.get_ident()
.map_or("unknown".to_string(), |ident| ident.to_string())
)))
}
});
parse_macro_input!(args with arg_parser);
let mut item_fn: ItemFn = match syn::parse(item) {
Ok(item_fn) => item_fn,
Err(error) => {
return syn::Error::new(
error.span(),
"#[stacksafe] can only be applied to functions",
)
.into_compile_error()
.into();
}
};
if let Some(asyncness) = &item_fn.sig.asyncness {
return syn::Error::new_spanned(asyncness, "#[stacksafe] does not support async functions")
.into_compile_error()
.into();
}
let block = item_fn.block;
let ret = match &item_fn.sig.output {
ReturnType::Type(_, ty) if matches!(**ty, Type::ImplTrait(_)) => ReturnType::Default,
_ => item_fn.sig.output.clone(),
};
let stacksafe_crate = crate_path.unwrap_or_else(|| parse_quote!(::stacksafe));
let wrapped_block = quote! {
{
#stacksafe_crate::internal::stacker::maybe_grow(
#stacksafe_crate::get_minimum_stack_size(),
#stacksafe_crate::get_stack_allocation_size(),
#stacksafe_crate::internal::with_protected(move || #ret { #block })
)
}
};
let wrapped_block = match syn::parse2(wrapped_block) {
Ok(block) => block,
Err(error) => return error.into_compile_error().into(),
};
item_fn.block = Box::new(wrapped_block);
item_fn.into_token_stream().into()
}
+9 -2
View File
@@ -9,6 +9,13 @@ CONTENTS_DIR="$APP_DIR/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS"
RESOURCES_DIR="$CONTENTS_DIR/Resources"
SIGN_IDENTITY="${SIGN_IDENTITY:--}"
APP_VERSION="${APP_VERSION:-$(sed -n 's/^version = "\([^"]*\)"/\1/p' "$ROOT_DIR/Cargo.toml" | head -n 1)}"
APP_BUILD_VERSION="${APP_BUILD_VERSION:-$APP_VERSION}"
if [[ -z "$APP_VERSION" ]]; then
echo "error: unable to read the package version from Cargo.toml" >&2
exit 1
fi
cd "$ROOT_DIR"
cargo build --release
@@ -40,9 +47,9 @@ cat > "$CONTENTS_DIR/Info.plist" <<EOF
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<string>$APP_VERSION</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>$APP_BUILD_VERSION</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>NSHighResolutionCapable</key>
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -1,8 +1,6 @@
pub(crate) const DEFAULT_COLS: u16 = 100;
pub(crate) const DEFAULT_ROWS: u16 = 30;
pub(crate) const SIDEBAR_WIDTH: f32 = 306.0;
pub(crate) const COLLAPSED_SIDEBAR_WIDTH: f32 = 52.0;
pub(crate) const TAB_BAR_HEIGHT: f32 = 52.0;
pub(crate) const TERMINAL_PADDING_X: f32 = 32.0;
pub(crate) const TERMINAL_PADDING_Y: f32 = 32.0;
pub(crate) const SIDEBAR_WIDTH: f32 = 310.0;
pub(crate) const TERMINAL_SCROLLBAR_GUTTER: f32 = 16.0;
pub(crate) const TERMINAL_KEY_CONTEXT: &str = "AshellTerminal";
+232
View File
@@ -0,0 +1,232 @@
use std::{rc::Rc, time::Duration};
use gpui::{
App, ClipboardItem, ElementId, InteractiveElement as _, IntoElement, MouseButton,
ParentElement as _, Rems, RenderOnce, SharedString, StatefulInteractiveElement as _,
Styled as _, Window, div, prelude::FluentBuilder as _, px, rems,
};
use gpui_component::{
ActiveTheme as _, Icon, IconName, Sizable as _, Size,
button::{Button, ButtonVariants as _},
checkbox::Checkbox,
switch::Switch,
};
/// Shared application button size, matching the SSH file panel header controls.
pub(crate) const APP_BUTTON_SIZE: Size = Size::Small;
pub(crate) fn pointer_button(id: impl Into<ElementId>) -> Button {
Button::new(id).with_size(APP_BUTTON_SIZE).cursor_pointer()
}
/// Keep intentionally compact text readable when a view uses relative sizes.
pub(crate) fn ui_rems(size: f32) -> Rems {
rems(size.max(0.85))
}
pub(crate) fn pointer_checkbox(id: impl Into<ElementId>) -> Checkbox {
Checkbox::new(id).cursor_pointer()
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum SelectionState {
Unchecked,
Indeterminate,
Checked,
}
impl SelectionState {
pub(crate) fn from_counts(selected: usize, total: usize) -> Self {
if total == 0 || selected == 0 {
Self::Unchecked
} else if selected >= total {
Self::Checked
} else {
Self::Indeterminate
}
}
fn next_checked(self) -> bool {
self != Self::Checked
}
}
type SelectionClickHandler = Rc<dyn Fn(&bool, &mut Window, &mut App) + 'static>;
#[derive(IntoElement)]
pub(crate) struct PointerSelectionCheckbox {
id: ElementId,
state: SelectionState,
disabled: bool,
on_click: Option<SelectionClickHandler>,
}
impl PointerSelectionCheckbox {
pub(crate) fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
state: SelectionState::Unchecked,
disabled: false,
on_click: None,
}
}
pub(crate) fn state(mut self, state: SelectionState) -> Self {
self.state = state;
self
}
pub(crate) fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub(crate) fn on_click(
mut self,
handler: impl Fn(&bool, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
}
impl RenderOnce for PointerSelectionCheckbox {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let selected = self.state != SelectionState::Unchecked;
let border_color = if selected {
cx.theme().primary
} else {
cx.theme().input
};
let color = if self.disabled {
border_color.opacity(0.5)
} else {
border_color
};
let icon_color = if self.disabled {
cx.theme().primary_foreground.opacity(0.5)
} else {
cx.theme().primary_foreground
};
let radius = cx.theme().radius.min(px(4.));
let next_checked = self.state.next_checked();
let on_click = self.on_click.clone();
div()
.id(self.id)
.size(px(16.))
.flex_none()
.flex()
.items_center()
.justify_center()
.border_1()
.border_color(color)
.rounded(radius)
.bg(if selected {
color
} else {
cx.theme().input_background()
})
.when(cx.theme().shadow && !self.disabled, |this| this.shadow_xs())
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.on_mouse_down(MouseButton::Right, |_, _, cx| cx.stop_propagation())
.when(!self.disabled, |this| {
this.cursor_pointer().on_click(move |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
if let Some(on_click) = &on_click {
on_click(&next_checked, window, cx);
}
})
})
.when_some(
match self.state {
SelectionState::Unchecked => None,
SelectionState::Indeterminate => Some(IconName::Minus),
SelectionState::Checked => Some(IconName::Check),
},
|this, icon| {
this.child(
Icon::new(icon)
.with_size(Size::Small)
.text_color(icon_color),
)
},
)
}
}
pub(crate) fn pointer_switch(id: impl Into<ElementId>) -> Switch {
Switch::new(id).cursor_pointer()
}
#[derive(IntoElement)]
pub(crate) struct PointerClipboard {
id: ElementId,
value: SharedString,
tooltip: Option<SharedString>,
}
impl PointerClipboard {
pub(crate) fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
value: SharedString::default(),
tooltip: None,
}
}
pub(crate) fn value(mut self, value: impl Into<SharedString>) -> Self {
self.value = value.into();
self
}
pub(crate) fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
self.tooltip = Some(tooltip.into());
self
}
}
impl RenderOnce for PointerClipboard {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| ClipboardState::default());
let copied = state.read(cx).copied;
pointer_button(self.id)
.icon(if copied {
IconName::Check
} else {
IconName::Copy
})
.ghost()
.small()
.when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip))
.when(!copied, |this| {
let state = state.clone();
let value = self.value.clone();
this.on_click(move |_, _, cx| {
cx.stop_propagation();
cx.write_to_clipboard(ClipboardItem::new_string(value.to_string()));
state.update(cx, |state, cx| {
state.copied = true;
cx.notify();
});
let state = state.clone();
cx.spawn(async move |cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
state.update(cx, |state, cx| {
state.copied = false;
cx.notify();
});
})
.detach();
})
})
}
}
#[derive(Default)]
struct ClipboardState {
copied: bool,
}
+3395 -556
View File
File diff suppressed because it is too large Load Diff
+99 -10
View File
@@ -3,13 +3,13 @@ use gpui::{
};
use gpui_component::{
Sizable,
button::{Button, ButtonVariants},
button::ButtonVariants,
kbd::Kbd,
setting::{SettingField, SettingGroup, SettingItem},
};
use rust_i18n::t;
use crate::{Ashell, session::config::ConfigStore};
use crate::{Ashell, app::controls::pointer_button, session::config::ConfigStore};
gpui::actions!(
ashell_workspace,
@@ -18,6 +18,8 @@ gpui::actions!(
OpenSession,
OpenTransfers,
NewSsh,
OpenSearch,
QuitApplication,
ToggleSidebar,
ToggleSftpZoom,
FocusPaneLeft,
@@ -28,7 +30,9 @@ gpui::actions!(
SplitPaneRight,
SplitPaneUp,
SplitPaneDown,
ClosePane
ClosePane,
Copy,
Paste
]
);
@@ -59,9 +63,19 @@ pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[
},
WorkspaceAction {
id: "NewSsh",
label_key: "settings_new_ssh",
label_key: "settings_new_connection",
default_suffix: "n",
},
WorkspaceAction {
id: "OpenSearch",
label_key: "settings_open_search",
default_suffix: "f",
},
WorkspaceAction {
id: "QuitApplication",
label_key: "settings_quit_application",
default_suffix: "q",
},
WorkspaceAction {
id: "ToggleSidebar",
label_key: "settings_toggle_sidebar",
@@ -117,6 +131,24 @@ pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[
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 {
@@ -128,6 +160,10 @@ pub(crate) fn default_modifier() -> &'static str {
}
pub(crate) fn default_keystroke(action_id: &str) -> Option<String> {
if action_id == "QuitApplication" && cfg!(target_os = "windows") {
return Some("alt-f4".to_string());
}
WORKSPACE_ACTIONS
.iter()
.find(|action| action.id == action_id)
@@ -180,7 +216,41 @@ 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 suspend_quit_keybinding(cx: &mut App, config: &ConfigStore) {
let default = default_keystroke("QuitApplication").expect("quit action has a default key");
let configured =
configured_keystroke(config, "QuitApplication").unwrap_or_else(|| default.clone());
let action_name = crate::QuitApplication.name();
let mut bindings = vec![KeyBinding::new(&default, Unbind(action_name.into()), None)];
if configured != default && configured != "none" && !configured.is_empty() {
bindings.push(KeyBinding::new(
&configured,
Unbind(action_name.into()),
None,
));
}
cx.bind_keys(bindings);
}
pub(crate) fn restore_quit_keybinding(cx: &mut App, config: &ConfigStore) {
let default = default_keystroke("QuitApplication").expect("quit action has a default key");
let configured =
configured_keystroke(config, "QuitApplication").unwrap_or_else(|| default.clone());
let action_name = crate::QuitApplication.name();
let mut bindings = Vec::new();
if configured != default {
bindings.push(KeyBinding::new(&default, Unbind(action_name.into()), None));
}
if configured != "none" && !configured.is_empty() {
bindings.push(KeyBinding::new(&configured, crate::QuitApplication, None));
}
cx.bind_keys(bindings);
}
/// Unbind workspace keybindings that could interfere with key recording.
///
/// The quit binding remains active so the application can always be closed while
/// the settings dialog is open.
pub(crate) fn unbind_all_workspace_keys(cx: &mut App, config: &ConfigStore) {
let mut bindings = Vec::new();
@@ -192,7 +262,7 @@ pub(crate) fn unbind_all_workspace_keys(cx: &mut App, config: &ConfigStore) {
// Unbind both the default and configured keystroke
bindings.push(KeyBinding::new(&default, Unbind(action_name.into()), None));
if configured != default {
if configured != default && configured != "none" && !configured.is_empty() {
bindings.push(KeyBinding::new(
&configured,
Unbind(action_name.into()),
@@ -206,6 +276,7 @@ pub(crate) fn unbind_all_workspace_keys(cx: &mut App, config: &ConfigStore) {
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);
@@ -217,6 +288,8 @@ pub(crate) fn unbind_all_workspace_keys(cx: &mut App, config: &ConfigStore) {
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);
}
@@ -233,7 +306,7 @@ pub(crate) fn find_conflict(
continue;
}
let existing = configured_keystroke(config, action.id).unwrap_or_default();
if !existing.is_empty() && existing == new_keystroke {
if !existing.is_empty() && existing != "none" && existing == new_keystroke {
return Some((action.id.to_string(), t!(action.label_key).to_string()));
}
}
@@ -253,7 +326,9 @@ fn bind_workspace_actions(cx: &mut App, config: &ConfigStore) {
bindings.push(KeyBinding::new(&default, Unbind(action_name.into()), None));
}
bindings.push(KeyBinding::new(&configured, $action, None));
if configured != "none" && !configured.is_empty() {
bindings.push(KeyBinding::new(&configured, $action, None));
}
};
}
@@ -261,6 +336,8 @@ fn bind_workspace_actions(cx: &mut App, config: &ConfigStore) {
bind_action!("OpenSession", crate::OpenSession);
bind_action!("OpenTransfers", crate::OpenTransfers);
bind_action!("NewSsh", crate::NewSsh);
bind_action!("OpenSearch", crate::OpenSearch);
bind_action!("QuitApplication", crate::QuitApplication);
bind_action!("ToggleSidebar", crate::ToggleSidebar);
bind_action!("ToggleSftpZoom", crate::ToggleSftpZoom);
bind_action!("FocusPaneLeft", crate::FocusPaneLeft);
@@ -272,6 +349,8 @@ fn bind_workspace_actions(cx: &mut App, config: &ConfigStore) {
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);
}
@@ -281,7 +360,16 @@ impl KeybindingsPage {
let groups = [
(
"settings_group_keybind_general",
vec!["OpenSettings", "OpenSession", "OpenTransfers", "NewSsh"],
vec![
"OpenSettings",
"OpenSession",
"OpenTransfers",
"NewSsh",
"OpenSearch",
"QuitApplication",
"Copy",
"Paste",
],
),
(
"settings_group_keybind_zoom",
@@ -353,7 +441,7 @@ impl KeybindingsPage {
let view = view.clone();
let action_id = action.id.to_string();
move |_, _window, _cx| {
Button::new(gpui::SharedString::from(format!("keybind-{action_id}")))
pointer_button(gpui::SharedString::from(format!("keybind-{action_id}")))
.label(btn_label.clone())
.small()
.when(recording, |this| this.primary())
@@ -365,6 +453,7 @@ impl KeybindingsPage {
view.update(cx, |this, cx| {
// Clear any previous error when starting new recording
this.keybind_error = None;
suspend_quit_keybinding(cx, &this.config);
this.recording_action = Some(action_id.clone());
this.focus_handle.focus(window, cx);
cx.notify();
+1637 -159
View File
File diff suppressed because it is too large Load Diff
+353
View File
@@ -0,0 +1,353 @@
#![allow(dead_code)]
use std::ops::Range;
use gpui::{
Along, App, Axis, Bounds, Context, ElementId, EventEmitter, IsZero, Pixels, Window, px,
};
mod panel;
mod resize_handle;
pub use panel::*;
pub(crate) use resize_handle::*;
pub(crate) const PANEL_MIN_SIZE: Pixels = px(100.);
pub enum ResizablePanelEvent {
Resized,
}
/// Create a [`ResizablePanelGroup`] with horizontal resizing
pub fn h_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
ResizablePanelGroup::new(id).axis(Axis::Horizontal)
}
/// Create a [`ResizablePanelGroup`] with vertical resizing
pub fn v_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
ResizablePanelGroup::new(id).axis(Axis::Vertical)
}
/// Create a [`ResizablePanel`].
pub fn resizable_panel() -> ResizablePanel {
ResizablePanel::new()
}
/// State for a [`ResizablePanel`]
#[derive(Debug, Clone)]
pub struct ResizableState {
/// The `axis` will sync to actual axis of the ResizablePanelGroup in use.
axis: Axis,
pub(crate) panels: Vec<ResizablePanelState>,
pub(crate) sizes: Vec<Pixels>,
pub(crate) resizing_panel_ix: Option<usize>,
pub(crate) bounds: Bounds<Pixels>,
}
impl Default for ResizableState {
fn default() -> Self {
Self {
axis: Axis::Horizontal,
panels: vec![],
sizes: vec![],
resizing_panel_ix: None,
bounds: Bounds::default(),
}
}
}
impl ResizableState {
/// Get the size of the panels.
pub fn sizes(&self) -> &Vec<Pixels> {
&self.sizes
}
/// Programmatically resize the panel at `ix` to `size`, redistributing
/// space among siblings using the same logic as a drag.
///
/// Sizes are clamped to the panel's `size_range` and to the container.
/// Emits `ResizablePanelEvent::Resized` so subscribers (e.g. preference
/// persistence) see the change just as if the user had dragged a handle.
///
/// Out-of-range indices are a no-op. For the last panel, space is taken
/// from the previous sibling (the last panel has no handle of its own).
pub fn resize_panel(
&mut self,
ix: usize,
size: Pixels,
window: &mut Window,
cx: &mut Context<Self>,
) {
if ix >= self.sizes.len() {
return;
}
if ix + 1 < self.sizes.len() {
self.resize_panel_at_handle(ix, size, window, cx);
} else if ix > 0 {
// Last panel: drive its size by resizing the previous sibling so
// the freed space lands here.
let delta = self.sizes[ix] - size;
let prev = self.sizes[ix - 1];
self.resize_panel_at_handle(ix - 1, prev + delta, window, cx);
}
self.done_resizing(cx);
}
pub(crate) fn insert_panel(
&mut self,
size: Option<Pixels>,
ix: Option<usize>,
cx: &mut Context<Self>,
) {
let panel_state = ResizablePanelState {
size,
..Default::default()
};
let size = size.unwrap_or(PANEL_MIN_SIZE);
// We make sure that the size always sums up to the container size
// by reducing the size of all other panels first.
let container_size = self.container_size().max(px(1.));
let total_leftover_size = (container_size - size).max(px(1.));
for (i, panel) in self.panels.iter_mut().enumerate() {
let ratio = self.sizes[i] / container_size;
self.sizes[i] = total_leftover_size * ratio;
panel.size = Some(self.sizes[i]);
}
if let Some(ix) = ix {
self.panels.insert(ix, panel_state);
self.sizes.insert(ix, size);
} else {
self.panels.push(panel_state);
self.sizes.push(size);
};
cx.notify();
}
pub(crate) fn sync_panels_count(
&mut self,
axis: Axis,
panels_count: usize,
fit_to_container: bool,
cx: &mut Context<Self>,
) {
let mut changed = self.axis != axis;
self.axis = axis;
if panels_count > self.panels.len() {
let diff = panels_count - self.panels.len();
self.panels
.extend(vec![ResizablePanelState::default(); diff]);
self.sizes.extend(vec![PANEL_MIN_SIZE; diff]);
changed = true;
}
if panels_count < self.panels.len() {
self.panels.truncate(panels_count);
self.sizes.truncate(panels_count);
changed = true;
}
if changed && fit_to_container {
// We need to make sure the total size is in line with the container size.
self.adjust_to_container_size(cx);
}
}
pub(crate) fn update_panel_size(
&mut self,
panel_ix: usize,
bounds: Bounds<Pixels>,
size_range: Range<Pixels>,
cx: &mut Context<Self>,
) {
let size = bounds.size.along(self.axis);
// This check is only necessary to stop the very first panel from resizing on its own
// it needs to be passed when the panel is freshly created so we get the initial size,
// but its also fine when it sometimes passes later.
if self.sizes[panel_ix].as_f32() == PANEL_MIN_SIZE.as_f32() {
self.sizes[panel_ix] = size;
self.panels[panel_ix].size = Some(size);
}
self.panels[panel_ix].bounds = bounds;
self.panels[panel_ix].size_range = size_range;
cx.notify();
}
pub(crate) fn remove_panel(&mut self, panel_ix: usize, cx: &mut Context<Self>) {
self.panels.remove(panel_ix);
self.sizes.remove(panel_ix);
if let Some(resizing_panel_ix) = self.resizing_panel_ix {
if resizing_panel_ix > panel_ix {
self.resizing_panel_ix = Some(resizing_panel_ix - 1);
}
}
self.adjust_to_container_size(cx);
}
pub(crate) fn replace_panel(
&mut self,
panel_ix: usize,
panel: ResizablePanelState,
cx: &mut Context<Self>,
) {
let old_size = self.sizes[panel_ix];
self.panels[panel_ix] = panel;
self.sizes[panel_ix] = old_size;
self.adjust_to_container_size(cx);
}
pub(crate) fn clear(&mut self) {
self.panels.clear();
self.sizes.clear();
}
#[inline]
pub(crate) fn container_size(&self) -> Pixels {
self.bounds.size.along(self.axis)
}
pub(crate) fn done_resizing(&mut self, cx: &mut Context<Self>) {
self.resizing_panel_ix = None;
cx.emit(ResizablePanelEvent::Resized);
}
fn panel_size_range(&self, ix: usize) -> Range<Pixels> {
let Some(panel) = self.panels.get(ix) else {
return PANEL_MIN_SIZE..Pixels::MAX;
};
panel.size_range.clone()
}
fn sync_real_panel_sizes(&mut self, _: &App) {
for (i, panel) in self.panels.iter().enumerate() {
self.sizes[i] = panel.bounds.size.along(self.axis);
}
}
pub(crate) fn resize_panel_at_handle(
&mut self,
ix: usize,
size: Pixels,
_: &mut Window,
cx: &mut Context<Self>,
) {
let old_sizes = self.sizes.clone();
let mut ix = ix;
if ix >= old_sizes.len() - 1 {
return;
}
let container_size = self.container_size();
self.sync_real_panel_sizes(cx);
let move_changed = size - old_sizes[ix];
if move_changed == px(0.) {
return;
}
let size_range = self.panel_size_range(ix);
let new_size = size.clamp(size_range.start, size_range.end);
let is_expand = move_changed > px(0.);
let main_ix = ix;
let mut new_sizes = old_sizes.clone();
if is_expand {
let mut changed = new_size - old_sizes[ix];
new_sizes[ix] = new_size;
while changed > px(0.) && ix < old_sizes.len() - 1 {
ix += 1;
let size_range = self.panel_size_range(ix);
let available_size = (new_sizes[ix] - size_range.start).max(px(0.));
let to_reduce = changed.min(available_size);
new_sizes[ix] -= to_reduce;
changed -= to_reduce;
}
} else {
let mut changed = new_size - size;
new_sizes[ix] = new_size;
while changed > px(0.) && ix > 0 {
ix -= 1;
let size_range = self.panel_size_range(ix);
let available_size = (new_sizes[ix] - size_range.start).max(px(0.));
let to_reduce = changed.min(available_size);
changed -= to_reduce;
new_sizes[ix] -= to_reduce;
}
new_sizes[main_ix + 1] += old_sizes[main_ix] - size - changed;
}
let total_size: Pixels = new_sizes.iter().map(|s| s.as_f32()).sum::<f32>().into();
if total_size > container_size {
let overflow = total_size - container_size;
new_sizes[main_ix] = (new_sizes[main_ix] - overflow).max(size_range.start);
}
for (i, _) in old_sizes.iter().enumerate() {
let size = new_sizes[i];
self.panels[i].size = Some(size);
}
self.sizes = new_sizes;
cx.notify();
}
pub(crate) fn resize_panel_independently(
&mut self,
ix: usize,
size: Pixels,
_: &mut Window,
cx: &mut Context<Self>,
) {
if ix >= self.sizes.len() {
return;
}
self.sync_real_panel_sizes(cx);
let size_range = self.panel_size_range(ix);
let new_size = size.clamp(size_range.start, size_range.end);
if new_size == self.sizes[ix] {
return;
}
self.sizes[ix] = new_size;
self.panels[ix].size = Some(new_size);
cx.notify();
}
pub(crate) fn adjust_to_container_size(&mut self, cx: &mut Context<Self>) {
if self.container_size().is_zero() {
return;
}
let container_size = self.container_size();
let total_size = px(self.sizes.iter().map(|s| s.as_f32()).sum::<f32>());
for i in 0..self.panels.len() {
let size = self.sizes[i];
let ratio = size / total_size;
let new_size = container_size * ratio;
self.sizes[i] = new_size;
self.panels[i].size = Some(new_size);
}
cx.notify();
}
}
impl EventEmitter<ResizablePanelEvent> for ResizableState {}
#[derive(Debug, Clone, Default)]
pub(crate) struct ResizablePanelState {
pub size: Option<Pixels>,
pub size_range: Range<Pixels>,
pub(crate) bounds: Bounds<Pixels>,
}
+458
View File
@@ -0,0 +1,458 @@
use std::{
ops::{Deref, Range},
rc::Rc,
};
use gpui::{
Along, AnyElement, App, AppContext as _, Axis, Bounds, Context, Element, ElementId, Empty,
Entity, EventEmitter, InteractiveElement as _, IntoElement, IsZero as _, MouseMoveEvent,
MouseUpEvent, ParentElement, Pixels, Render, RenderOnce, Style, StyleRefinement, Styled,
Window, div, prelude::FluentBuilder,
};
use gpui_component::{AxisExt, ElementExt, StyledExt, h_flex, v_flex};
use super::{PANEL_MIN_SIZE, ResizablePanelEvent, ResizableState, resizable_panel, resize_handle};
type ResizeCallback = Rc<dyn Fn(&Entity<ResizableState>, &mut Window, &mut App)>;
#[derive(Clone)]
pub(crate) struct DragPanel;
impl Render for DragPanel {
fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement {
Empty
}
}
/// A group of resizable panels.
#[derive(IntoElement)]
pub struct ResizablePanelGroup {
id: ElementId,
state: Option<Entity<ResizableState>>,
axis: Axis,
size: Option<Pixels>,
children: Vec<ResizablePanel>,
on_resize: ResizeCallback,
locked: bool,
independent_resize: bool,
}
impl ResizablePanelGroup {
/// Create a new resizable panel group.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
axis: Axis::Horizontal,
children: vec![],
state: None,
size: None,
on_resize: Rc::new(|_, _, _| {}),
locked: false,
independent_resize: false,
}
}
/// Bind yourself to a resizable state entity.
///
/// If not provided, it will handle its own state internally.
pub fn with_state(mut self, state: &Entity<ResizableState>) -> Self {
self.state = Some(state.clone());
self
}
/// Set the axis of the resizable panel group, default is horizontal.
pub fn axis(mut self, axis: Axis) -> Self {
self.axis = axis;
self
}
/// Set lock status of the resizable panel group.
pub fn lock(mut self, locked: bool) -> Self {
self.locked = locked;
self
}
/// Resize each panel without redistributing space among its siblings.
/// This lets the total group size grow and adds a trailing handle to the last panel.
pub fn independent_resize(mut self) -> Self {
self.independent_resize = true;
self
}
/// Add a panel to the group.
///
/// - The `axis` will be set to the same axis as the group.
/// - The `initial_size` will be set to the average size of all panels if not provided.
/// - The `group` will be set to the group entity.
pub fn child(mut self, panel: impl Into<ResizablePanel>) -> Self {
self.children.push(panel.into());
self
}
/// Add multiple panels to the group.
pub fn children<I>(mut self, panels: impl IntoIterator<Item = I>) -> Self
where
I: Into<ResizablePanel>,
{
self.children = panels.into_iter().map(|panel| panel.into()).collect();
self
}
/// Set size of the resizable panel group
///
/// - When the axis is horizontal, the size is the height of the group.
/// - When the axis is vertical, the size is the width of the group.
pub fn size(mut self, size: Pixels) -> Self {
self.size = Some(size);
self
}
/// Set the callback to be called when the panels are resized.
///
/// ## Callback arguments
///
/// - Entity<ResizableState>: The state of the ResizablePanelGroup.
pub fn on_resize(
mut self,
on_resize: impl Fn(&Entity<ResizableState>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_resize = Rc::new(on_resize);
self
}
}
impl<T> From<T> for ResizablePanel
where
T: Into<AnyElement>,
{
fn from(value: T) -> Self {
resizable_panel().child(value.into())
}
}
impl From<ResizablePanelGroup> for ResizablePanel {
fn from(value: ResizablePanelGroup) -> Self {
resizable_panel().child(value)
}
}
impl EventEmitter<ResizablePanelEvent> for ResizablePanelGroup {}
impl RenderOnce for ResizablePanelGroup {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = self.state.unwrap_or_else(|| {
window.use_keyed_state(self.id.clone(), cx, |_, _| ResizableState::default())
});
let container = if self.axis.is_horizontal() {
h_flex()
} else {
v_flex()
};
// Sync panels to the state
let panels_count = self.children.len();
state.update(cx, |state, cx| {
state.sync_panels_count(self.axis, panels_count, !self.independent_resize, cx);
});
container
.id(self.id)
.size_full()
.children(
self.children
.into_iter()
.enumerate()
.map(|(ix, mut panel)| {
panel.panel_ix = ix;
panel.axis = self.axis;
panel.state = Some(state.clone());
panel.locked = self.locked;
panel.trailing_resize_handle =
self.independent_resize && ix + 1 == panels_count;
panel
}),
)
.on_prepaint({
let state = state.clone();
move |bounds, _, cx| {
state.update(cx, |state, cx| {
let size_changed =
state.bounds.size.along(self.axis) != bounds.size.along(self.axis);
state.bounds = bounds;
if size_changed && !self.independent_resize {
state.adjust_to_container_size(cx);
}
})
}
})
.child(ResizePanelGroupElement {
state: state.clone(),
axis: self.axis,
on_resize: self.on_resize.clone(),
independent_resize: self.independent_resize,
})
}
}
/// A resizable panel inside a [`ResizablePanelGroup`].
#[derive(IntoElement)]
pub struct ResizablePanel {
axis: Axis,
panel_ix: usize,
state: Option<Entity<ResizableState>>,
/// Initial size is the size that the panel has when it is created.
initial_size: Option<Pixels>,
/// size range limit of this panel.
size_range: Range<Pixels>,
children: Vec<AnyElement>,
visible: bool,
style: StyleRefinement,
locked: bool,
trailing_resize_handle: bool,
}
impl ResizablePanel {
/// Create a new resizable panel.
pub(super) fn new() -> Self {
Self {
panel_ix: 0,
initial_size: None,
state: None,
size_range: (PANEL_MIN_SIZE..Pixels::MAX),
axis: Axis::Horizontal,
children: vec![],
visible: true,
style: StyleRefinement::default(),
locked: false,
trailing_resize_handle: false,
}
}
/// Set the visibility of the panel, default is true.
pub fn visible(mut self, visible: bool) -> Self {
self.visible = visible;
self
}
/// Set the initial size of the panel.
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
self.initial_size = Some(size.into());
self
}
/// Set the size range to limit panel resize.
///
/// Default is [`PANEL_MIN_SIZE`] to [`Pixels::MAX`].
pub fn size_range(mut self, range: impl Into<Range<Pixels>>) -> Self {
self.size_range = range.into();
self
}
}
impl Styled for ResizablePanel {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl ParentElement for ResizablePanel {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for ResizablePanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
if !self.visible {
return div().id(("resizable-panel", self.panel_ix));
}
let state = self
.state
.expect("BUG: The `state` in ResizablePanel should be present.");
let panel_state = state
.read(cx)
.panels
.get(self.panel_ix)
.expect("BUG: The `index` of ResizablePanel should be one of in `state`.");
let size_range = self.size_range.clone();
let leading_handle_state = state.clone();
let trailing_handle_state = state.clone();
div()
.id(("resizable-panel", self.panel_ix))
.flex()
.flex_grow_1()
.size_full()
.relative()
.refine_style(&self.style)
.when(self.axis.is_vertical(), |this| {
this.min_h(size_range.start).max_h(size_range.end)
})
.when(self.axis.is_horizontal(), |this| {
this.min_w(size_range.start).max_w(size_range.end)
})
.when(self.initial_size.is_none(), |this| this.flex_shrink_1())
.when_some(self.initial_size, |this, initial_size| {
this.when(
panel_state.size.is_none() && !initial_size.is_zero(),
|this| this.flex_none(),
)
.flex_basis(initial_size)
})
.map(|this| match panel_state.size {
Some(size) => this.flex_basis(size.min(size_range.end).max(size_range.start)),
None => this,
})
.on_prepaint({
let state = state.clone();
move |bounds, _, cx| {
state.update(cx, |state, cx| {
state.update_panel_size(self.panel_ix, bounds, self.size_range, cx)
})
}
})
.children(self.children)
.when(self.panel_ix > 0, |this| {
let ix = self.panel_ix - 1;
let handle = resize_handle(("resizable-handle", ix), self.axis);
let handle = if self.locked {
handle
} else {
handle.on_drag(DragPanel, move |drag_panel, _, _, cx| {
cx.stop_propagation();
leading_handle_state.update(cx, |state, cx| {
state.resizing_panel_ix = Some(ix);
cx.notify();
});
cx.new(|_| drag_panel.deref().clone())
})
};
this.child(handle)
})
.when(self.trailing_resize_handle, |this| {
let ix = self.panel_ix;
let handle = resize_handle(("resizable-handle", ix), self.axis)
.placement(gpui_component::dock::DockPlacement::Left);
let handle = if self.locked {
handle
} else {
handle.on_drag(DragPanel, move |drag_panel, _, _, cx| {
cx.stop_propagation();
trailing_handle_state.update(cx, |state, cx| {
state.resizing_panel_ix = Some(ix);
cx.notify();
});
cx.new(|_| drag_panel.deref().clone())
})
};
this.child(handle)
})
}
}
struct ResizePanelGroupElement {
state: Entity<ResizableState>,
on_resize: ResizeCallback,
axis: Axis,
independent_resize: bool,
}
impl IntoElement for ResizePanelGroupElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for ResizePanelGroupElement {
type RequestLayoutState = ();
type PrepaintState = ();
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
(window.request_layout(Style::default(), None, cx), ())
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_window: &mut Window,
_cx: &mut App,
) -> Self::PrepaintState {
}
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
_cx: &mut App,
) {
window.on_mouse_event({
let state = self.state.clone();
let axis = self.axis;
let independent_resize = self.independent_resize;
move |e: &MouseMoveEvent, phase, window, cx| {
if !phase.bubble() {
return;
}
let Some(ix) = state.read(cx).resizing_panel_ix else {
return;
};
state.update(cx, |state, cx| {
let panel = state.panels.get(ix).expect("BUG: invalid panel index");
let size = match axis {
Axis::Horizontal => e.position.x - panel.bounds.left(),
Axis::Vertical => e.position.y - panel.bounds.top(),
};
if independent_resize {
state.resize_panel_independently(ix, size, window, cx);
} else {
state.resize_panel_at_handle(ix, size, window, cx);
}
cx.notify();
})
}
});
// When any mouse up, stop dragging
window.on_mouse_event({
let state = self.state.clone();
let on_resize = self.on_resize.clone();
move |_: &MouseUpEvent, phase, window, cx| {
if state.read(cx).resizing_panel_ix.is_none() {
return;
}
if phase.bubble() {
state.update(cx, |state, cx| state.done_resizing(cx));
on_resize(&state, window, cx);
}
}
})
}
}
+223
View File
@@ -0,0 +1,223 @@
use std::{cell::Cell, rc::Rc};
use gpui::{
AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId, InteractiveElement,
IntoElement, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render,
StatefulInteractiveElement, Styled as _, Window, div, prelude::FluentBuilder as _, px,
};
use gpui_component::{ActiveTheme as _, AxisExt as _, dock::DockPlacement};
pub(crate) const HANDLE_PADDING: Pixels = px(4.);
pub(crate) const HANDLE_SIZE: Pixels = px(1.);
type DragCallback<E> = Rc<dyn Fn(&Point<Pixels>, &mut Window, &mut App) -> Entity<E>>;
/// Create a resize handle for a resizable panel.
pub(crate) fn resize_handle<T: 'static, E: 'static + Render>(
id: impl Into<ElementId>,
axis: Axis,
) -> ResizeHandle<T, E> {
ResizeHandle::new(id, axis)
}
pub(crate) struct ResizeHandle<T: 'static, E: 'static + Render> {
id: ElementId,
axis: Axis,
drag_value: Option<Rc<T>>,
placement: Option<DockPlacement>,
on_drag: Option<DragCallback<E>>,
}
impl<T: 'static, E: 'static + Render> ResizeHandle<T, E> {
fn new(id: impl Into<ElementId>, axis: Axis) -> Self {
let id = id.into();
Self {
id: id.clone(),
on_drag: None,
drag_value: None,
placement: None,
axis,
}
}
pub(crate) fn on_drag(
mut self,
value: T,
f: impl Fn(Rc<T>, &Point<Pixels>, &mut Window, &mut App) -> Entity<E> + 'static,
) -> Self {
let value = Rc::new(value);
self.drag_value = Some(value.clone());
self.on_drag = Some(Rc::new(move |p, window, cx| {
f(value.clone(), p, window, cx)
}));
self
}
pub(crate) fn placement(mut self, placement: DockPlacement) -> Self {
self.placement = Some(placement);
self
}
}
#[derive(Default, Debug, Clone)]
struct ResizeHandleState {
active: Cell<bool>,
}
impl ResizeHandleState {
fn set_active(&self, active: bool) {
self.active.set(active);
}
fn is_active(&self) -> bool {
self.active.get()
}
}
impl<T: 'static, E: 'static + Render> IntoElement for ResizeHandle<T, E> {
type Element = ResizeHandle<T, E>;
fn into_element(self) -> Self::Element {
self
}
}
impl<T: 'static, E: 'static + Render> Element for ResizeHandle<T, E> {
type RequestLayoutState = AnyElement;
type PrepaintState = ();
fn id(&self) -> Option<ElementId> {
Some(self.id.clone())
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
id: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let neg_offset = -HANDLE_PADDING;
let axis = self.axis;
window.with_element_state(id.unwrap(), |state, window| {
let state: ResizeHandleState = state.unwrap_or_default();
let bg_color = if state.is_active() {
cx.theme().drag_border
} else {
cx.theme().border
};
let has_drag = self.on_drag.is_some();
let mut el = div()
.id(self.id.clone())
.occlude()
.absolute()
.flex_shrink_0()
.group("handle")
.when_some(self.on_drag.clone(), |this, on_drag| {
this.on_drag(
self.drag_value.clone().unwrap(),
move |_, position, window, cx| on_drag(&position, window, cx),
)
})
.map(|this| match self.placement {
Some(DockPlacement::Left) => this
.when(has_drag, |this| this.cursor_col_resize())
.top_0()
.right(px(1.))
.h_full()
.w(HANDLE_SIZE)
.pl(HANDLE_PADDING),
_ => this
.when(axis.is_horizontal(), |this| {
this.when(has_drag, |this| this.cursor_col_resize())
.top_0()
.left(neg_offset)
.h_full()
.w(HANDLE_SIZE)
.px(HANDLE_PADDING)
})
.when(axis.is_vertical(), |this| {
this.when(has_drag, |this| this.cursor_row_resize())
.top(neg_offset)
.left_0()
.w_full()
.h(HANDLE_SIZE)
.py(HANDLE_PADDING)
}),
})
.child(
div()
.bg(bg_color)
.group_hover("handle", |this| this.bg(bg_color))
.when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE))
.when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)),
)
.into_any_element();
let layout_id = el.request_layout(window, cx);
((layout_id, el), state)
})
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
request_layout.prepaint(window, cx);
}
fn paint(
&mut self,
id: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
bounds: gpui::Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
request_layout.paint(window, cx);
window.with_element_state(id.unwrap(), |state: Option<ResizeHandleState>, window| {
let state = state.unwrap_or_default();
if self.on_drag.is_some() {
window.on_mouse_event({
let state = state.clone();
move |ev: &MouseDownEvent, phase, window, _| {
if bounds.contains(&ev.position) && phase.bubble() {
state.set_active(true);
window.refresh();
}
}
});
window.on_mouse_event({
let state = state.clone();
move |_: &MouseUpEvent, _, window, _| {
if state.is_active() {
state.set_active(false);
window.refresh();
}
}
});
}
((), state)
});
}
}
+453
View File
@@ -0,0 +1,453 @@
use std::collections::HashMap;
use gpui::{
Context, Focusable as _, Hsla, InteractiveElement as _, IntoElement, MouseButton,
ParentElement as _, Styled as _, Window, div, prelude::FluentBuilder as _, px,
};
use gpui_component::{
ActiveTheme as _, Disableable as _, ElementExt as _, IconName, Sizable as _,
button::ButtonVariants as _, h_flex, input::Input,
};
use rust_i18n::t;
use crate::{
Ashell,
app::controls::{pointer_button, ui_rems},
};
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);
});
});
}
fn search_tab_index(&self) -> Option<usize> {
self.active_tab
.as_deref()
.and_then(|id| self.tabs.iter().position(|tab| tab.id == id))
.or_else(|| {
self.active_group
.as_ref()
.and_then(|group_id| self.tab_groups.iter().find(|group| &group.id == group_id))
.and_then(|group| group.pane_root.first_tab_id())
.and_then(|id| self.tabs.iter().position(|tab| tab.id == id))
})
.or_else(|| (!self.tabs.is_empty()).then_some(0))
}
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;
}
let Some(tab_index) = self.search_tab_index() 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(self.tabs[tab_index].id.clone());
let tab = &self.tabs[tab_index];
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;
}
}
matches.sort_unstable();
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.first_tab_id())
.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(false);
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;
}
let tab = self.tabs.get(self.search_tab_index()?)?;
let snapshot = tab.render_snapshot(false);
let display_offset = snapshot.display_offset as i32;
let rows = snapshot.rows as i32;
let mut map = HashMap::new();
let sorted = &self.search_matches;
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 next_i = next_match_group_index(sorted, i);
if vp_row >= 0 && vp_row < rows {
for &(_, col) in &sorted[i..next_i] {
map.insert((vp_row, col), color);
}
}
group_idx += 1;
i = next_i;
}
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(
pointer_button("search-btn")
.ghost()
.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| {
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)),
)
.when(!current_display.is_empty(), |this| {
this.child(
div()
.text_size(ui_rems(0.75))
.text_color(cx.theme().muted_foreground)
.min_w(px(36.))
.text_center()
.child(current_display),
)
})
.child(
pointer_button("search-prev")
.ghost()
.small()
.icon(IconName::ChevronUp)
.disabled(!has_matches)
.on_click(cx.listener(|this, _, _, cx| {
this.search_goto_prev(cx);
})),
)
.child(
pointer_button("search-next")
.ghost()
.small()
.icon(IconName::ChevronDown)
.disabled(!has_matches)
.on_click(cx.listener(|this, _, _, cx| {
this.search_goto_next(cx);
})),
)
.child(
pointer_button("search-close")
.ghost()
.small()
.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 {
let mut count = 0;
let mut i = 0;
while i < matches.len() {
count += 1;
i = next_match_group_index(matches, i);
}
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)> {
let mut group_idx = 0;
let mut i = 0;
while i < matches.len() {
if group_idx == n {
return Some(matches[i]);
}
group_idx += 1;
i = next_match_group_index(matches, i);
}
None
}
fn next_match_group_index(matches: &[(i32, i32)], start: usize) -> usize {
let row = matches[start].0;
let mut end = start + 1;
while end < matches.len() && matches[end].0 == row && matches[end].1 == matches[end - 1].1 + 1 {
end += 1;
}
end
}
#[cfg(test)]
mod tests {
use super::{count_match_groups, find_nth_match_start};
#[test]
fn groups_consecutive_search_matches_by_row() {
let matches = [(0, 1), (0, 2), (0, 5), (1, 0), (1, 1)];
assert_eq!(count_match_groups(&matches), 3);
assert_eq!(find_nth_match_start(&matches, 0), Some((0, 1)));
assert_eq!(find_nth_match_start(&matches, 1), Some((0, 5)));
assert_eq!(find_nth_match_start(&matches, 2), Some((1, 0)));
assert_eq!(find_nth_match_start(&matches, 3), None);
}
}
+146 -101
View File
@@ -2,7 +2,75 @@ use gpui::{App, AppContext as _, Bounds, WindowOptions, point, px, size};
use gpui_component::Root;
use crate::Ashell;
use crate::session::config::ConfigStore;
use crate::session::config::{ConfigStore, SavedWindowBounds};
fn clamp_saved_window_bounds(
x: f32,
y: f32,
width: f32,
height: f32,
cx: &App,
) -> Bounds<gpui::Pixels> {
let displays = cx.displays();
let Some(display) = displays.iter().max_by(|left, right| {
let intersection_area = |display: &std::rc::Rc<dyn gpui::PlatformDisplay>| {
let visible = display.visible_bounds();
let left_edge = x.max(visible.origin.x.as_f32());
let top_edge = y.max(visible.origin.y.as_f32());
let right_edge =
(x + width).min(visible.origin.x.as_f32() + visible.size.width.as_f32());
let bottom_edge =
(y + height).min(visible.origin.y.as_f32() + visible.size.height.as_f32());
(right_edge - left_edge).max(0.0) * (bottom_edge - top_edge).max(0.0)
};
intersection_area(left).total_cmp(&intersection_area(right))
}) else {
return Bounds::new(point(px(x), px(y)), size(px(width), px(height)));
};
let visible = display.visible_bounds();
let visible_x = visible.origin.x.as_f32();
let visible_y = visible.origin.y.as_f32();
let visible_width = visible.size.width.as_f32();
let visible_height = visible.size.height.as_f32();
let width = width.max(1.0).min(visible_width);
let height = height.max(1.0).min(visible_height);
let x = if x.is_finite() {
x.clamp(visible_x, visible_x + visible_width - width)
} else {
visible_x + (visible_width - width) / 2.0
};
let y = if y.is_finite() {
y.clamp(visible_y, visible_y + visible_height - height)
} else {
visible_y + (visible_height - height) / 2.0
};
Bounds::new(point(px(x), px(y)), size(px(width), px(height)))
}
fn restore_window_bounds(bounds: &SavedWindowBounds, cx: &App) -> gpui::WindowBounds {
match bounds {
SavedWindowBounds::Fullscreen {
x,
y,
width,
height,
} => gpui::WindowBounds::Fullscreen(clamp_saved_window_bounds(*x, *y, *width, *height, cx)),
SavedWindowBounds::Maximized {
x,
y,
width,
height,
} => gpui::WindowBounds::Maximized(clamp_saved_window_bounds(*x, *y, *width, *height, cx)),
SavedWindowBounds::Windowed {
x,
y,
width,
height,
} => gpui::WindowBounds::Windowed(clamp_saved_window_bounds(*x, *y, *width, *height, cx)),
}
}
pub(crate) fn bind_workspace_keys(cx: &mut gpui::App) {
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
@@ -162,6 +230,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 {
@@ -172,48 +246,80 @@ 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 {
x,
y,
width,
height,
} => gpui::WindowBounds::Fullscreen(Bounds::new(
point(px(*x), px(*y)),
size(px(*width), px(*height)),
)),
crate::session::config::SavedWindowBounds::Maximized {
x,
y,
width,
height,
} => gpui::WindowBounds::Maximized(Bounds::new(
point(px(*x), px(*y)),
size(px(*width), px(*height)),
)),
crate::session::config::SavedWindowBounds::Windowed {
x,
y,
width,
height,
} => gpui::WindowBounds::Windowed(Bounds::new(
point(px(*x), px(*y)),
size(px(*width), px(*height)),
)),
});
window_options.window_bounds = Some(restore_window_bounds(bounds, cx));
} else if let Some(display) = cx.displays().first().cloned() {
let display_bounds = display.bounds();
let width = display_bounds.size.width * 0.8;
@@ -237,85 +343,24 @@ pub(crate) fn open_main_window(cx: &mut App) {
window.set_window_title("ashell");
gpui_component::Theme::sync_system_appearance(Some(window), cx);
let view = cx.new(|cx| Ashell::new(window, cx));
crate::app::system_menu::set_app_menus(cx);
tracing::info!("[ui] main application window opened");
let focus_handle = view.read(cx).focus_handle.clone();
window.focus(&focus_handle, cx);
let sftp_minimized = view.read(cx).config.sftp_panel_minimized();
if sftp_minimized {
window.defer(cx, {
let view = view.clone();
move |window, cx| {
view.update(cx, |this, cx| {
this.toggle_sftp_minimized(window, cx);
});
}
});
}
let workspace_panels_clone = view.read(cx).workspace_panels.clone();
let body_panels_clone = view.read(cx).body_panels.clone();
let view_clone = view.clone();
window.on_window_should_close(cx, move |window: &mut gpui::Window, cx: &mut gpui::App| {
if view_clone.read(cx).is_layout_reset {
tracing::info!("[ui] layout was reset, skipping save layout state.");
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;
}
tracing::info!("[ui] main application window closed, saving layout state...");
let mut config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
let current_bounds = window.window_bounds();
let saved_bounds = match current_bounds {
gpui::WindowBounds::Fullscreen(b) => {
crate::session::config::SavedWindowBounds::Fullscreen {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
gpui::WindowBounds::Maximized(b) => {
crate::session::config::SavedWindowBounds::Maximized {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
gpui::WindowBounds::Windowed(b) => {
crate::session::config::SavedWindowBounds::Windowed {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
};
let workspace_sizes: Vec<f32> = workspace_panels_clone
.read(cx)
.sizes()
.iter()
.map(|s| s.into())
.collect();
let mut body_sizes: Vec<f32> = body_panels_clone
.read(cx)
.sizes()
.iter()
.map(|s| s.into())
.collect();
if view_clone.read(cx).sftp_panel_minimized {
if let Some(prev) = view_clone.read(cx).prev_monitoring_size {
if body_sizes.len() > 1 {
body_sizes[1] = prev.into();
}
}
}
config.set_layout_state(Some(saved_bounds), Some(workspace_sizes), Some(body_sizes));
config.set_sidebar_collapsed(view_clone.read(cx).sidebar_collapsed);
config.set_sftp_panel_minimized(view_clone.read(cx).sftp_panel_minimized);
let _ = config.save();
view_clone.update(cx, |this, cx| {
this.save_layout_state(window, cx);
});
true
});
+132
View File
@@ -0,0 +1,132 @@
#[cfg(target_os = "macos")]
use gpui::KeyBinding;
use gpui::{App, Menu, MenuItem};
use rust_i18n::t;
gpui::actions!(
ashell_system_menu,
[
AboutAshell,
CloseWindow,
HideAshell,
HideOtherApplications,
MinimizeWindow,
NewLocalTerminal,
OpenProjectWebsite,
ShowAllApplications,
ToggleFullScreen,
ZoomWindow
]
);
const PROJECT_URL: &str = "https://github.com/rust-kotlin/ashell";
pub(crate) fn init(cx: &mut App) {
#[cfg(target_os = "macos")]
{
cx.on_action(|_: &HideAshell, cx| cx.hide());
cx.on_action(|_: &HideOtherApplications, cx| cx.hide_other_apps());
cx.on_action(|_: &ShowAllApplications, cx| cx.unhide_other_apps());
cx.bind_keys([
KeyBinding::new("cmd-h", HideAshell, None),
KeyBinding::new("alt-cmd-h", HideOtherApplications, None),
KeyBinding::new("cmd-m", MinimizeWindow, None),
KeyBinding::new("ctrl-cmd-f", ToggleFullScreen, None),
]);
}
cx.on_action(|_: &OpenProjectWebsite, _| {
if let Err(error) = open::that(PROJECT_URL) {
tracing::warn!("failed to open Ashell project website: {error}");
}
});
}
pub(crate) fn set_app_menus(cx: &mut App) {
let mut application_items = vec![
MenuItem::action(t!("menu_about_ashell").to_string(), AboutAshell),
MenuItem::separator(),
MenuItem::action(t!("settings").to_string(), crate::OpenSettings),
];
#[cfg(target_os = "macos")]
application_items.extend([
MenuItem::separator(),
MenuItem::os_submenu(
t!("menu_services").to_string(),
gpui::SystemMenuType::Services,
),
MenuItem::separator(),
MenuItem::action(t!("menu_hide_ashell").to_string(), HideAshell),
MenuItem::action(t!("menu_hide_others").to_string(), HideOtherApplications),
MenuItem::action(t!("menu_show_all").to_string(), ShowAllApplications),
]);
application_items.extend([
MenuItem::separator(),
MenuItem::action(t!("menu_quit_ashell").to_string(), crate::QuitApplication),
]);
cx.set_menus([
Menu::new("Ashell").items(application_items),
Menu::new(t!("menu_file").to_string()).items([
MenuItem::action(t!("local_terminal").to_string(), NewLocalTerminal),
MenuItem::action(t!("new_connection").to_string(), crate::NewSsh),
MenuItem::separator(),
MenuItem::action(t!("menu_close_window").to_string(), CloseWindow),
]),
Menu::new(t!("menu_edit").to_string()).items([
MenuItem::os_action(
t!("menu_undo").to_string(),
gpui_component::input::Undo,
gpui::OsAction::Undo,
),
MenuItem::os_action(
t!("menu_redo").to_string(),
gpui_component::input::Redo,
gpui::OsAction::Redo,
),
MenuItem::separator(),
MenuItem::os_action(
t!("menu_cut").to_string(),
gpui_component::input::Cut,
gpui::OsAction::Cut,
),
MenuItem::os_action(
t!("menu_copy").to_string(),
crate::Copy,
gpui::OsAction::Copy,
),
MenuItem::os_action(
t!("menu_paste").to_string(),
crate::Paste,
gpui::OsAction::Paste,
),
MenuItem::separator(),
MenuItem::os_action(
t!("menu_select_all").to_string(),
gpui_component::input::SelectAll,
gpui::OsAction::SelectAll,
),
]),
Menu::new(t!("menu_view").to_string()).items([
MenuItem::action(t!("search").to_string(), crate::OpenSearch),
MenuItem::action(t!("menu_toggle_sidebar").to_string(), crate::ToggleSidebar),
MenuItem::action(
t!("settings_open_transfers").to_string(),
crate::OpenTransfers,
),
MenuItem::separator(),
MenuItem::action(t!("menu_toggle_full_screen").to_string(), ToggleFullScreen),
]),
// GPUI recognizes this exact title as the native macOS Window menu.
Menu::new("Window").items([
MenuItem::action(t!("menu_minimize").to_string(), MinimizeWindow),
MenuItem::action(t!("menu_zoom").to_string(), ZoomWindow),
]),
Menu::new(t!("menu_help").to_string()).items([MenuItem::action(
t!("menu_project_website").to_string(),
OpenProjectWebsite,
)]),
]);
}
+88 -17
View File
@@ -1,6 +1,7 @@
use anyhow::{Context as _, Result};
use gpui::{App, Context, SharedString, Window, px};
use gpui_component::{ActiveTheme as _, Theme, ThemeMode, ThemeRegistry};
use rust_i18n::t;
use crate::Ashell;
@@ -9,19 +10,76 @@ 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) const COMMON_SYSTEM_UI_FONTS: &[&str] = &[
"Helvetica",
"Segoe UI",
"Ubuntu",
"Adwaita Sans",
"Cantarell",
"Noto Sans",
"DejaVu Sans",
"Arial",
"San Francisco",
".SF NS Text",
".SF NS Display",
"Segoe UI Variable",
"PingFang SC",
"Microsoft YaHei",
"SimSun",
];
pub(crate) fn resolve_safe_ui_font(cx: &App, requested_font: &str) -> String {
let all_fonts = cx.text_system().all_font_names();
if requested_font == ".SystemUIFont" {
let has_system_ui = COMMON_SYSTEM_UI_FONTS
.iter()
.any(|font| all_fonts.contains(&font.to_string()));
if has_system_ui {
".SystemUIFont".to_string()
} else {
"Maple Mono NF CN".to_string()
}
} else if all_fonts.contains(&requested_font.to_string()) {
requested_font.to_string()
} else {
let has_system_ui = COMMON_SYSTEM_UI_FONTS
.iter()
.any(|font| all_fonts.contains(&font.to_string()));
if has_system_ui {
".SystemUIFont".to_string()
} else {
"Maple Mono NF CN".to_string()
}
}
}
pub(crate) fn load_fonts(cx: &mut App) -> Result<()> {
let regular = std::borrow::Cow::Borrowed(
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Regular.ttf").as_slice(),
);
let bold = std::borrow::Cow::Borrowed(
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Bold.ttf").as_slice(),
);
cx.text_system()
.add_fonts(vec![regular, bold])
.context("load Maple Mono NF CN fonts")?;
set_theme_font_names(cx.global_mut::<Theme>(), ".SystemUIFont");
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")?;
}
let safe_font = resolve_safe_ui_font(cx, ".SystemUIFont");
set_theme_font_names(cx.global_mut::<Theme>(), &safe_font);
Ok(())
}
@@ -113,9 +171,23 @@ impl Ashell {
}
rust_i18n::set_locale(&active_locale);
gpui_component::set_locale(&active_locale);
if let Err(err) = self.config.save() {
tracing::warn!("failed to save language preferences: {err:#}");
}
crate::app::system_menu::set_app_menus(cx);
self.connection_filter_input.update(cx, |input, cx| {
input.set_placeholder(t!("filter_connections").to_string(), window, cx);
});
self.connection_group_name_input.update(cx, |input, cx| {
input.set_placeholder(t!("connection_group_name").to_string(), window, cx);
});
self.command_history_filter_input.update(cx, |input, cx| {
input.set_placeholder(t!("filter_command_history").to_string(), window, cx);
});
self.remote_process_filter_input.update(cx, |input, cx| {
input.set_placeholder(t!("filter_processes").to_string(), window, cx);
});
self.remote_port_filter_input.update(cx, |input, cx| {
input.set_placeholder(t!("filter_ports").to_string(), window, cx);
});
self.save_preferences_background();
window.refresh();
cx.notify();
}
@@ -131,11 +203,12 @@ impl Ashell {
.get(&self.dark_theme_name)
.cloned()
.unwrap_or_else(|| ThemeRegistry::global(cx).default_dark_theme().clone());
let safe_font = resolve_safe_ui_font(cx, &self.ui_font_family);
let theme = Theme::global_mut(cx);
theme.light_theme = light_theme;
theme.dark_theme = dark_theme;
theme.font_size = px(self.ui_font_size);
set_theme_font_names(theme, &self.ui_font_family);
set_theme_font_names(theme, &safe_font);
if self.follow_system_theme {
Theme::sync_system_appearance(Some(window), cx);
@@ -155,8 +228,6 @@ impl Ashell {
self.light_theme_name.to_string(),
self.dark_theme_name.to_string(),
);
if let Err(err) = self.config.save() {
tracing::warn!("failed to save theme preferences: {err:#}");
}
self.save_preferences_background();
}
}
+4658 -1303
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
//! Window dragging helpers for the integrated title bar.
//!
//! GPUI implements `Window::start_window_move` on macOS and Linux, but the
//! Windows backend leaves it as a no-op. On Windows we post a native
//! `WM_SYSCOMMAND` / `SC_MOVE | HTCAPTION` message so the GPUI pointer callback
//! can finish before `DefWindowProc` enters its modal window-move loop.
/// Begin a native window drag from a mouse-down on the integrated title bar.
pub(crate) fn start_window_drag(window: &gpui::Window) {
#[cfg(target_os = "windows")]
{
use windows::Win32::Foundation::{HWND, LPARAM, WPARAM};
use windows::Win32::UI::Input::KeyboardAndMouse::ReleaseCapture;
use windows::Win32::UI::WindowsAndMessaging::{
HTCAPTION, PostMessageW, SC_MOVE, WM_SYSCOMMAND,
};
let Some(handle) = crate::desktop_notification::native_window_handle(window) else {
tracing::warn!("failed to start window drag: missing native window handle");
return;
};
unsafe {
// The button-down capture must be released before the system move
// loop can take over the pointer.
let _ = ReleaseCapture();
if let Err(err) = PostMessageW(
HWND(handle as _),
WM_SYSCOMMAND,
WPARAM((SC_MOVE | HTCAPTION) as usize),
LPARAM(0),
) {
tracing::warn!("failed to start native window drag: {err}");
}
}
}
#[cfg(not(target_os = "windows"))]
{
window.start_window_move();
}
}
+617 -15
View File
@@ -1,19 +1,480 @@
use std::{
fs,
io::{Read, Write},
sync::mpsc::{self, Sender},
path::{Path, PathBuf},
sync::{OnceLock, mpsc},
thread,
time::Duration,
};
#[cfg(not(windows))]
use std::time::Instant;
use anyhow::{Context, Result};
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
#[cfg(not(windows))]
use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
use crate::terminal::{BackendCommand, BackendEvent, BackendTx};
use crate::session::config::LocalTerminalShell;
use crate::terminal::{BackendCommand, BackendEvent, BackendTx, GuardedBackendEventSender};
pub fn spawn_local_terminal(
#[cfg(not(windows))]
const DIRECTORY_POLL_INTERVAL: Duration = Duration::from_secs(1);
const BASH_INTEGRATION_RC: &str = r#"# Generated by ashell. User startup files remain authoritative.
if [[ ${ASHELL_BASH_LOGIN:-0} == 1 ]]; then
[[ -r /etc/profile ]] && source /etc/profile
if [[ -r $HOME/.bash_profile ]]; then
source "$HOME/.bash_profile"
elif [[ -r $HOME/.bash_login ]]; then
source "$HOME/.bash_login"
elif [[ -r $HOME/.profile ]]; then
source "$HOME/.profile"
fi
elif [[ -r $HOME/.bashrc ]]; then
source "$HOME/.bashrc"
fi
if [[ -z ${__ASHELL_BASH_INTEGRATION_ACTIVE:-} ]]; then
__ASHELL_BASH_INTEGRATION_ACTIVE=1
__ashell_prompt_suffix='\[\e]133;B\a\]'
__ashell_secondary_prefix='\[\e]133;A;k=s\a\]'
__ashell_install_prompt_markers() {
PS1=${PS1//"$__ashell_prompt_suffix"/}
PS1+=$__ashell_prompt_suffix
PS2=${PS2//"$__ashell_secondary_prefix"/}
PS2=${PS2//"$__ashell_prompt_suffix"/}
PS2="${__ashell_secondary_prefix}${PS2}${__ashell_prompt_suffix}"
}
__ashell_precmd() {
local __ashell_last_status=$?
local __ashell_cwd=$PWD
__ashell_install_prompt_markers
if [[ ${ASHELL_WINDOWS_GIT_BASH:-0} == 1 ]]; then
__ashell_cwd=$(pwd -W 2>/dev/null) || __ashell_cwd=$PWD
fi
printf '\033]133;D;%s\007\033]0;ASHELL_CWD:%s\007\033]133;A\007' \
"$__ashell_last_status" "$__ashell_cwd"
return "$__ashell_last_status"
}
case $(declare -p PROMPT_COMMAND 2>/dev/null) in
'declare -a'*)
PROMPT_COMMAND+=(__ashell_precmd)
;;
*)
[[ -n ${PROMPT_COMMAND:-} ]] && PROMPT_COMMAND+=$'\n'
PROMPT_COMMAND+='__ashell_precmd'
;;
esac
__ashell_install_prompt_markers
fi
"#;
const ZSH_ENV_WRAPPER: &str = r#"# Generated by ashell. Redirect startup back to the user's files first.
typeset -g ASHELL_USER_ZDOTDIR="${ASHELL_ORIGINAL_ZDOTDIR:-$HOME}"
if [[ "$ASHELL_USER_ZDOTDIR" != "$ASHELL_INTEGRATION_ZDOTDIR" && -r "$ASHELL_USER_ZDOTDIR/.zshenv" ]]; then
export ZDOTDIR="$ASHELL_USER_ZDOTDIR"
source "$ASHELL_USER_ZDOTDIR/.zshenv"
typeset -g ASHELL_USER_ZDOTDIR="${ZDOTDIR:-$ASHELL_USER_ZDOTDIR}"
fi
export ASHELL_USER_ZDOTDIR
export ZDOTDIR="$ASHELL_INTEGRATION_ZDOTDIR"
"#;
const ZSH_INTEGRATION_RC: &str = r#"# Generated by ashell. User startup files remain authoritative.
if [[ ${HISTFILE:-} == "$ASHELL_INTEGRATION_ZDOTDIR"/* ]]; then
HISTFILE="$ASHELL_USER_ZDOTDIR/${HISTFILE#"$ASHELL_INTEGRATION_ZDOTDIR"/}"
fi
if [[ "$ASHELL_USER_ZDOTDIR" != "$ASHELL_INTEGRATION_ZDOTDIR" && -r "$ASHELL_USER_ZDOTDIR/.zshrc" ]]; then
export ZDOTDIR="$ASHELL_USER_ZDOTDIR"
source "$ASHELL_USER_ZDOTDIR/.zshrc"
typeset -g ASHELL_USER_ZDOTDIR="${ZDOTDIR:-$ASHELL_USER_ZDOTDIR}"
export ZDOTDIR="$ASHELL_INTEGRATION_ZDOTDIR"
fi
if [[ -z ${__ASHELL_ZSH_INTEGRATION_ACTIVE:-} ]]; then
typeset -g __ASHELL_ZSH_INTEGRATION_ACTIVE=1
typeset -g __ashell_prompt_suffix=$'%{\e]133;B\a%}'
typeset -g __ashell_secondary_prefix=$'%{\e]133;A;k=s\a%}'
__ashell_install_prompt_markers() {
PROMPT=${PROMPT//$__ashell_prompt_suffix/}
PROMPT+=$__ashell_prompt_suffix
PS2=${PS2//$__ashell_secondary_prefix/}
PS2=${PS2//$__ashell_prompt_suffix/}
PS2="${__ashell_secondary_prefix}${PS2}${__ashell_prompt_suffix}"
}
__ashell_precmd() {
local __ashell_last_status=$?
__ashell_install_prompt_markers
print -rn -- $'\e]133;D;'"$__ashell_last_status"$'\a'
print -rn -- $'\e]0;ASHELL_CWD:'"$PWD"$'\a'
print -rn -- $'\e]133;A\a'
return "$__ashell_last_status"
}
__ashell_preexec() {
print -rn -- $'\e]133;C\a'
}
autoload -Uz add-zsh-hook
add-zsh-hook -d precmd __ashell_precmd 2>/dev/null
add-zsh-hook -d preexec __ashell_preexec 2>/dev/null
add-zsh-hook precmd __ashell_precmd
add-zsh-hook preexec __ashell_preexec
__ashell_install_prompt_markers
fi
export ZDOTDIR="$ASHELL_USER_ZDOTDIR"
"#;
const FISH_INTEGRATION_COMMAND: &str = r#"
if status --is-interactive; and not functions -q __ashell_original_fish_prompt
functions -c fish_prompt __ashell_original_fish_prompt
function __ashell_common_prompt
set -l __ashell_last_status $status
printf '\033]133;D;%s\007' $__ashell_last_status
printf '\033]0;ASHELL_CWD:%s\007' $PWD
printf '\033]133;A\007'
return $__ashell_last_status
end
function __ashell_preexec --on-event fish_preexec
printf '\033]133;C\007'
end
function fish_prompt
__ashell_common_prompt
__ashell_original_fish_prompt
printf '\033]133;B\007'
end
end
"#;
#[cfg(any(windows, test))]
const POWERSHELL_SHELL_INTEGRATION: &str = r#"& {
$global:AshellOriginalPrompt = $function:prompt
function global:prompt {
$promptText = if ($global:AshellOriginalPrompt) { & $global:AshellOriginalPrompt } else { "PS $PWD> " }
$cwd = $PWD.ProviderPath
$encoded = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($cwd))
[Console]::Write("$([char]27)]133;D$([char]7)$([char]27)]133;A$([char]7)")
[Console]::Write("$([char]27)]0;ASHELL_CWD_B64:$encoded$([char]7)")
"$promptText$([char]27)]133;B$([char]7)"
}
try {
$options = Get-PSReadLineOption -ErrorAction Stop
$continuation = "$([char]27)]133;A;k=s$([char]7)$($options.ContinuationPrompt)$([char]27)]133;B$([char]7)"
Set-PSReadLineOption -ContinuationPrompt $continuation -ErrorAction Stop
} catch {}
}"#;
#[derive(Debug)]
struct ShellIntegrationPaths {
bash_rc: PathBuf,
zsh_dir: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FileShellIntegration {
Bash { login: bool },
Zsh,
Fish,
}
static SHELL_INTEGRATION_PATHS: OnceLock<Result<ShellIntegrationPaths, String>> = OnceLock::new();
fn file_shell_integration(executable: &Path, login_bash: bool) -> Option<FileShellIntegration> {
let executable_name = executable
.file_stem()?
.to_string_lossy()
.to_ascii_lowercase();
match executable_name.as_str() {
"bash" => Some(FileShellIntegration::Bash { login: login_bash }),
"zsh" => Some(FileShellIntegration::Zsh),
"fish" => Some(FileShellIntegration::Fish),
_ => None,
}
}
fn shell_integration_paths() -> Result<&'static ShellIntegrationPaths> {
SHELL_INTEGRATION_PATHS
.get_or_init(|| create_shell_integration_paths().map_err(|error| format!("{error:#}")))
.as_ref()
.map_err(|message| anyhow::anyhow!("{message}"))
}
fn create_shell_integration_paths() -> Result<ShellIntegrationPaths> {
let root = std::env::temp_dir().join(format!(
"ashell-shell-integration-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
let zsh_dir = root.join("zsh");
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(&zsh_dir)
.with_context(|| format!("create shell integration directory {}", zsh_dir.display()))?;
}
#[cfg(not(unix))]
fs::create_dir_all(&zsh_dir)
.with_context(|| format!("create shell integration directory {}", zsh_dir.display()))?;
let bash_rc = root.join("bashrc");
write_shell_integration_file(&bash_rc, BASH_INTEGRATION_RC)?;
write_shell_integration_file(&zsh_dir.join(".zshenv"), ZSH_ENV_WRAPPER)?;
write_shell_integration_file(&zsh_dir.join(".zshrc"), ZSH_INTEGRATION_RC)?;
Ok(ShellIntegrationPaths { bash_rc, zsh_dir })
}
fn write_shell_integration_file(path: &Path, contents: &str) -> Result<()> {
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options
.open(path)
.with_context(|| format!("create shell integration file {}", path.display()))?;
file.write_all(contents.as_bytes())
.with_context(|| format!("write shell integration file {}", path.display()))
}
fn configure_file_shell_integration(
cmd: &mut CommandBuilder,
executable: &Path,
login_bash: bool,
) -> Result<bool> {
let Some(integration) = file_shell_integration(executable, login_bash) else {
return Ok(false);
};
match integration {
FileShellIntegration::Bash { login } => {
let paths = shell_integration_paths()?;
cmd.env("ASHELL_BASH_LOGIN", if login { "1" } else { "0" });
cmd.env("ASHELL_WINDOWS_GIT_BASH", if login { "1" } else { "0" });
cmd.arg("--rcfile");
cmd.arg(paths.bash_rc.as_os_str());
cmd.arg("-i");
}
FileShellIntegration::Zsh => {
let paths = shell_integration_paths()?;
if let Some(original_zdotdir) = std::env::var_os("ZDOTDIR") {
cmd.env("ASHELL_ORIGINAL_ZDOTDIR", original_zdotdir);
}
cmd.env("ASHELL_INTEGRATION_ZDOTDIR", paths.zsh_dir.as_os_str());
cmd.env("ZDOTDIR", paths.zsh_dir.as_os_str());
}
FileShellIntegration::Fish => {
cmd.args(["-C", FISH_INTEGRATION_COMMAND]);
}
}
cmd.env("ASHELL_SHELL_INTEGRATION", "1");
Ok(true)
}
#[cfg(any(windows, test))]
fn command_prompt_with_shell_integration(original_prompt: &str) -> String {
format!("\x1b]133;D\x07\x1b]133;A\x07\x1b]0;ASHELL_CWD:$P\x07{original_prompt}\x1b]133;B\x07")
}
#[cfg(not(windows))]
fn local_process_directory(system: &mut System, pid: Pid) -> Option<std::path::PathBuf> {
system.refresh_processes_specifics(
ProcessesToUpdate::Some(&[pid]),
false,
ProcessRefreshKind::nothing().with_cwd(UpdateKind::Always),
);
system
.process(pid)
.and_then(|process| process.cwd())
.map(std::path::PathBuf::from)
}
#[derive(Debug, Clone)]
pub struct LocalTerminalShellLaunch {
pub executable: PathBuf,
}
/// Resolves a configured local shell to an executable available on this host.
pub fn resolve_local_terminal_shell(
shell: LocalTerminalShell,
) -> anyhow::Result<LocalTerminalShellLaunch> {
#[cfg(not(windows))]
let _ = shell;
#[cfg(windows)]
{
let executable = match shell {
LocalTerminalShell::WindowsPowerShell => resolve_windows_powershell(),
LocalTerminalShell::PowerShell7 => resolve_powershell7(),
LocalTerminalShell::CommandPrompt => resolve_command_prompt(),
LocalTerminalShell::GitBash => resolve_git_bash(),
}
.ok_or_else(|| anyhow::anyhow!("configured local shell is not installed: {shell:?}"))?;
return Ok(LocalTerminalShellLaunch { executable });
}
#[cfg(not(windows))]
{
let executable = std::env::var_os("SHELL")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/bin/zsh"));
Ok(LocalTerminalShellLaunch { executable })
}
}
pub fn local_terminal_shell_available(shell: LocalTerminalShell) -> bool {
resolve_local_terminal_shell(shell).is_ok()
}
#[cfg(windows)]
fn existing_file(path: PathBuf) -> Option<PathBuf> {
path.is_file().then_some(path)
}
#[cfg(windows)]
fn env_path(name: &str) -> Option<PathBuf> {
std::env::var_os(name).map(PathBuf::from)
}
#[cfg(windows)]
fn find_on_path(name: &str, predicate: impl Fn(&Path) -> bool) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|directory| directory.join(name))
.find(|candidate| candidate.is_file() && predicate(candidate))
}
#[cfg(windows)]
fn resolve_windows_powershell() -> Option<PathBuf> {
let system_root = env_path("SystemRoot").unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
existing_file(system_root.join(r"System32\WindowsPowerShell\v1.0\powershell.exe"))
.or_else(|| find_on_path("powershell.exe", |_| true))
}
#[cfg(windows)]
fn resolve_powershell7() -> Option<PathBuf> {
let mut roots = Vec::new();
if let Some(program_files) = env_path("ProgramFiles") {
roots.push(program_files.join("PowerShell"));
}
if let Some(program_files) = env_path("ProgramW6432") {
roots.push(program_files.join("PowerShell"));
}
if let Some(program_files) = env_path("ProgramFiles(x86)") {
roots.push(program_files.join("PowerShell"));
}
if let Some(local_app_data) = env_path("LOCALAPPDATA") {
roots.push(local_app_data.join(r"Programs\PowerShell"));
}
let mut candidates = Vec::new();
if let Some(local_app_data) = env_path("LOCALAPPDATA") {
candidates.push(local_app_data.join(r"Microsoft\WindowsApps\pwsh.exe"));
}
if let Some(scoop) = env_path("SCOOP") {
candidates.push(scoop.join(r"apps\powershell\current\pwsh.exe"));
}
if let Some(user_profile) = env_path("USERPROFILE") {
candidates.push(user_profile.join(r"scoop\apps\powershell\current\pwsh.exe"));
}
for root in roots {
if let Some(candidate) = existing_file(root.join("pwsh.exe")) {
candidates.push(candidate);
}
let Ok(entries) = std::fs::read_dir(root) else {
continue;
};
let mut versions = entries
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false))
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.chars()
.all(|character| character.is_ascii_digit() || character == '.')
})
.collect::<Vec<_>>();
versions.sort_by_key(|entry| entry.file_name());
candidates.extend(
versions
.into_iter()
.rev()
.filter_map(|entry| existing_file(entry.path().join("pwsh.exe"))),
);
}
candidates
.into_iter()
.next()
.or_else(|| find_on_path("pwsh.exe", |_| true))
}
#[cfg(windows)]
fn resolve_command_prompt() -> Option<PathBuf> {
env_path("ComSpec")
.and_then(existing_file)
.or_else(|| {
let system_root =
env_path("SystemRoot").unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
existing_file(system_root.join(r"System32\cmd.exe"))
})
.or_else(|| find_on_path("cmd.exe", |_| true))
}
#[cfg(windows)]
fn resolve_git_bash() -> Option<PathBuf> {
let mut candidates = Vec::new();
for variable in ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] {
if let Some(program_files) = env_path(variable) {
candidates.push(program_files.join(r"Git\bin\bash.exe"));
}
}
if let Some(local_app_data) = env_path("LOCALAPPDATA") {
candidates.push(local_app_data.join(r"Programs\Git\bin\bash.exe"));
}
if let Some(scoop) = env_path("SCOOP") {
candidates.push(scoop.join(r"apps\git\current\bin\bash.exe"));
}
if let Some(user_profile) = env_path("USERPROFILE") {
candidates.push(user_profile.join(r"scoop\apps\git\current\bin\bash.exe"));
}
candidates.into_iter().find_map(existing_file).or_else(|| {
find_on_path("bash.exe", |candidate| {
let path = candidate.to_string_lossy().to_ascii_lowercase();
path.contains("\\git\\") || path.contains("/git/")
})
})
}
pub fn spawn_local_terminal_at(
tab_id: String,
cols: u16,
rows: u16,
events: Sender<BackendEvent>,
events: GuardedBackendEventSender,
initial_directory: Option<&Path>,
shell: LocalTerminalShell,
) -> Result<BackendTx> {
let pty_system = native_pty_system();
let pair = pty_system
@@ -25,15 +486,51 @@ pub fn spawn_local_terminal(
})
.context("open local PTY")?;
let shell = std::env::var("SHELL").unwrap_or_else(|_| {
if cfg!(windows) {
"powershell.exe".into()
} else {
"/bin/zsh".into()
let launch = resolve_local_terminal_shell(shell).context("resolve local shell")?;
let mut cmd = CommandBuilder::new(&launch.executable);
#[cfg(windows)]
{
match shell {
LocalTerminalShell::WindowsPowerShell | LocalTerminalShell::PowerShell7 => {
cmd.args([
"-NoLogo",
"-NoExit",
"-Command",
POWERSHELL_SHELL_INTEGRATION,
]);
}
LocalTerminalShell::CommandPrompt => {
let original_prompt =
std::env::var("PROMPT").unwrap_or_else(|_| "$P$G".to_string());
cmd.args(["/Q"]);
cmd.env(
"PROMPT",
command_prompt_with_shell_integration(&original_prompt),
);
}
LocalTerminalShell::GitBash => {
cmd.env("CHERE_INVOKING", "1");
}
}
});
let mut cmd = CommandBuilder::new(&shell);
}
let login_bash = cfg!(windows) && shell == LocalTerminalShell::GitBash;
let shell_integration_configured =
match configure_file_shell_integration(&mut cmd, &launch.executable, login_bash) {
Ok(configured) => configured,
Err(error) => {
tracing::warn!(
"[local] shell integration unavailable for {}: {error:#}",
launch.executable.display()
);
false
}
};
#[cfg(windows)]
if shell == LocalTerminalShell::GitBash && !shell_integration_configured {
cmd.args(["--login", "-i"]);
}
#[cfg(not(windows))]
let _ = shell_integration_configured;
cmd.env(
"TERM",
std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".into()),
@@ -54,8 +551,13 @@ pub fn spawn_local_terminal(
if let Ok(home) = std::env::var("HOME") {
cmd.env("HOME", home);
}
cmd.env("SHELL", shell);
if let Some(directory) = initial_directory.filter(|path| path.is_dir()) {
cmd.cwd(directory.as_os_str());
}
cmd.env("SHELL", launch.executable.as_os_str());
let mut child = pair.slave.spawn_command(cmd).context("spawn local shell")?;
#[cfg(not(windows))]
let child_pid = child.process_id().map(Pid::from_u32);
drop(pair.slave);
let master = pair.master;
@@ -94,8 +596,15 @@ pub fn spawn_local_terminal(
let write_tab = tab_id.clone();
let write_events = events.clone();
thread::spawn(move || {
#[cfg(not(windows))]
let mut process_system = System::new();
#[cfg(not(windows))]
let mut last_directory = None;
#[cfg(not(windows))]
let mut last_directory_check = None;
loop {
match cmd_rx.recv_timeout(std::time::Duration::from_millis(100)) {
match cmd_rx.recv_timeout(Duration::from_millis(100)) {
Ok(command) => match command {
BackendCommand::Input(bytes) => {
if let Err(err) = writer.write_all(&bytes) {
@@ -116,7 +625,10 @@ pub fn spawn_local_terminal(
});
}
BackendCommand::Close => break,
BackendCommand::SampleMetrics => {}
BackendCommand::SampleMetrics
| BackendCommand::SampleProcesses
| BackendCommand::SamplePorts
| BackendCommand::TerminateProcess { .. } => {}
},
Err(mpsc::RecvTimeoutError::Timeout) => {
if let Ok(Some(status)) = child.try_wait() {
@@ -129,6 +641,26 @@ pub fn spawn_local_terminal(
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
#[cfg(not(windows))]
{
if last_directory_check.is_none_or(|checked_at: Instant| {
checked_at.elapsed() >= DIRECTORY_POLL_INTERVAL
}) {
last_directory_check = Some(Instant::now());
if let Some(directory) =
child_pid.and_then(|pid| local_process_directory(&mut process_system, pid))
{
if last_directory.as_ref() != Some(&directory) {
last_directory = Some(directory.clone());
let _ = write_events.send(BackendEvent::LocalDirectoryChanged {
tab_id: write_tab.clone(),
path: directory,
});
}
}
}
}
}
let _ = child.kill();
});
@@ -140,3 +672,73 @@ pub fn spawn_local_terminal(
Ok(BackendTx::Local(cmd_tx))
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::{
BASH_INTEGRATION_RC, FISH_INTEGRATION_COMMAND, FileShellIntegration,
POWERSHELL_SHELL_INTEGRATION, ZSH_ENV_WRAPPER, ZSH_INTEGRATION_RC,
command_prompt_with_shell_integration, file_shell_integration,
};
#[test]
fn recognizes_supported_file_based_shells_on_unix_and_windows() {
assert_eq!(
file_shell_integration(Path::new("/bin/bash"), false),
Some(FileShellIntegration::Bash { login: false })
);
assert_eq!(
file_shell_integration(Path::new("bash.exe"), true),
Some(FileShellIntegration::Bash { login: true })
);
assert_eq!(
file_shell_integration(Path::new("/bin/zsh"), false),
Some(FileShellIntegration::Zsh)
);
assert_eq!(
file_shell_integration(Path::new("/opt/homebrew/bin/fish"), false),
Some(FileShellIntegration::Fish)
);
assert_eq!(file_shell_integration(Path::new("pwsh.exe"), false), None);
}
#[test]
fn shell_integrations_mark_prompt_and_command_boundaries() {
for integration in [
BASH_INTEGRATION_RC,
ZSH_INTEGRATION_RC,
FISH_INTEGRATION_COMMAND,
POWERSHELL_SHELL_INTEGRATION,
] {
assert!(integration.contains("133;A"));
assert!(integration.contains("133;B"));
assert!(integration.contains("133;C") || integration.contains("133;D"));
}
assert!(BASH_INTEGRATION_RC.contains("133;A;k=s"));
assert!(ZSH_INTEGRATION_RC.contains("133;A;k=s"));
assert!(POWERSHELL_SHELL_INTEGRATION.contains("133;A;k=s"));
let cmd_prompt = command_prompt_with_shell_integration("$P$G");
assert!(cmd_prompt.contains("133;A"));
assert!(cmd_prompt.contains("133;B"));
assert!(cmd_prompt.contains("ASHELL_CWD:$P"));
}
#[test]
fn startup_wrappers_load_user_configuration_before_installing_hooks() {
assert!(BASH_INTEGRATION_RC.contains("$HOME/.bashrc"));
assert!(BASH_INTEGRATION_RC.contains("$HOME/.bash_profile"));
assert!(ZSH_ENV_WRAPPER.contains("$ASHELL_USER_ZDOTDIR/.zshenv"));
assert!(ZSH_INTEGRATION_RC.contains("$ASHELL_USER_ZDOTDIR/.zshrc"));
}
#[test]
fn zsh_integration_restores_user_history_path() {
assert!(ZSH_INTEGRATION_RC.contains(r#"${HISTFILE:-} == "$ASHELL_INTEGRATION_ZDOTDIR"/*"#));
assert!(ZSH_INTEGRATION_RC.contains(
r#"HISTFILE="$ASHELL_USER_ZDOTDIR/${HISTFILE#"$ASHELL_INTEGRATION_ZDOTDIR"/}""#
));
}
}
+1
View File
@@ -1,2 +1,3 @@
pub mod local;
pub mod serial;
pub mod ssh;
+233
View File
@@ -0,0 +1,233 @@
use crate::session::config::Session;
use crate::terminal::{BackendCommand, BackendEvent, GuardedBackendEventSender};
use std::io::{Read, Write};
/// Spawn the serial port backend threads.
/// Returns a sender to send commands (like keyboard inputs) to the serial port.
pub fn spawn_serial_client(
_handle: &tokio::runtime::Handle,
tab_id: String,
session: Session,
events_tx: GuardedBackendEventSender,
) -> tokio::sync::mpsc::UnboundedSender<BackendCommand> {
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<BackendCommand>();
let tab_id_clone = tab_id.clone();
let events_tx_clone = events_tx.clone();
std::thread::spawn(move || {
let _ = events_tx_clone.send(BackendEvent::Status {
tab_id: tab_id_clone.clone(),
text: rust_i18n::t!("starting_connection").to_string(),
});
let port_name = session.host;
let baud_rate = session.baud_rate;
tracing::info!(
"[serial] opening port {} at baud rate {}",
port_name,
baud_rate
);
let mut port_result = serialport::new(&port_name, baud_rate)
.timeout(std::time::Duration::from_millis(100))
.open();
if port_result.is_err() && baud_rate != 0 {
tracing::info!(
"[serial] failed to open port with baud rate {}, retrying with 0 (virtual port mode)",
baud_rate
);
port_result = serialport::new(&port_name, 0)
.timeout(std::time::Duration::from_millis(100))
.open();
}
let mut port = match port_result {
Ok(p) => p,
Err(e) => {
tracing::error!("[serial] failed to open port {}: {}", port_name, e);
let _ = events_tx_clone.send(BackendEvent::Closed {
tab_id: tab_id_clone,
reason: format!("Failed to open serial port {port_name}: {e}"),
});
return;
}
};
let mut port_write = match port.try_clone() {
Ok(pw) => pw,
Err(e) => {
tracing::error!("[serial] failed to clone port: {}", e);
let _ = events_tx_clone.send(BackendEvent::Closed {
tab_id: tab_id_clone,
reason: format!("Failed to clone serial port: {e}"),
});
return;
}
};
// Notify connected
let _ = events_tx_clone.send(BackendEvent::Connected {
tab_id: tab_id_clone.clone(),
});
// Spawn write thread
let tab_id_write = tab_id_clone.clone();
let events_tx_write = events_tx_clone.clone();
std::thread::spawn(move || {
while let Some(cmd) = cmd_rx.blocking_recv() {
match cmd {
BackendCommand::Input(bytes) => {
if let Err(e) = port_write.write_all(&bytes) {
tracing::error!("[serial] write error: {}", e);
let _ = events_tx_write.send(BackendEvent::Closed {
tab_id: tab_id_write.clone(),
reason: format!("Serial write error: {e}"),
});
break;
}
let _ = port_write.flush();
}
BackendCommand::Close => break,
BackendCommand::Resize { .. }
| BackendCommand::SampleMetrics
| BackendCommand::SampleProcesses
| BackendCommand::SamplePorts
| BackendCommand::TerminateProcess { .. } => {}
}
}
});
// Read loop in current thread
let mut buf = [0u8; 1024];
let mut last_was_cr = false;
loop {
match port.read(&mut buf) {
Ok(n) if n > 0 => {
let mut processed = Vec::with_capacity(n * 2);
let read_bytes = &buf[..n];
for i in 0..n {
let b = read_bytes[i];
if b == b'\n' {
let prev_was_cr = if i > 0 {
read_bytes[i - 1] == b'\r'
} else {
last_was_cr
};
if !prev_was_cr {
processed.push(b'\r');
}
}
processed.push(b);
}
last_was_cr = read_bytes[n - 1] == b'\r';
let _ = events_tx_clone.send(BackendEvent::Output {
tab_id: tab_id_clone.clone(),
bytes: processed,
});
}
Ok(_) => {}
Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => {}
Err(e) => {
tracing::info!("[serial] port read error/closed: {}", e);
let _ = events_tx_clone.send(BackendEvent::Closed {
tab_id: tab_id_clone,
reason: format!("Serial read error: {e}"),
});
break;
}
}
}
});
cmd_tx
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use portable_pty::{NativePtySystem, PtySize, PtySystem};
#[tokio::test]
async fn test_serial_read_write_simulation() {
// 1. Create a PTY pair using portable-pty to simulate a serial device
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
// On macOS/Linux, the slave name path behaves like a TTY device.
let fd = pair.master.as_raw_fd().unwrap();
let slave_name = unsafe {
let ptr = libc::ptsname(fd);
assert!(!ptr.is_null());
std::ffi::CStr::from_ptr(ptr).to_string_lossy().into_owned()
};
println!("Simulating serial device on PTY slave path: {}", slave_name);
// 2. Spawn the serial backend targeting the PTY slave path
let (events_tx, events_rx) = std::sync::mpsc::channel();
let handle = tokio::runtime::Handle::current();
let session = Session::serial(slave_name, 0);
let backend_events = GuardedBackendEventSender::new(events_tx);
let cmd_tx = spawn_serial_client(&handle, "test-tab".to_string(), session, backend_events);
// Wait for the Status event
let status_event = events_rx
.recv_timeout(std::time::Duration::from_secs(2))
.expect("Failed to receive Status event")
.into_current();
match status_event {
Some(BackendEvent::Status { tab_id, .. }) => assert_eq!(tab_id, "test-tab"),
event => panic!("Expected Status event, got: {event:?}"),
}
// Wait for the Connected event
let connected_event = events_rx
.recv_timeout(std::time::Duration::from_secs(2))
.expect("Failed to receive Connected event")
.into_current();
match connected_event {
Some(BackendEvent::Connected { tab_id }) => assert_eq!(tab_id, "test-tab"),
event => panic!("Expected Connected event, got: {event:?}"),
}
// 3. Test Reading: Write to PTY master, verify serial backend outputs it to UI
let mut master_writer = pair.master.take_writer().unwrap();
master_writer.write_all(b"hello serial simulator").unwrap();
master_writer.flush().unwrap();
let output_event = events_rx
.recv_timeout(std::time::Duration::from_secs(2))
.expect("Failed to receive Output event")
.into_current();
match output_event {
Some(BackendEvent::Output { tab_id, bytes }) => {
assert_eq!(tab_id, "test-tab");
assert_eq!(bytes, b"hello serial simulator");
}
event => panic!("Expected Output event, got: {event:?}"),
}
// 4. Test Writing: Send BackendCommand::Input to backend, verify PTY master reads it
cmd_tx
.send(BackendCommand::Input(b"world serial simulator".to_vec()))
.unwrap();
let mut master_reader = pair.master.try_clone_reader().unwrap();
let mut read_buf = [0u8; 128];
let bytes_read = master_reader.read(&mut read_buf).unwrap();
assert_eq!(&read_buf[..bytes_read], b"world serial simulator");
// 5. Clean up
cmd_tx.send(BackendCommand::Close).unwrap();
}
}
+732 -120
View File
@@ -5,18 +5,28 @@ use std::{
use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use base64::Engine as _;
use directories::BaseDirs;
use russh::{
ChannelMsg, Disconnect,
client::{self, Handler},
keys::{HashAlg, PrivateKey, decode_secret_key, key::PrivateKeyWithHashAlg, load_secret_key},
keys::{PrivateKey, decode_secret_key, load_secret_key},
};
use tokio::sync::mpsc;
use crate::{
session::config::{AuthMethod, Session},
system::{SystemSnapshot, remote_snapshot_from_kv},
terminal::{BackendCommand, BackendEvent, BackendTx},
session::{
config::{AuthMethod, Session},
ssh_keys::{
authenticate_with_default_keys, normalize_inline_private_key, private_keys_with_algs,
session_has_explicit_key,
},
},
system::{
RemotePort, RemoteProcess, SystemSnapshot, remote_ports_from_probe,
remote_processes_from_ps, remote_snapshot_from_kv,
},
terminal::{BackendCommand, BackendEvent, BackendTx, GuardedBackendEventSender},
};
pub fn spawn_ssh_terminal(
@@ -25,7 +35,7 @@ pub fn spawn_ssh_terminal(
session: Session,
cols: u16,
rows: u16,
events: std::sync::mpsc::Sender<BackendEvent>,
events: GuardedBackendEventSender,
) -> BackendTx {
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<BackendCommand>();
let task_tab = tab_id.clone();
@@ -52,30 +62,213 @@ pub fn spawn_ssh_terminal(
async fn sample_remote_system_with_handle(
handle: Arc<tokio::sync::Mutex<russh::client::Handle<ClientHandler>>>,
) -> Result<SystemSnapshot> {
let unix_result = async {
let output = execute_remote_command_with_handle(
handle.clone(),
REMOTE_SYSTEM_PROBE,
"Unix remote metrics probe",
)
.await?;
remote_snapshot_from_kv(&output).context("parse Unix remote metrics probe")
}
.await;
match unix_result {
Ok(snapshot) => Ok(snapshot),
Err(unix_error) => {
let command = powershell_encoded_command(REMOTE_WINDOWS_SYSTEM_PROBE);
let output = execute_remote_command_with_handle(
handle,
&command,
"Windows remote metrics probe",
)
.await
.with_context(|| format!("Unix probe failed first: {unix_error:#}"))?;
remote_snapshot_from_kv(&output)
.context("parse Windows remote metrics probe")
.with_context(|| format!("Unix probe failed first: {unix_error:#}"))
}
}
}
async fn execute_remote_command_with_handle(
handle: Arc<tokio::sync::Mutex<russh::client::Handle<ClientHandler>>>,
command: &str,
operation: &str,
) -> Result<String> {
let mut channel = handle
.lock()
.await
.channel_open_session()
.await
.context("open metrics session")?;
.with_context(|| format!("open {operation} session"))?;
channel
.exec(true, REMOTE_SYSTEM_PROBE)
.exec(true, command)
.await
.context("exec remote metrics probe")?;
.with_context(|| format!("execute {operation}"))?;
let mut stdout = Vec::new();
while let Some(msg) = channel.wait().await {
match msg {
ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, ext: _ } => {
stdout.extend_from_slice(&data);
let mut output = Vec::new();
let mut exit_status = None;
let wait_result = tokio::time::timeout(std::time::Duration::from_secs(20), async {
while let Some(msg) = channel.wait().await {
match msg {
ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, ext: _ } => {
output.extend_from_slice(&data);
}
ChannelMsg::ExitStatus {
exit_status: status,
} => exit_status = Some(status),
ChannelMsg::Close => break,
_ => {}
}
ChannelMsg::Close => break,
_ => {}
}
})
.await;
if wait_result.is_err() {
return Err(anyhow!("{operation} timed out after 20 seconds"));
}
let output = String::from_utf8_lossy(&stdout);
remote_snapshot_from_kv(&output)
let output = String::from_utf8_lossy(&output).trim().to_string();
if exit_status.is_none_or(|status| status != 0) {
let detail = if output.is_empty() {
exit_status.map_or_else(
|| "remote command closed without an exit status".to_string(),
|status| format!("exit status {status}"),
)
} else {
output
};
return Err(anyhow!("{operation} failed: {detail}"));
}
Ok(output)
}
async fn sample_remote_processes_with_handle(
handle: Arc<tokio::sync::Mutex<russh::client::Handle<ClientHandler>>>,
) -> Result<Vec<RemoteProcess>> {
let unix_result = async {
let output = execute_remote_command_with_handle(
handle.clone(),
REMOTE_PROCESS_PROBE,
"Unix remote process probe",
)
.await?;
parse_remote_process_probe(&output, "Unix remote process probe")
}
.await;
match unix_result {
Ok(processes) => Ok(processes),
Err(unix_error) => {
let command = powershell_encoded_command(REMOTE_WINDOWS_PROCESS_PROBE);
let output = execute_remote_command_with_handle(
handle,
&command,
"Windows remote process probe",
)
.await
.with_context(|| format!("Unix probe failed first: {unix_error:#}"))?;
parse_remote_process_probe(&output, "Windows remote process probe")
.with_context(|| format!("Unix probe failed first: {unix_error:#}"))
}
}
}
fn parse_remote_process_probe(output: &str, operation: &str) -> Result<Vec<RemoteProcess>> {
let processes = remote_processes_from_ps(output);
if !processes.is_empty() {
return Ok(processes);
}
let detail = output
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("empty output");
Err(anyhow!(
"{operation} returned no parseable process rows: {detail}"
))
}
async fn sample_remote_ports_with_handle(
handle: Arc<tokio::sync::Mutex<russh::client::Handle<ClientHandler>>>,
) -> Result<Vec<RemotePort>> {
let unix_result = async {
let output = execute_remote_command_with_handle(
handle.clone(),
REMOTE_PORT_PROBE,
"Unix remote port probe",
)
.await?;
parse_remote_port_probe(&output, "Unix remote port probe")
}
.await;
match unix_result {
Ok(ports) => Ok(ports),
Err(unix_error) => {
let command = powershell_encoded_command(REMOTE_WINDOWS_PORT_PROBE);
let output =
execute_remote_command_with_handle(handle, &command, "Windows remote port probe")
.await
.with_context(|| format!("Unix probe failed first: {unix_error:#}"))?;
parse_remote_port_probe(&output, "Windows remote port probe")
.with_context(|| format!("Unix probe failed first: {unix_error:#}"))
}
}
}
fn parse_remote_port_probe(output: &str, operation: &str) -> Result<Vec<RemotePort>> {
let ports = remote_ports_from_probe(output);
if !ports.is_empty() {
return Ok(ports);
}
if output.trim().is_empty() {
return Ok(Vec::new());
}
let detail = output
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("empty output");
Err(anyhow!(
"{operation} returned no parseable port rows: {detail}"
))
}
async fn terminate_remote_process_with_handle(
handle: Arc<tokio::sync::Mutex<russh::client::Handle<ClientHandler>>>,
pid: u32,
) -> Result<()> {
if pid <= 1 {
return Err(anyhow!("refusing to terminate protected PID {pid}"));
}
if let Err(unix_error) = execute_remote_command_with_handle(
handle.clone(),
&format!("kill -TERM {pid}"),
"terminate Unix remote process",
)
.await
{
let command = powershell_encoded_command(&format!(
"$ErrorActionPreference = 'Stop'; Stop-Process -Id {pid} -ErrorAction Stop"
));
execute_remote_command_with_handle(handle, &command, "terminate Windows remote process")
.await
.with_context(|| format!("Unix termination failed first: {unix_error:#}"))?;
}
Ok(())
}
fn powershell_encoded_command(script: &str) -> String {
let utf16_le = script
.encode_utf16()
.flat_map(u16::to_le_bytes)
.collect::<Vec<_>>();
let encoded = base64::engine::general_purpose::STANDARD.encode(utf16_le);
format!("powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand {encoded}")
}
async fn run_ssh(
@@ -84,7 +277,7 @@ async fn run_ssh(
cols: u16,
rows: u16,
mut commands: mpsc::UnboundedReceiver<BackendCommand>,
events: std::sync::mpsc::Sender<BackendEvent>,
events: GuardedBackendEventSender,
) -> Result<()> {
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.clone(),
@@ -156,6 +349,82 @@ async fn run_ssh(
}
});
}
Some(BackendCommand::SampleProcesses) => {
let handle_clone = handle.clone();
let tab_id_clone = tab_id.clone();
let events_clone = events.clone();
tokio::spawn(async move {
match sample_remote_processes_with_handle(handle_clone).await {
Ok(processes) => {
let _ = events_clone.send(BackendEvent::RemoteProcesses {
tab_id: tab_id_clone,
processes,
});
}
Err(err) => {
let _ = events_clone.send(
BackendEvent::RemoteProcessesUnavailable {
tab_id: tab_id_clone,
reason: format!(
"remote process list unavailable: {err:#}"
),
},
);
}
}
});
}
Some(BackendCommand::SamplePorts) => {
let handle_clone = handle.clone();
let tab_id_clone = tab_id.clone();
let events_clone = events.clone();
tokio::spawn(async move {
match sample_remote_ports_with_handle(handle_clone).await {
Ok(ports) => {
let _ = events_clone.send(BackendEvent::RemotePorts {
tab_id: tab_id_clone,
ports,
});
}
Err(err) => {
let _ = events_clone.send(
BackendEvent::RemotePortsUnavailable {
tab_id: tab_id_clone,
reason: format!(
"remote port list unavailable: {err:#}"
),
},
);
}
}
});
}
Some(BackendCommand::TerminateProcess { pid }) => {
let handle_clone = handle.clone();
let tab_id_clone = tab_id.clone();
let events_clone = events.clone();
tokio::spawn(async move {
match terminate_remote_process_with_handle(handle_clone, pid).await {
Ok(()) => {
let _ = events_clone.send(
BackendEvent::RemoteProcessTerminated {
tab_id: tab_id_clone,
pid,
},
);
}
Err(err) => {
let _ = events_clone.send(
BackendEvent::RemoteProcessTerminateFailed {
tab_id: tab_id_clone,
pid,
reason: format!("{err:#}"),
},
);
}
}
});
}
Some(BackendCommand::Close) | None => {
tracing::info!("[ssh] local client closed the session for tab {}", tab_id);
let _ = channel.eof().await;
@@ -216,25 +485,33 @@ async fn run_ssh(
async fn connect_and_authenticate(
tab_id: &str,
session: &Session,
events: &std::sync::mpsc::Sender<BackendEvent>,
events: &GuardedBackendEventSender,
) -> Result<russh::client::Handle<ClientHandler>> {
let config = Arc::new(client::Config {
inactivity_timeout: Some(std::time::Duration::from_secs(600)),
keepalive_interval: Some(std::time::Duration::from_secs(10)),
keepalive_max: 3,
..Default::default()
});
let config = Arc::new(crate::session::config::ssh_client_config());
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"))?;
@@ -260,7 +537,12 @@ async fn connect_and_authenticate(
.context("password authentication failed")?
}
AuthMethod::Key => {
let source = key_source_label(session);
let has_explicit_key = session_has_explicit_key(session);
let source = if has_explicit_key {
key_source_label(session)
} else {
"~/.ssh/ default keys".to_string()
};
tracing::info!(
"[ssh] sending key authentication for {}@{} (key source: {})",
session.user,
@@ -269,43 +551,141 @@ async fn connect_and_authenticate(
);
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!("connected to {addr}, loading private key from {source}"),
text: if has_explicit_key {
format!("connected to {addr}, loading private key from {source}")
} else {
format!(
"connected to {addr}, trying default keys from ~/.ssh/ for {}",
session.user
)
},
});
let keypair = load_session_private_key(session)?;
let algorithm = format!("{:?}", keypair.algorithm());
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!("private key loaded from {source}, algorithm {algorithm}, sending public key authentication for {}", session.user),
});
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;
let passphrase = session.passphrase.trim();
let passphrase = (!passphrase.is_empty()).then_some(passphrase);
if has_explicit_key {
let keypair = load_session_private_key(session)?;
let algorithm = format!("{:?}", keypair.algorithm());
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!("private key loaded from {source}, algorithm {algorithm}, sending public key authentication for {}", session.user),
});
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
} else {
let success =
authenticate_with_default_keys(&mut handle, &session.user, passphrase).await?;
if !success {
return Err(anyhow::anyhow!(
"public key authentication failed for {}@{}:{} - no valid default key found in ~/.ssh/",
session.user,
session.host,
session.port
));
}
success
}
if !success {
return Err(anyhow::anyhow!(
"public key authentication failed for {}@{}:{} using {} ({})",
session.user,
session.host,
session.port,
source,
algorithm
));
}
AuthMethod::Config => {
// SSH Config auth: try the identity file from config, or default keys
let source = key_source_label(session);
tracing::info!(
"[ssh] sending ssh-config 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}"),
});
// If an explicit key path is set from the SSH config IdentityFile, use it;
// otherwise try default keys from ~/.ssh/
// Note: for Config auth, we never use inline key content
let has_explicit_key = !session.private_key_path.trim().is_empty();
if has_explicit_key {
let keypair = load_session_private_key(session)?;
let algorithm = format!("{:?}", keypair.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) => {
continue;
}
Err(_) => {
continue;
}
}
}
if !success {
return Err(anyhow::anyhow!(
"ssh-config key authentication failed for {}@{}:{} using {} ({})",
session.user,
session.host,
session.port,
source,
algorithm
));
}
success
} else {
let passphrase = session.passphrase.trim();
let passphrase = (!passphrase.is_empty()).then_some(passphrase);
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!(
"connected to {addr}, trying default keys from ~/.ssh/ for {}",
session.user
),
});
let success =
authenticate_with_default_keys(&mut handle, &session.user, passphrase).await?;
if !success {
return Err(anyhow::anyhow!(
"ssh-config authentication failed for {}@{}:{} - no valid default key found",
session.user,
session.host,
session.port
));
}
success
}
success
}
};
@@ -328,6 +708,10 @@ async fn connect_and_authenticate(
session.port,
key_source_label(session)
),
AuthMethod::Config => format!(
"authentication failed: server rejected ssh-config authentication for {}@{}:{}",
session.user, session.host, session.port
),
}
));
}
@@ -380,47 +764,6 @@ fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
Err(anyhow!(errors.join("; ")))
}
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 {
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 {
let mut normalized = value
.trim()
.replace("\\r\\n", "\n")
.replace("\\n", "\n")
.replace("\r\n", "\n");
if !normalized.ends_with('\n') {
normalized.push('\n');
}
normalized
}
fn expand_key_path(value: &str) -> Option<PathBuf> {
if value.is_empty() {
return None;
@@ -447,10 +790,12 @@ fn key_source_label(session: &Session) -> String {
const REMOTE_SYSTEM_PROBE: &str = r#"sh -lc '
os=$(uname -s 2>/dev/null || echo unknown)
LC_ALL=C
export LC_ALL
if [ "$os" = "Linux" ] && [ -r /proc/stat ]; then
cpu_stat() { awk '"'"'/^cpu / { print ($2+$3+$4+$5+$6+$7+$8), $5 }'"'"' /proc/stat 2>/dev/null; }
net_stat() { awk -F"[: ]+" '"'"'/:/ && $1!="Inter" && $1!="face" { rx += $3; tx += $11 } END { print rx+0, tx+0 }'"'"' /proc/net/dev 2>/dev/null; }
cpu_stat() { awk '"'"'/^cpu / { total = ($2+$3+$4+$5+$6+$7+$8); printf "%.0f %.0f\n", total, $5 }'"'"' /proc/stat 2>/dev/null; }
net_stat() { awk -F"[: ]+" '"'"'/:/ && $1!="Inter" && $1!="face" { rx += $3; tx += $11 } END { printf "%.0f %.0f\n", rx+0, tx+0 }'"'"' /proc/net/dev 2>/dev/null; }
read cpu_total_1 cpu_idle_1 <<EOF
$(cpu_stat)
@@ -466,22 +811,62 @@ EOF
$(net_stat)
EOF
cpu_total_1=${cpu_total_1:-0}
cpu_idle_1=${cpu_idle_1:-0}
cpu_total_2=${cpu_total_2:-0}
cpu_idle_2=${cpu_idle_2:-0}
net_rx_1=${net_rx_1:-0}
net_tx_1=${net_tx_1:-0}
net_rx_2=${net_rx_2:-0}
net_tx_2=${net_tx_2:-0}
cpu_delta=$((cpu_total_2 - cpu_total_1))
idle_delta=$((cpu_idle_2 - cpu_idle_1))
cpu_percent=$(awk -v total="$cpu_delta" -v idle="$idle_delta" '"'"'BEGIN { if (total <= 0) print "0.00"; else printf "%.2f", ((total-idle)/total)*100 }'"'"')
mem_total=$(awk '"'"'/^MemTotal:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
mem_available=$(awk '"'"'/^MemAvailable:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
swap_total=$(awk '"'"'/^SwapTotal:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
swap_free=$(awk '"'"'/^SwapFree:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
# metrics normalization
cpu_percent=$(awk -v total="$cpu_delta" -v idle="$idle_delta" '"'"'BEGIN {
if (total <= 0) {
print "0.00"
} else {
usage = ((total-idle)/total)*100
if (usage < 0) usage = 0
if (usage > 100) usage = 100
printf "%.2f", usage
}
}'"'"')
mem_available=$(awk '"'"'
/^MemTotal:/ { total = $2 * 1024 }
/^MemAvailable:/ { available = $2 * 1024; has_available = 1 }
/^MemFree:/ { free = $2 * 1024 }
/^Buffers:/ { buffers = $2 * 1024 }
/^Cached:/ { cached = $2 * 1024 }
/^SReclaimable:/ { reclaimable = $2 * 1024 }
/^Shmem:/ { shmem = $2 * 1024 }
END {
if (!has_available) available = free + buffers + cached + reclaimable - shmem
if (available < 0) available = 0
if (total > 0 && available > total) available = total
printf "%.0f\n", available
}
'"'"' /proc/meminfo 2>/dev/null)
mem_total=$(awk '"'"'/^MemTotal:/ {printf "%.0f\n", $2 * 1024; exit}'"'"' /proc/meminfo 2>/dev/null)
swap_total=$(awk '"'"'/^SwapTotal:/ {printf "%.0f\n", $2 * 1024; exit}'"'"' /proc/meminfo 2>/dev/null)
swap_free=$(awk '"'"'/^SwapFree:/ {printf "%.0f\n", $2 * 1024; exit}'"'"' /proc/meminfo 2>/dev/null)
mem_used=$(( ${mem_total:-0} - ${mem_available:-0} ))
[ "$mem_used" -lt 0 ] && mem_used=0
swap_used=$(( ${swap_total:-0} - ${swap_free:-0} ))
[ "$swap_used" -lt 0 ] && swap_used=0
net_rx=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))
[ "$net_rx" -lt 0 ] && net_rx=0
net_tx=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))
[ "$net_tx" -lt 0 ] && net_tx=0
echo "CPU_PERCENT=${cpu_percent:-0.00}"
echo "MEM_TOTAL=${mem_total:-0}"
echo "MEM_USED=$(( ${mem_total:-0} - ${mem_available:-0} ))"
echo "MEM_USED=$mem_used"
echo "SWAP_TOTAL=${swap_total:-0}"
echo "SWAP_USED=$(( ${swap_total:-0} - ${swap_free:-0} ))"
echo "NET_RX=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))"
echo "NET_TX=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))"
df -kP 2>/dev/null | awk "NR > 1 && \$1 !~ /^(tmpfs|devtmpfs|ramfs|overlay|aufs)\$/ { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 6
echo "SWAP_USED=$swap_used"
echo "NET_RX=$net_rx"
echo "NET_TX=$net_tx"
LC_ALL=C df -kP 2>/dev/null | awk "NR > 1 && \$1 !~ /^(tmpfs|devtmpfs|ramfs|overlay|aufs)\$/ { printf \"DISK=%s\t%.0f\t%.0f\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 6
exit 0
fi
@@ -524,15 +909,242 @@ EOF
exit 0
fi
echo "CPU_PERCENT=0.00"
echo "MEM_TOTAL=0"
echo "MEM_USED=0"
echo "SWAP_TOTAL=0"
echo "SWAP_USED=0"
echo "NET_RX=0"
echo "NET_TX=0"
echo "unsupported Unix remote operating system: $os" >&2
exit 2
'"#;
const REMOTE_PROCESS_PROBE: &str = r#"sh -lc '
os=$(uname -s 2>/dev/null || echo unknown)
LC_ALL=C
export LC_ALL
if [ "$os" = "Linux" ] && [ -r /proc/stat ]; then
before=$(mktemp "${TMPDIR:-/tmp}/ashell-process-before.XXXXXX") || exit 1
after=$(mktemp "${TMPDIR:-/tmp}/ashell-process-after.XXXXXX") || { rm -f "$before"; exit 1; }
trap '"'"'rm -f "$before" "$after"'"'"' EXIT HUP INT TERM
hz=$(getconf CLK_TCK 2>/dev/null || echo 100)
page_size=$(getconf PAGESIZE 2>/dev/null || echo 4096)
capture_processes() {
for process_stat in /proc/[0-9]*/stat; do
[ -r "$process_stat" ] || continue
process_pid=${process_stat#/proc/}
process_pid=${process_pid%/stat}
process_line=$(cat "$process_stat" 2>/dev/null) || continue
process_rest=${process_line##*) }
set -- $process_rest
[ "$#" -ge 22 ] || continue
process_ticks=$((${12} + ${13}))
process_start=${20}
process_rss_pages=${22}
[ "$process_rss_pages" -ge 0 ] 2>/dev/null || process_rss_pages=0
process_memory=$((process_rss_pages * page_size))
process_user=$(stat -c %U "/proc/$process_pid" 2>/dev/null || echo "-")
process_command=$(tr '"'"'\000\t\r\n'"'"' '"'"' '"'"' < "/proc/$process_pid/cmdline" 2>/dev/null)
if [ -z "$process_command" ]; then
process_command=$(tr '"'"'\t\r\n'"'"' '"'"' '"'"' < "/proc/$process_pid/comm" 2>/dev/null)
fi
[ -n "$process_command" ] || process_command="[$process_pid]"
printf '"'"'%s\t%s\t%s\t%s\t%s\t%s\n'"'"' "$process_pid" "$process_user" "$process_ticks" "$process_start" "$process_memory" "$process_command"
done
}
capture_processes > "$before"
sleep 1
capture_processes > "$after"
awk -F '"'"'\t'"'"' -v hz="$hz" '"'"'
NR == FNR { ticks[$1] = $3; starts[$1] = $4; next }
{
delta = ($1 in ticks && starts[$1] == $4) ? ($3 - ticks[$1]) : 0;
cpu = (delta > 0 && hz > 0) ? delta * 100 / hz : 0;
printf "PROCESS\t%s\t%s\t%.2f\t%s\t%s\n", $1, $2, cpu, $5, $6;
}
'"'"' "$before" "$after"
exit 0
fi
if [ "$os" = "Darwin" ]; then
top_output=$(mktemp "${TMPDIR:-/tmp}/ashell-process-top.XXXXXX") || exit 1
trap '"'"'rm -f "$top_output"'"'"' EXIT HUP INT TERM
LC_ALL=C top -l 2 -s 1 -n 10000 -stats pid,cpu > "$top_output" 2>/dev/null || exit 1
LC_ALL=C ps -axo pid=,user=,rss=,command= 2>/dev/null | awk -v top_output="$top_output" '"'"'
BEGIN {
sample = 0;
while ((getline line < top_output) > 0) {
count = split(line, fields, /[[:space:]]+/);
start = fields[1] == "" ? 2 : 1;
if (fields[start] == "PID") { sample++; continue; }
if (sample == 2 && fields[start] ~ /^[0-9]+$/) {
value = fields[start + 1];
gsub(/%/, "", value);
cpu[fields[start]] = value + 0;
}
}
close(top_output);
}
{
pid = $1; user = $2; memory = $3 * 1024;
$1 = $2 = $3 = "";
sub(/^[[:space:]]+/, "");
printf "PROCESS\t%s\t%s\t%.2f\t%.0f\t%s\n", pid, user, cpu[pid] + 0, memory, $0;
}
'"'"'
exit 0
fi
echo "unsupported Unix remote operating system: $os" >&2
exit 2
'"#;
const REMOTE_PORT_PROBE: &str = r#"sh -lc '
LC_ALL=C
export LC_ALL
if command -v lsof >/dev/null 2>&1; then
lsof_output=$(
lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk '"'"'
NR > 1 && $2 ~ /^[0-9]+$/ {
endpoint = $9;
state = $10;
gsub(/[()]/, "", state);
if (endpoint !~ /:[0-9]+$/) next;
port = endpoint;
sub(/^.*:/, "", port);
address = endpoint;
sub(/:[^:]*$/, "", address);
if (address == "") address = "*";
if (state == "") state = "LISTEN";
printf "PORT\tTCP\t%s\t%s\t%s\t%s\t%s\n", address, port, state, $2, $1;
}
'"'"'
lsof -nP -iUDP 2>/dev/null | awk '"'"'
NR > 1 && $2 ~ /^[0-9]+$/ {
endpoint = $9;
if (endpoint !~ /:[0-9]+$/) next;
port = endpoint;
sub(/^.*:/, "", port);
address = endpoint;
sub(/:[^:]*$/, "", address);
if (address == "") address = "*";
printf "PORT\tUDP\t%s\t%s\tUNCONN\t%s\t%s\n", address, port, $2, $1;
}
'"'"'
)
if [ -n "$lsof_output" ]; then
printf "%s\n" "$lsof_output"
exit 0
fi
fi
if command -v ss >/dev/null 2>&1; then
ss_output=$(ss -lntup 2>/dev/null | awk '"'"'
NF >= 5 {
protocol = $1;
state = $2;
endpoint = $5;
if (endpoint !~ /:[0-9]+$/) next;
port = endpoint;
sub(/^.*:/, "", port);
address = endpoint;
sub(/:[^:]*$/, "", address);
pid = "-";
process = "-";
for (i = 6; i <= NF; i++) {
token = $i;
if (token ~ /users:/) {
name = token;
sub(/^.*\(\("/, "", name);
sub(/".*$/, "", name);
if (name != "") process = name;
if (match(token, /pid=[0-9]+/)) {
pid = substr(token, RSTART + 4, RLENGTH - 4);
}
}
}
printf "PORT\t%s\t%s\t%s\t%s\t%s\t%s\n", protocol, address, port, state, pid, process;
}
'"'"'
)
if [ -n "$ss_output" ]; then
printf "%s\n" "$ss_output"
exit 0
fi
fi
if command -v netstat >/dev/null 2>&1; then
netstat -an 2>/dev/null | awk '"'"'
NR > 1 && NF >= 4 {
protocol = $1;
endpoint = $4;
state = protocol ~ /^udp/i ? "UNCONN" : "LISTEN";
if (endpoint !~ /:[0-9]+$/ && endpoint !~ /\.[0-9]+$/) next;
port = endpoint;
sub(/^.*[:.]/, "", port);
address = endpoint;
sub(/[:.][^:.]*$/, "", address);
if (address == "") address = "*";
if (protocol !~ /^udp/i && $NF != "LISTEN" && $NF != "LISTENING") next;
printf "PORT\t%s\t%s\t%s\t%s\t-\t-\n", protocol, address, port, state;
}
'"'"'
exit 0
fi
echo "no supported remote port utility found" >&2
exit 2
'"#;
const REMOTE_WINDOWS_SYSTEM_PROBE: &str = r#"$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$cpu = (Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average
$os = Get-CimInstance Win32_OperatingSystem
$memTotal = [uint64]$os.TotalVisibleMemorySize * 1024
$memFree = [uint64]$os.FreePhysicalMemory * 1024
$swapTotal = [uint64]$os.TotalVirtualMemorySize * 1024 - $memTotal
$swapFree = [uint64]$os.FreeVirtualMemory * 1024 - $memFree
$network = Get-CimInstance Win32_PerfFormattedData_Tcpip_NetworkInterface
$netRx = ($network | Measure-Object -Property BytesReceivedPersec -Sum).Sum
$netTx = ($network | Measure-Object -Property BytesSentPersec -Sum).Sum
$cpuText = [string]::Format([Globalization.CultureInfo]::InvariantCulture, "{0:F2}", [double]$cpu)
Write-Output ("CPU_PERCENT={0}" -f $cpuText)
Write-Output ("MEM_TOTAL={0}" -f $memTotal)
Write-Output ("MEM_USED={0}" -f ($memTotal - $memFree))
Write-Output ("SWAP_TOTAL={0}" -f ([Math]::Max(0, $swapTotal)))
Write-Output ("SWAP_USED={0}" -f ([Math]::Max(0, $swapTotal - $swapFree)))
Write-Output ("NET_RX={0}" -f ([uint64]$netRx))
Write-Output ("NET_TX={0}" -f ([uint64]$netTx))
Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | ForEach-Object {
Write-Output ("DISK={0}`t{1}`t{2}" -f $_.DeviceID, [uint64]$_.FreeSpace, [uint64]$_.Size)
}"#;
const REMOTE_WINDOWS_PROCESS_PROBE: &str = r#"$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$before = @{}
Get-Process | ForEach-Object { if ($null -ne $_.CPU) { $before[$_.Id] = [double]$_.CPU } }
Start-Sleep -Seconds 1
Get-Process | ForEach-Object {
$previous = $before[$_.Id]
$cpu = if ($null -ne $previous -and $null -ne $_.CPU) { [Math]::Max(0, ([double]$_.CPU - $previous) * 100) } else { 0 }
$command = $_.ProcessName.Replace("`t", " ").Replace("`r", " ").Replace("`n", " ")
$cpuText = [string]::Format([Globalization.CultureInfo]::InvariantCulture, "{0:F2}", $cpu)
Write-Output ("PROCESS`t{0}`t-`t{1}`t{2}`t{3}" -f $_.Id, $cpuText, [uint64]$_.WorkingSet64, $command)
}"#;
const REMOTE_WINDOWS_PORT_PROBE: &str = r#"$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$tab = [char]9
$processNames = @{}
Get-Process | ForEach-Object { $processNames[[int]$_.Id] = $_.ProcessName }
@(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue) | ForEach-Object {
$ownerPid = [int]$_.OwningProcess
$processName = if ($processNames.ContainsKey($ownerPid)) { $processNames[$ownerPid] } else { '-' }
Write-Output ("PORT{0}TCP{0}{1}{0}{2}{0}LISTEN{0}{3}{0}{4}" -f $tab, $_.LocalAddress, $_.LocalPort, $ownerPid, $processName)
}
@(Get-NetUDPEndpoint -ErrorAction SilentlyContinue) | ForEach-Object {
$ownerPid = [int]$_.OwningProcess
$processName = if ($processNames.ContainsKey($ownerPid)) { $processNames[$ownerPid] } else { '-' }
Write-Output ("PORT{0}UDP{0}{1}{0}{2}{0}UNCONN{0}{3}{0}{4}" -f $tab, $_.LocalAddress, $_.LocalPort, $ownerPid, $processName)
}"#;
#[derive(Clone)]
struct ClientHandler;
+291
View File
@@ -0,0 +1,291 @@
#[cfg(any(target_os = "macos", target_os = "windows"))]
use std::{collections::VecDeque, sync::Mutex};
#[cfg(target_os = "macos")]
const APP_BUNDLE_ID: &str = "dev.ashell.app";
#[cfg(target_os = "windows")]
const APP_USER_MODEL_ID: &str = "dev.ashell.app";
#[cfg(any(target_os = "macos", target_os = "windows"))]
const MAX_PENDING_TERMINAL_NOTIFICATION_ACTIVATIONS: usize = 64;
#[cfg(any(target_os = "macos", target_os = "windows"))]
static TERMINAL_NOTIFICATION_ACTIVATIONS: Mutex<VecDeque<String>> = Mutex::new(VecDeque::new());
pub(crate) fn initialize() {
#[cfg(target_os = "macos")]
match desktop_notify::set_application(APP_BUNDLE_ID) {
Ok(()) => {
tracing::info!("registered the macOS notification application identity {APP_BUNDLE_ID}")
}
Err(error) => {
tracing::warn!(
"failed to register the macOS notification application identity {APP_BUNDLE_ID}: {error}"
);
}
}
#[cfg(target_os = "windows")]
if let Err(error) = unsafe {
windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID(windows::core::w!(
"dev.ashell.app"
))
} {
tracing::warn!("failed to set Windows application identity: {error}");
}
}
pub(crate) fn show_terminal_notification(tab_id: String, title: String, body: String) {
#[cfg(any(target_os = "macos", target_os = "windows"))]
if let Err(error) = std::thread::Builder::new()
.name("desktop-notification".to_string())
.spawn(move || show_native_notification(&title, &body, &tab_id))
{
tracing::warn!("failed to start desktop notification thread: {error}");
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
let _ = (tab_id, title, body);
}
}
#[cfg(target_os = "macos")]
fn show_native_notification(title: &str, body: &str, tab_id: &str) {
let result = mac_notification_sys::Notification::new()
.title(title)
.message(body)
.wait_for_click(true)
.send();
match result {
Ok(mac_notification_sys::NotificationResponse::Click) => {
queue_terminal_notification_activation(tab_id.to_string());
}
Ok(_) => tracing::info!("terminal notification closed without activation on macOS"),
Err(error) => tracing::warn!("failed to show macOS terminal notification: {error}"),
}
}
#[cfg(target_os = "windows")]
fn show_native_notification(title: &str, body: &str, tab_id: &str) {
match show_windows_notification(APP_USER_MODEL_ID, title, body, tab_id) {
Ok(()) => tracing::info!("submitted terminal notification to Windows"),
Err(primary_error) => {
if let Err(fallback_error) = show_windows_notification(
winrt_notification::Toast::POWERSHELL_APP_ID,
title,
body,
tab_id,
) {
tracing::warn!(
"failed to show Windows terminal notification: {primary_error}; fallback failed: {fallback_error}"
);
}
}
}
}
#[cfg(target_os = "windows")]
fn show_windows_notification(
app_id: &str,
title: &str,
body: &str,
tab_id: &str,
) -> winrt_notification::Result<()> {
let activation_tab_id = tab_id.to_string();
winrt_notification::Toast::new(app_id)
.title(title)
.text2(body)
.duration(winrt_notification::Duration::Short)
.sound(None)
.on_activated(move || {
queue_terminal_notification_activation(activation_tab_id.clone());
Ok(())
})
.show()
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn queue_terminal_notification_activation(tab_id: String) {
let Ok(mut activations) = TERMINAL_NOTIFICATION_ACTIVATIONS.lock() else {
tracing::warn!("failed to lock terminal notification activation queue");
return;
};
if activations.len() >= MAX_PENDING_TERMINAL_NOTIFICATION_ACTIVATIONS {
activations.pop_front();
}
activations.push_back(tab_id);
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
pub(crate) fn take_terminal_notification_activation() -> Option<String> {
let Ok(mut activations) = TERMINAL_NOTIFICATION_ACTIVATIONS.lock() else {
tracing::warn!("failed to lock terminal notification activation queue");
return None;
};
activations.pop_front()
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub(crate) fn take_terminal_notification_activation() -> Option<String> {
None
}
pub(crate) fn native_window_handle(window: &gpui::Window) -> Option<isize> {
#[cfg(target_os = "windows")]
{
use raw_window_handle::RawWindowHandle;
let handle = raw_window_handle::HasWindowHandle::window_handle(window).ok()?;
return match handle.as_raw() {
RawWindowHandle::Win32(handle) => Some(handle.hwnd.get()),
_ => None,
};
}
#[cfg(not(target_os = "windows"))]
{
let _ = window;
None
}
}
pub(crate) fn set_unread_indicator(unread: bool, window_handle: Option<isize>) {
#[cfg(target_os = "macos")]
{
use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication;
use objc2_foundation::NSString;
let _ = window_handle;
let Some(main_thread) = MainThreadMarker::new() else {
tracing::warn!("attempted to update the Dock badge outside the main thread");
return;
};
let application = NSApplication::sharedApplication(main_thread);
let dock_tile = application.dockTile();
let label = unread.then(|| NSString::from_str("\u{2022}"));
dock_tile.setBadgeLabel(label.as_deref());
}
#[cfg(target_os = "windows")]
if let Some(window_handle) = window_handle {
if let Err(error) = set_windows_taskbar_badge(window_handle, unread) {
tracing::warn!("failed to update Windows taskbar badge: {error}");
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let _ = (unread, window_handle);
}
pub(crate) fn clear_unread_indicator(window_handle: Option<isize>) {
set_unread_indicator(false, window_handle);
}
#[allow(deprecated)]
/// Clears notifications delivered by the current application from Notification Center.
pub(crate) fn clear_current_app_delivered_notifications() {
#[cfg(target_os = "macos")]
{
use objc2_foundation::NSUserNotificationCenter;
// The default center is scoped to this application, so other apps' notifications remain untouched.
NSUserNotificationCenter::defaultUserNotificationCenter().removeAllDeliveredNotifications();
}
#[cfg(target_os = "windows")]
{
use windows::UI::Notifications::ToastNotificationManager;
if let Err(error) = ToastNotificationManager::History().and_then(|history| history.Clear())
{
tracing::warn!("failed to clear Windows notification history: {error}");
}
}
}
#[cfg(target_os = "windows")]
fn set_windows_taskbar_badge(window_handle: isize, unread: bool) -> windows::core::Result<()> {
use windows::{
Win32::{
Foundation::HWND,
System::Com::{CLSCTX_INPROC_SERVER, CoCreateInstance},
UI::{
Shell::{ITaskbarList3, TaskbarList},
WindowsAndMessaging::{
CreateIconFromResourceEx, DestroyIcon, HICON, LR_DEFAULTCOLOR,
},
},
},
core::{IUnknown, PCWSTR, w},
};
let taskbar: ITaskbarList3 =
unsafe { CoCreateInstance(&TaskbarList, None::<&IUnknown>, CLSCTX_INPROC_SERVER)? };
unsafe { taskbar.HrInit()? };
let hwnd = HWND(window_handle);
if !unread {
return unsafe { taskbar.SetOverlayIcon(hwnd, HICON(0), PCWSTR::null()) };
}
let icon_resource = windows_badge_icon_resource();
let icon = unsafe {
CreateIconFromResourceEx(&icon_resource, true, 0x0003_0000, 16, 16, LR_DEFAULTCOLOR)?
};
let result = unsafe { taskbar.SetOverlayIcon(hwnd, icon, w!("Unread terminal notification")) };
let destroy_result = unsafe { DestroyIcon(icon) };
result?;
destroy_result
}
#[cfg(target_os = "windows")]
fn windows_badge_icon_resource() -> Vec<u8> {
const WIDTH: usize = 16;
const HEIGHT: usize = 16;
const BITMAP_HEADER_SIZE: u32 = 40;
const XOR_BYTES: usize = WIDTH * HEIGHT * 4;
const AND_ROW_BYTES: usize = 4;
let mut resource = Vec::with_capacity(BITMAP_HEADER_SIZE as usize + XOR_BYTES + 64);
resource.extend_from_slice(&BITMAP_HEADER_SIZE.to_le_bytes());
resource.extend_from_slice(&(WIDTH as i32).to_le_bytes());
resource.extend_from_slice(&((HEIGHT * 2) as i32).to_le_bytes());
resource.extend_from_slice(&1u16.to_le_bytes());
resource.extend_from_slice(&32u16.to_le_bytes());
resource.extend_from_slice(&0u32.to_le_bytes());
resource.extend_from_slice(&(XOR_BYTES as u32).to_le_bytes());
resource.extend_from_slice(&0i32.to_le_bytes());
resource.extend_from_slice(&0i32.to_le_bytes());
resource.extend_from_slice(&0u32.to_le_bytes());
resource.extend_from_slice(&0u32.to_le_bytes());
for y in (0..HEIGHT).rev() {
for x in 0..WIDTH {
if windows_badge_pixel_is_red(x, y) {
resource.extend_from_slice(&[42, 48, 239, 255]);
} else {
resource.extend_from_slice(&[0, 0, 0, 0]);
}
}
}
for y in (0..HEIGHT).rev() {
let mut row = [0u8; AND_ROW_BYTES];
for x in 0..WIDTH {
if !windows_badge_pixel_is_red(x, y) {
row[x / 8] |= 0x80 >> (x % 8);
}
}
resource.extend_from_slice(&row);
}
resource
}
#[cfg(target_os = "windows")]
fn windows_badge_pixel_is_red(x: usize, y: usize) -> bool {
let dx = x as f32 - 7.5;
let dy = y as f32 - 7.5;
dx * dx + dy * dy <= 42.25
}
+14 -6
View File
@@ -5,28 +5,33 @@ use gpui_component_assets::Assets;
mod app;
mod backend;
mod desktop_notification;
mod session;
mod sftp;
mod sync;
mod system;
mod terminal;
mod text_encoding;
rust_i18n::i18n!("locales", fallback = "en");
gpui::actions!(ashell_terminal, [TerminalTabKey, TerminalBacktabKey]);
pub(crate) use app::keybinding_recorder::{
ClosePane, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSession,
OpenSettings, OpenTransfers, SplitPaneDown, SplitPaneLeft, SplitPaneRight, SplitPaneUp,
ToggleSftpZoom, ToggleSidebar,
ClosePane, Copy, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSearch,
OpenSession, OpenSettings, OpenTransfers, Paste, QuitApplication, SplitPaneDown, SplitPaneLeft,
SplitPaneRight, SplitPaneUp, ToggleSftpZoom, ToggleSidebar,
};
pub(crate) use app::system_menu::{
AboutAshell, CloseWindow, MinimizeWindow, NewLocalTerminal, ToggleFullScreen, ZoomWindow,
};
pub(crate) use app::{
Ashell, ConnectionProgress, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup,
};
pub(crate) use app::{Ashell, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup};
fn main() {
app::startup::sync_macos_launch_environment();
app::startup::init_logging();
desktop_notification::initialize();
#[cfg(target_os = "macos")]
let app = gpui_platform::application()
@@ -43,6 +48,9 @@ fn main() {
});
app.run(move |cx| {
gpui_component::init(cx);
app::system_menu::init(cx);
cx.on_action(|_: &QuitApplication, cx| cx.quit());
cx.bind_keys([
KeyBinding::new(
"tab",
+1758 -42
View File
File diff suppressed because it is too large Load Diff
+1924 -359
View File
File diff suppressed because it is too large Load Diff
+179
View File
@@ -0,0 +1,179 @@
use std::fs;
use std::path::PathBuf;
use anyhow::{Context, Result};
use directories::BaseDirs;
/// A parsed entry from ~/.ssh/config
#[derive(Debug, Clone)]
pub struct SshConfigEntry {
/// The Host pattern (alias) from the config, e.g. "myserver"
pub host_alias: String,
/// The actual hostname (HostName), defaults to the host alias if not specified
pub hostname: String,
/// The user, defaults to empty (will use current OS user)
pub user: String,
/// The port, defaults to 22
pub port: u16,
/// Identity files specified for this host
pub identity_files: Vec<String>,
/// Whether this is a wildcard/pattern host (Host * or Host *)
pub is_wildcard: bool,
}
/// Parse ~/.ssh/config and return a list of concrete host entries.
/// Wildcard patterns (Host *) are excluded.
pub fn parse_ssh_config() -> Result<Vec<SshConfigEntry>> {
let config_path = ssh_config_path()?;
if !config_path.is_file() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&config_path)
.with_context(|| format!("failed to read {}", config_path.display()))?;
parse_ssh_config_content(&content)
}
fn ssh_config_path() -> Result<PathBuf> {
BaseDirs::new()
.map(|dirs| dirs.home_dir().join(".ssh/config"))
.context("failed to determine home directory")
}
/// Parse the content of an ssh config file into entries.
pub fn parse_ssh_config_content(content: &str) -> Result<Vec<SshConfigEntry>> {
let mut entries: Vec<SshConfigEntry> = Vec::new();
let mut current_host: Option<SshConfigEntry> = None;
for line in content.lines() {
let line = line.trim();
// Skip empty lines and comments
if line.is_empty() || line.starts_with('#') {
continue;
}
// Split into keyword and value
// OpenSSH supports both "keyword value" and "keyword=value" formats
let (keyword, value) = if let Some(pos) = line.find('=') {
(&line[..pos], line[pos + 1..].trim())
} else if let Some(pos) = line.find(' ') {
(&line[..pos], line[pos..].trim())
} else {
continue;
};
let keyword_lower = keyword.trim().to_lowercase();
let value = value.trim();
match keyword_lower.as_str() {
"host" => {
// Save previous entry if it exists and is not a wildcard
if let Some(entry) = current_host.take() {
if !entry.is_wildcard {
entries.push(entry);
}
}
// Host line may contain multiple patterns (Host a b c)
// Take the first non-wildcard pattern as the display alias
let patterns: Vec<&str> = value.split_whitespace().collect();
if patterns.is_empty() {
continue;
}
let host_alias = patterns
.iter()
.find(|p| !p.contains('*') && !p.contains('?'))
.map(|p| p.to_string())
.unwrap_or_else(|| patterns[0].to_string());
let is_wildcard = value.contains('*') || value.contains('?');
current_host = Some(SshConfigEntry {
host_alias: host_alias.clone(),
hostname: host_alias,
user: String::new(),
port: 22,
identity_files: Vec::new(),
is_wildcard,
});
}
"hostname" => {
if let Some(entry) = current_host.as_mut() {
entry.hostname = value.to_string();
}
}
"user" => {
if let Some(entry) = current_host.as_mut() {
entry.user = value.to_string();
}
}
"port" => {
if let Some(entry) = current_host.as_mut() {
entry.port = value.parse::<u16>().unwrap_or(22);
}
}
"identityfile" => {
if let Some(entry) = current_host.as_mut() {
entry.identity_files.push(value.to_string());
}
}
// Skip Match blocks and Include directives — not supported yet
"match" | "include" => {
// If we encounter a Match block, flush the current Host entry
// since Match blocks apply to all subsequent hosts until the next Match/Host
if let Some(entry) = current_host.take() {
if !entry.is_wildcard {
entries.push(entry);
}
}
// Don't create a new entry for Match/Include
}
_ => {}
}
}
// Save the last entry if it's not a wildcard
if let Some(entry) = current_host.take() {
if !entry.is_wildcard {
entries.push(entry);
}
}
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_ssh_config_content() {
let content = r#"
Host myhost
HostName 1.2.3.4
User git
Port 2222
IdentityFile ~/.ssh/id_rsa
Host
Host =
Host anotherhost
HostName 5.6.7.8
"#;
let entries = parse_ssh_config_content(content).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].host_alias, "myhost");
assert_eq!(entries[0].hostname, "1.2.3.4");
assert_eq!(entries[0].user, "git");
assert_eq!(entries[0].port, 2222);
assert_eq!(entries[0].identity_files, vec!["~/.ssh/id_rsa".to_string()]);
assert_eq!(entries[1].host_alias, "anotherhost");
assert_eq!(entries[1].hostname, "5.6.7.8");
}
}
+96
View File
@@ -0,0 +1,96 @@
use std::sync::Arc;
use anyhow::{Result, anyhow};
use directories::BaseDirs;
use russh::{
client::{self, Handler},
keys::{HashAlg, PrivateKey, key::PrivateKeyWithHashAlg, load_secret_key},
};
use crate::session::config::Session;
pub const DEFAULT_KEY_NAMES: &[&str] = &["id_ed25519", "id_rsa", "id_ecdsa", "id_dsa"];
pub fn session_has_explicit_key(session: &Session) -> bool {
!session.private_key_path.trim().is_empty()
|| !normalize_inline_private_key(&session.private_key_inline).is_empty()
}
pub fn normalize_inline_private_key(value: &str) -> String {
let mut normalized = value
.trim()
.replace("\\r\\n", "\n")
.replace("\\n", "\n")
.replace("\r\n", "\n");
if normalized.is_empty() {
return String::new();
}
if !normalized.ends_with('\n') {
normalized.push('\n');
}
normalized
}
pub 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 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)
}
pub async fn authenticate_with_default_keys<H>(
handle: &mut client::Handle<H>,
user: &str,
passphrase: Option<&str>,
) -> Result<bool>
where
H: Handler + Send + Sync,
H::Error: Into<anyhow::Error>,
{
let Some(ssh_dir) = BaseDirs::new().map(|d| d.home_dir().join(".ssh")) else {
return Ok(false);
};
for key_name in DEFAULT_KEY_NAMES {
let key_path = ssh_dir.join(key_name);
if !key_path.exists() {
continue;
}
tracing::debug!("[ssh] trying default key {}", key_path.display());
match load_secret_key(&key_path, passphrase) {
Ok(keypair) => {
if let Ok(keys) = private_keys_with_algs(keypair) {
for key in keys {
match handle.authenticate_publickey(user, key).await {
Ok(true) => return Ok(true),
Ok(false) | Err(_) => continue,
}
}
}
}
Err(_) => continue,
}
}
Ok(false)
}
+541 -388
View File
File diff suppressed because it is too large Load Diff
+164 -34
View File
@@ -6,26 +6,6 @@ use crate::{
terminal,
};
pub(crate) fn is_editable_text_file(filename: &str) -> bool {
let lower = filename.to_lowercase();
let ext = std::path::Path::new(&lower)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("");
let known_exts = [
"txt", "conf", "json", "yaml", "yml", "xml", "ini", "sh", "py", "rs", "js", "ts", "html",
"css", "md", "toml", "csv", "log", "cfg",
];
if known_exts.contains(&ext) {
return true;
}
let known_names = ["dockerfile", "makefile", ".gitignore", ".env"];
if known_names.contains(&lower.as_str()) {
return true;
}
false
}
impl Ashell {
pub(crate) fn active_sftp(&self) -> Option<&terminal::SftpUiState> {
self.active_group
@@ -49,24 +29,154 @@ impl Ashell {
}
pub(crate) fn navigate_sftp(&mut self, path: String, cx: &mut Context<Self>) {
if let Some(handle) = self.active_sftp_handle() {
tracing::info!("[sftp] navigating to directory: '{}'", path);
handle.list_dir(path.clone());
if let Some(sftp) = self.active_sftp_mut() {
sftp.current_path = path;
self.pending_sftp_path_sync = Some(sftp.current_path.clone());
let Some((current_path, home_dir)) = self
.active_sftp()
.map(|sftp| (sftp.current_path.clone(), sftp.home_dir.clone()))
else {
return;
};
let path = crate::sftp::normalize_remote_path(&path, &current_path, &home_dir);
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
tracing::info!("[sftp] navigating to directory: '{}'", path);
let ancestors = crate::sftp::remote_path_ancestors(&path);
let mut paths_to_load = Vec::new();
if let Some(sftp) = self.active_sftp_mut() {
sftp.current_path = path.clone();
sftp.selected_path = None;
sftp.selection_anchor = None;
sftp.preview = None;
sftp.selected_entries.clear();
sftp.expand_to(&path);
sftp.begin_directory_load(&path);
for ancestor in ancestors {
if ancestor != path
&& !sftp.directory_cache.contains_key(&ancestor)
&& !sftp.loading_directories.contains(&ancestor)
{
sftp.begin_directory_load(&ancestor);
paths_to_load.push(ancestor);
}
}
}
self.pending_sftp_path_sync = Some(path.clone());
for ancestor in paths_to_load {
handle.list_dir(ancestor);
}
handle.list_dir(path);
cx.notify();
}
pub(crate) fn toggle_sftp_tree_directory(&mut self, path: String, cx: &mut Context<Self>) {
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
let mut should_load = false;
if let Some(sftp) = self.active_sftp_mut() {
let has_error = sftp.directory_errors.contains_key(&path);
let has_cache = sftp.directory_cache.contains_key(&path);
if sftp.expanded_directories.contains(&path) && !has_error && has_cache {
sftp.expanded_directories.remove(&path);
cx.notify();
return;
}
sftp.expanded_directories.insert(path.clone());
should_load = (has_error || !has_cache) && !sftp.loading_directories.contains(&path);
if should_load {
sftp.begin_directory_load(&path);
}
}
if should_load {
handle.list_dir(path);
}
cx.notify();
}
pub(crate) fn reveal_current_sftp_directory(&mut self, cx: &mut Context<Self>) {
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
let Some(current_path) = self.active_sftp().map(|sftp| sftp.current_path.clone()) else {
return;
};
let ancestors = crate::sftp::remote_path_ancestors(&current_path);
let mut paths_to_load = Vec::new();
if let Some(sftp) = self.active_sftp_mut() {
sftp.expand_to(&current_path);
for path in ancestors {
if !sftp.directory_cache.contains_key(&path)
&& !sftp.loading_directories.contains(&path)
{
sftp.begin_directory_load(&path);
paths_to_load.push(path);
}
}
}
for path in paths_to_load {
handle.list_dir(path);
}
if let Some(index) = self.active_sftp().and_then(|sftp| {
sftp.tree_rows(self.show_hidden_files)
.iter()
.position(|row| row.path == current_path)
}) {
self.remote_tree_scroll_handle
.scroll_to_item(index, gpui::ScrollStrategy::Center);
}
cx.notify();
}
pub(crate) fn collapse_sftp_tree(&mut self, cx: &mut Context<Self>) {
if let Some(sftp) = self.active_sftp_mut() {
sftp.collapse_all();
cx.notify();
}
}
pub(crate) fn select_sftp_entry(&mut self, entry: RemoteEntry, cx: &mut Context<Self>) {
pub(crate) fn select_sftp_entry(
&mut self,
entry: RemoteEntry,
visible_paths: &[String],
extend_range: bool,
cx: &mut Context<Self>,
) {
if extend_range {
if let Some(sftp) = self.active_sftp_mut() {
let target_path = entry.full_path.clone();
let target_index = visible_paths.iter().position(|path| path == &target_path);
let anchor_index = sftp
.selection_anchor
.as_ref()
.and_then(|anchor| visible_paths.iter().position(|path| path == anchor));
sftp.selected_entries.clear();
if let Some(target_index) = target_index {
let anchor_index = anchor_index.unwrap_or(target_index);
let start = anchor_index.min(target_index);
let end = anchor_index.max(target_index);
sftp.selected_entries
.extend(visible_paths[start..=end].iter().cloned());
} else {
sftp.selected_entries.insert(target_path.clone());
}
if sftp.selection_anchor.is_none() {
sftp.selection_anchor = Some(target_path.clone());
}
sftp.selected_path = Some(target_path);
}
cx.notify();
return;
}
if entry.is_dir {
self.navigate_sftp(entry.full_path, cx);
return;
}
self.mark_sftp_entry_selected(&entry.full_path, cx);
if let Some(sftp) = self.active_sftp_mut() {
sftp.selection_anchor = Some(entry.full_path.clone());
if !sftp.selected_entries.remove(&entry.full_path) {
sftp.selected_entries.insert(entry.full_path);
}
@@ -144,15 +254,27 @@ impl Ashell {
cx.notify();
}
pub(crate) fn trigger_sftp_context_edit(&mut self, cx: &mut Context<Self>) {
pub(crate) fn trigger_sftp_context_edit(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(menu) = self.sftp_context_menu.take() else {
return;
};
if let Some(handle) = self.active_sftp_handle() {
tracing::info!("[sftp] triggering edit for file: '{}'", menu.remote_path);
handle.edit_file(menu.remote_path);
}
cx.notify();
tracing::info!("[sftp] opening in-app editor for: '{}'", menu.remote_path);
self.show_sftp_editor_dialog(menu.remote_path, window, cx);
}
pub(crate) fn trigger_sftp_context_rename(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(menu) = self.sftp_context_menu.take() else {
return;
};
self.show_sftp_rename_dialog(menu.remote_path, window, cx);
}
pub(crate) fn download_sftp_entry(
@@ -295,6 +417,8 @@ impl Ashell {
cx: &mut Context<Self>,
) {
if let Some(sftp) = self.active_sftp_mut() {
sftp.selected_path = Some(path.clone());
sftp.selection_anchor = Some(path.clone());
if checked {
sftp.selected_entries.insert(path);
} else {
@@ -307,12 +431,18 @@ impl Ashell {
pub(crate) fn toggle_all_sftp_entries(&mut self, checked: bool, cx: &mut Context<Self>) {
if let Some(sftp) = self.active_sftp_mut() {
if checked {
let paths: Vec<String> = sftp.entries.iter().map(|e| e.full_path.clone()).collect();
let paths: Vec<String> = sftp
.current_entries()
.iter()
.map(|entry| entry.full_path.clone())
.collect();
for path in paths {
sftp.selected_entries.insert(path);
}
} else {
sftp.selected_entries.clear();
sftp.selected_path = None;
sftp.selection_anchor = None;
}
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");
}
}
+214 -33
View File
@@ -22,6 +22,25 @@ pub struct DiskSample {
pub total_bytes: u64,
}
#[derive(Debug, Clone, Default)]
pub struct RemoteProcess {
pub pid: u32,
pub user: String,
pub cpu_percent: f32,
pub memory_bytes: u64,
pub command: String,
}
#[derive(Debug, Clone, Default)]
pub struct RemotePort {
pub protocol: String,
pub address: String,
pub port: u16,
pub state: String,
pub pid: Option<u32>,
pub process: String,
}
#[derive(Debug, Clone, Default)]
pub struct SystemSnapshot {
pub cpu_percent: f32,
@@ -52,8 +71,8 @@ impl SystemSampler {
sys.refresh_all();
let nets = Networks::new_with_refreshed_list();
let disks = Disks::new_with_refreshed_list();
let last_rx_total = nets.iter().map(|(_, d)| d.total_received()).sum();
let last_tx_total = nets.iter().map(|(_, d)| d.total_transmitted()).sum();
let last_rx_total = nets.values().map(|d| d.total_received()).sum();
let last_tx_total = nets.values().map(|d| d.total_transmitted()).sum();
Self {
sys,
@@ -81,8 +100,8 @@ impl SystemSampler {
let swap_total = self.sys.total_swap();
let swap_used = self.sys.used_swap();
let rx_total: u64 = self.nets.iter().map(|(_, d)| d.total_received()).sum();
let tx_total: u64 = self.nets.iter().map(|(_, d)| d.total_transmitted()).sum();
let rx_total: u64 = self.nets.values().map(|d| d.total_received()).sum();
let tx_total: u64 = self.nets.values().map(|d| d.total_transmitted()).sum();
let now = Instant::now();
let elapsed = now
.duration_since(self.last_instant)
@@ -104,24 +123,16 @@ impl SystemSampler {
total_bytes: disk.total_space(),
})
.collect();
disks.sort_by(|a, b| {
if a.mount == "/" {
return std::cmp::Ordering::Less;
}
if b.mount == "/" {
return std::cmp::Ordering::Greater;
}
a.mount.cmp(&b.mount)
});
sort_disks(&mut disks);
SystemSnapshot {
cpu_percent,
mem_percent: ratio(mem_used, mem_total),
swap_percent: ratio(swap_used, swap_total),
mem_detail: format!("{}/{}", format_bytes(mem_used), format_bytes(mem_total)),
swap_detail: format!("{}/{}", format_bytes(swap_used), format_bytes(swap_total)),
net_rx: format!("{}/s", format_bytes(rx_rate)),
net_tx: format!("{}/s", format_bytes(tx_rate)),
mem_detail: format_usage(mem_used, mem_total),
swap_detail: format_usage(swap_used, swap_total),
net_rx: format_rate(rx_rate),
net_tx: format_rate(tx_rate),
net_rx_rate: rx_rate,
net_tx_rate: tx_rate,
disks,
@@ -153,6 +164,22 @@ pub fn format_bytes(bytes: u64) -> String {
}
}
fn format_usage(used: u64, total: u64) -> String {
format!("{}/{}", format_bytes(used), format_bytes(total))
}
fn format_rate(bytes: u64) -> String {
format!("{}/s", format_bytes(bytes))
}
fn sort_disks(disks: &mut [DiskSample]) {
disks.sort_by(|a, b| {
(a.mount != "/")
.cmp(&(b.mount != "/"))
.then_with(|| a.mount.cmp(&b.mount))
});
}
pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
let mut kv = BTreeMap::new();
let mut disks = Vec::new();
@@ -173,7 +200,7 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
.unwrap_or_default();
disks.push(DiskSample {
mount,
available_bytes,
available_bytes: available_bytes.min(total_bytes),
total_bytes,
});
continue;
@@ -189,13 +216,15 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
.get("CPU_PERCENT")
.ok_or_else(|| anyhow!("missing CPU_PERCENT"))?
.parse::<f32>()
.ok()
.filter(|value| value.is_finite())
.unwrap_or_default()
/ 100.0;
let mem_used = parse_u64(&kv, "MEM_USED");
let mem_total = parse_u64(&kv, "MEM_TOTAL");
let swap_used = parse_u64(&kv, "SWAP_USED");
let swap_total = parse_u64(&kv, "SWAP_TOTAL");
let mem_used = parse_u64(&kv, "MEM_USED").min(mem_total);
let swap_used = parse_u64(&kv, "SWAP_USED").min(swap_total);
let rx_rate = parse_u64(&kv, "NET_RX");
let tx_rate = parse_u64(&kv, "NET_TX");
@@ -203,24 +232,16 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
// (catches any virtual fs lines that slipped past the script filter)
disks.retain(|d| d.total_bytes >= 1024 * 1024);
disks.sort_by(|a, b| {
if a.mount == "/" {
return std::cmp::Ordering::Less;
}
if b.mount == "/" {
return std::cmp::Ordering::Greater;
}
a.mount.cmp(&b.mount)
});
sort_disks(&mut disks);
Ok(SystemSnapshot {
cpu_percent: cpu_percent.clamp(0.0, 1.0),
mem_percent: ratio(mem_used, mem_total),
swap_percent: ratio(swap_used, swap_total),
mem_detail: format!("{}/{}", format_bytes(mem_used), format_bytes(mem_total)),
swap_detail: format!("{}/{}", format_bytes(swap_used), format_bytes(swap_total)),
net_rx: format!("{}/s", format_bytes(rx_rate)),
net_tx: format!("{}/s", format_bytes(tx_rate)),
mem_detail: format_usage(mem_used, mem_total),
swap_detail: format_usage(swap_used, swap_total),
net_rx: format_rate(rx_rate),
net_tx: format_rate(tx_rate),
net_rx_rate: rx_rate,
net_tx_rate: tx_rate,
disks,
@@ -228,8 +249,168 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
})
}
pub fn remote_processes_from_ps(raw: &str) -> Vec<RemoteProcess> {
raw.lines()
.filter_map(|line| {
if let Some(record) = line.strip_prefix("PROCESS\t") {
let mut fields = record.splitn(5, '\t');
let pid = fields.next()?.parse::<u32>().ok()?;
let user = fields.next()?.to_string();
let cpu_percent = fields
.next()?
.parse::<f32>()
.ok()
.filter(|value| value.is_finite())
.map(|value| value.max(0.0))?;
let memory_bytes = fields.next()?.parse::<u64>().ok()?;
let command = fields.next()?.trim().to_string();
return (!command.is_empty()).then_some(RemoteProcess {
pid,
user,
cpu_percent,
memory_bytes,
command,
});
}
let mut fields = line.split_whitespace();
let pid = fields.next()?.parse::<u32>().ok()?;
let user = fields.next()?.to_string();
let cpu_percent = fields
.next()?
.parse::<f32>()
.ok()
.filter(|value| value.is_finite())
.map(|value| value.max(0.0))?;
let memory_bytes = fields.next()?.parse::<u64>().ok()?.saturating_mul(1024);
let command = fields.collect::<Vec<_>>().join(" ");
if command.is_empty() {
return None;
}
Some(RemoteProcess {
pid,
user,
cpu_percent,
memory_bytes,
command,
})
})
.collect()
}
pub fn remote_ports_from_probe(raw: &str) -> Vec<RemotePort> {
raw.lines()
.filter_map(|line| {
let record = line.strip_prefix("PORT\t")?;
let mut fields = record.splitn(6, '\t');
let protocol = fields.next()?.trim().to_string();
let address = fields.next()?.trim().to_string();
let port = fields.next()?.parse::<u16>().ok()?;
let state = fields.next()?.trim().to_string();
let pid = match fields.next()?.trim() {
"" | "-" | "0" => None,
value => value.parse::<u32>().ok(),
};
let process = fields.next()?.trim().to_string();
if protocol.is_empty() || address.is_empty() || state.is_empty() {
return None;
}
Some(RemotePort {
protocol,
address,
port,
state,
pid,
process: if process.is_empty() {
"-".to_string()
} else {
process
},
})
})
.collect()
}
fn parse_u64(kv: &BTreeMap<String, String>, key: &str) -> u64 {
kv.get(key)
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::{remote_ports_from_probe, remote_processes_from_ps, remote_snapshot_from_kv};
#[test]
fn parses_remote_process_rows_and_rss_bytes() {
let processes = remote_processes_from_ps(
" 42 alice 12.5 2048 worker --queue main\n 7 root 0.0 512 sshd\n",
);
assert_eq!(processes.len(), 2);
assert_eq!(processes[0].pid, 42);
assert_eq!(processes[0].user, "alice");
assert_eq!(processes[0].cpu_percent, 12.5);
assert_eq!(processes[0].memory_bytes, 2 * 1024 * 1024);
assert_eq!(processes[0].command, "worker --queue main");
}
#[test]
fn parses_structured_current_process_rows() {
let processes =
remote_processes_from_ps("PROCESS\t42\talice\t37.25\t2097152\tworker --queue main\n");
assert_eq!(processes.len(), 1);
assert_eq!(processes[0].pid, 42);
assert_eq!(processes[0].user, "alice");
assert_eq!(processes[0].cpu_percent, 37.25);
assert_eq!(processes[0].memory_bytes, 2 * 1024 * 1024);
assert_eq!(processes[0].command, "worker --queue main");
}
#[test]
fn skips_malformed_remote_process_rows() {
let processes = remote_processes_from_ps("PID USER CPU RSS COMMAND\n9 root bad 64 init\n");
assert!(processes.is_empty());
}
#[test]
fn clamps_remote_resource_values_to_valid_ranges() {
let snapshot = remote_snapshot_from_kv(
"CPU_PERCENT=250\nMEM_TOTAL=100\nMEM_USED=150\nSWAP_TOTAL=0\nSWAP_USED=10\nNET_RX=12\nNET_TX=34\nDISK=/data\t1048576\t2097152\nDISK=/\t3145728\t2097152\n",
)
.unwrap();
assert_eq!(snapshot.cpu_percent, 1.0);
assert_eq!(snapshot.mem_percent, 1.0);
assert_eq!(snapshot.swap_percent, 0.0);
assert_eq!(snapshot.disks[0].mount, "/");
assert_eq!(snapshot.disks[0].available_bytes, 2097152);
}
#[test]
fn parses_structured_remote_port_rows() {
let ports = remote_ports_from_probe(
"PORT\ttcp\t0.0.0.0\t22\tLISTEN\t123\tsshd\nPORT\tudp\t127.0.0.1\t5353\tUNCONN\t-\t-\n",
);
assert_eq!(ports.len(), 2);
assert_eq!(ports[0].protocol, "tcp");
assert_eq!(ports[0].address, "0.0.0.0");
assert_eq!(ports[0].port, 22);
assert_eq!(ports[0].state, "LISTEN");
assert_eq!(ports[0].pid, Some(123));
assert_eq!(ports[0].process, "sshd");
assert_eq!(ports[1].pid, None);
}
#[test]
fn skips_malformed_remote_port_rows() {
let ports = remote_ports_from_probe(
"PORT\ttcp\t0.0.0.0\tbad\tLISTEN\t1\tsshd\nPORT\ttcp\t\t80\tLISTEN\t1\tnginx\n",
);
assert!(ports.is_empty());
}
}
+344 -61
View File
@@ -12,6 +12,7 @@ use gpui_component::ActiveTheme as _;
use crate::Ashell;
use crate::terminal::custom_blocks::{is_custom_block_supported, paint_custom_block};
use crate::terminal::highlight::HighlightStyle;
use crate::terminal::{RenderSnapshot, ViewportSelection};
#[derive(Clone, Copy)]
@@ -39,6 +40,26 @@ impl LayoutRect {
}
}
#[derive(Clone)]
struct LayoutUnderline {
row: i32,
col: i32,
cells: usize,
color: Hsla,
}
impl LayoutUnderline {
fn paint(&self, origin: Point<Pixels>, metrics: TerminalMetrics, window: &mut Window) {
let thickness = px(1.0);
let position = point(
origin.x + metrics.cell_width * self.col as f32,
origin.y + metrics.line_height * (self.row as f32 + 1.0) - thickness,
);
let size = gpui::size(metrics.cell_width * self.cells as f32, thickness);
window.paint_quad(fill(Bounds::new(position, size), self.color));
}
}
#[derive(Clone)]
struct BatchedTextRun {
row: i32,
@@ -126,12 +147,29 @@ struct CursorLayout {
pub struct TerminalElement {
view: Entity<Ashell>,
focus_handle: FocusHandle,
pane_focused: bool,
snapshot: RenderSnapshot,
marked_text: Option<String>,
font_family: SharedString,
font_size: Pixels,
line_height: Pixels,
cell_width: Pixels,
tab_id: String,
search_highlights: Option<std::collections::HashMap<(i32, i32), Hsla>>,
}
pub(crate) struct TerminalElementConfig {
pub(crate) view: Entity<Ashell>,
pub(crate) focus_handle: FocusHandle,
pub(crate) pane_focused: bool,
pub(crate) snapshot: RenderSnapshot,
pub(crate) marked_text: Option<String>,
pub(crate) font_family: SharedString,
pub(crate) font_size: Pixels,
pub(crate) line_height: Pixels,
pub(crate) cell_width: Pixels,
pub(crate) tab_id: String,
pub(crate) search_highlights: Option<std::collections::HashMap<(i32, i32), Hsla>>,
}
pub struct PrepaintState {
@@ -141,6 +179,7 @@ pub struct PrepaintState {
runs: Vec<BatchedTextRun>,
custom_blocks: Vec<LayoutCustomBlock>,
cursor: Option<CursorLayout>,
underlines: Vec<LayoutUnderline>,
}
#[derive(Clone)]
@@ -261,25 +300,32 @@ impl InputHandler for TerminalInputHandler {
}
impl TerminalElement {
pub fn new(
view: Entity<Ashell>,
focus_handle: FocusHandle,
snapshot: RenderSnapshot,
marked_text: Option<String>,
font_family: SharedString,
font_size: Pixels,
line_height: Pixels,
cell_width: Pixels,
) -> Self {
Self {
pub(crate) fn new(config: TerminalElementConfig) -> Self {
let TerminalElementConfig {
view,
focus_handle,
pane_focused,
snapshot,
marked_text,
font_family,
font_size,
line_height,
cell_width,
tab_id,
search_highlights,
} = config;
Self {
view,
focus_handle,
pane_focused,
snapshot,
marked_text,
font_family,
font_size,
line_height,
cell_width,
tab_id,
search_highlights,
}
}
@@ -293,21 +339,16 @@ impl TerminalElement {
}
}
fn cell_run_style(&self, cell: &alacritty_terminal::term::cell::Cell, cx: &App) -> TextRun {
let mut fg = color_to_hsla(cell.fg, true, cx);
let mut bg = color_to_hsla(cell.bg, false, cx);
if cell.flags.contains(Flags::INVERSE) {
std::mem::swap(&mut fg, &mut bg);
}
if cell.flags.contains(Flags::DIM) {
fg.a *= 0.7;
}
fn cell_run_style(
&self,
cell: &alacritty_terminal::term::cell::Cell,
foreground: Hsla,
) -> TextRun {
let underline = cell
.flags
.intersects(Flags::ALL_UNDERLINES)
.then(|| UnderlineStyle {
color: Some(fg),
color: Some(foreground),
thickness: px(1.0),
wavy: cell.flags.contains(Flags::UNDERCURL),
});
@@ -315,7 +356,7 @@ impl TerminalElement {
.flags
.contains(Flags::STRIKEOUT)
.then(|| StrikethroughStyle {
color: Some(fg),
color: Some(foreground),
thickness: px(1.0),
});
@@ -332,7 +373,7 @@ impl TerminalElement {
TextRun {
len: cell.c.len_utf8(),
color: fg,
color: foreground,
background_color: None,
font: Font {
family: self.font_family.clone(),
@@ -348,10 +389,19 @@ impl TerminalElement {
fn layout_grid(
&self,
cx: &App,
) -> (Vec<LayoutRect>, Vec<BatchedTextRun>, Vec<LayoutCustomBlock>) {
) -> (
Vec<LayoutRect>,
Vec<BatchedTextRun>,
Vec<LayoutCustomBlock>,
Vec<LayoutUnderline>,
) {
let view_read = self.view.read(cx);
let hovered_url = view_read.hovered_url.clone();
let mut rects = Vec::new();
let mut runs = Vec::new();
let mut custom_blocks = Vec::new();
let mut underlines = Vec::new();
let mut current_run: Option<BatchedTextRun> = None;
for render_cell in &self.snapshot.cells {
@@ -365,24 +415,54 @@ impl TerminalElement {
let selected = self.snapshot.selection.is_some_and(|selection| {
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) {
let cell_position = (render_cell.row, render_cell.col);
let keyword_highlight = self.snapshot.highlights.get(&cell_position).copied();
let search_highlight = self
.search_highlights
.as_ref()
.and_then(|highlights| highlights.get(&cell_position))
.copied();
let (foreground, background) = resolve_cell_colors(
color_to_hsla(cell.fg, true, cx),
color_to_hsla(cell.bg, false, cx),
keyword_highlight,
cell.flags,
);
let cell_span = if cell.flags.contains(Flags::WIDE_CHAR) {
2
} else {
1
};
if selected {
rects.push(LayoutRect {
row: render_cell.row,
col: render_cell.col,
cells: if cell.flags.contains(Flags::WIDE_CHAR) {
2
} else {
1
},
color: if selected {
cx.theme().selection
} else if cell.flags.contains(Flags::INVERSE) {
color_to_hsla(cell.fg, true, cx)
} else {
bg
},
cells: cell_span,
color: cx.theme().selection,
});
} else {
let has_highlight_background =
keyword_highlight.is_some_and(|style| style.background.is_some());
if !is_default_bg(cell.bg)
|| cell.flags.contains(Flags::INVERSE)
|| has_highlight_background
{
rects.push(LayoutRect {
row: render_cell.row,
col: render_cell.col,
cells: cell_span,
color: background,
});
}
if let Some(search_background) = search_highlight {
rects.push(LayoutRect {
row: render_cell.row,
col: render_cell.col,
cells: cell_span,
color: search_background,
});
}
}
if is_blank(cell) {
@@ -392,7 +472,27 @@ impl TerminalElement {
continue;
}
let style = self.cell_run_style(cell, cx);
let style = self.cell_run_style(cell, foreground);
// Apply hover underline if mouse is hovering over this URL
if let Some(hu) = &hovered_url {
if hu.tab_id == self.tab_id
&& hu
.cells
.contains(&(render_cell.row as usize, render_cell.col as usize))
{
underlines.push(LayoutUnderline {
row: render_cell.row,
col: render_cell.col,
cells: if cell.flags.contains(Flags::WIDE_CHAR) {
2
} else {
1
},
color: style.color,
});
}
}
// Box Drawing & Block Elements interception
let is_custom_block = is_custom_block_supported(cell.c);
@@ -446,19 +546,62 @@ impl TerminalElement {
runs.push(run);
}
(merge_rects(rects), runs, custom_blocks)
(
merge_rects(rects),
runs,
custom_blocks,
merge_underlines(underlines),
)
}
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,
fn cursor_layout(&self, window: &Window, cx: &App) -> Option<CursorLayout> {
if !should_render_cursor(
window.is_window_active(),
self.focus_handle.is_focused(window),
self.pane_focused,
) {
return None;
}
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,
}
})
}
}
fn should_render_cursor(window_active: bool, terminal_focused: bool, pane_focused: bool) -> bool {
window_active && terminal_focused && pane_focused
}
impl IntoElement for TerminalElement {
type Element = Self;
@@ -498,11 +641,31 @@ impl Element for TerminalElement {
_inspector_id: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
_window: &mut Window,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
let _ = self.base_text_style(cx);
let (rects, runs, custom_blocks) = self.layout_grid(cx);
// Local window animations emit many intermediate sizes. The view
// coalesces those requests before resizing its terminal grid and PTY.
let view = self.view.clone();
let tab_id = self.tab_id.clone();
let cols = (bounds.size.width.as_f32() / self.cell_width.as_f32())
.floor()
.max(1.0) as u16;
let rows = (bounds.size.height.as_f32() / self.line_height.as_f32())
.floor()
.max(1.0) as u16;
let resized_snapshot = view.update(cx, |this, cx| {
this.terminal_bounds.insert(tab_id.clone(), bounds);
this.resize_terminal_for_layout(&tab_id, cols, rows, cx)
});
if let Some(snapshot) = resized_snapshot {
self.snapshot = snapshot;
}
let (rects, runs, custom_blocks, underlines) = self.layout_grid(cx);
PrepaintState {
bounds,
metrics: TerminalMetrics {
@@ -512,7 +675,8 @@ impl Element for TerminalElement {
rects,
runs,
custom_blocks,
cursor: self.cursor_layout(cx),
cursor: self.cursor_layout(window, cx),
underlines,
}
}
@@ -526,19 +690,34 @@ 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 u in &prepaint.underlines {
u.paint(draw_origin, prepaint.metrics, window);
}
for block in &prepaint.custom_blocks {
let x = prepaint.bounds.origin.x.as_f32()
+ block.col as f32 * prepaint.metrics.cell_width.as_f32();
let y = prepaint.bounds.origin.y.as_f32()
+ block.row as f32 * prepaint.metrics.line_height.as_f32();
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,
@@ -554,7 +733,7 @@ impl Element for TerminalElement {
&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(),
},
@@ -564,8 +743,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 {
@@ -612,8 +791,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 => {
@@ -673,6 +852,23 @@ fn merge_rects(mut rects: Vec<LayoutRect>) -> Vec<LayoutRect> {
merged
}
fn merge_underlines(mut underlines: Vec<LayoutUnderline>) -> Vec<LayoutUnderline> {
underlines.sort_by_key(|u| (u.row, u.col));
let mut merged: Vec<LayoutUnderline> = Vec::with_capacity(underlines.len());
for u in underlines {
if let Some(last) = merged.last_mut() {
if last.row == u.row && last.color == u.color && last.col + last.cells as i32 == u.col {
last.cells += u.cells;
continue;
}
}
merged.push(u);
}
merged
}
fn selection_contains(selection: ViewportSelection, row: i32, col: i32) -> bool {
let row = row.max(0) as usize;
let col = col.max(0) as usize;
@@ -690,6 +886,31 @@ fn selection_contains(selection: ViewportSelection, row: i32, col: i32) -> bool
after_start && before_end
}
fn resolve_cell_colors(
mut foreground: Hsla,
mut background: Hsla,
highlight: Option<HighlightStyle>,
flags: Flags,
) -> (Hsla, Hsla) {
if let Some(highlight) = highlight {
if let Some(highlight_foreground) = highlight.foreground {
foreground = highlight_foreground;
}
if let Some(highlight_background) = highlight.background {
background = highlight_background;
}
}
if flags.contains(Flags::INVERSE) {
std::mem::swap(&mut foreground, &mut background);
}
if flags.contains(Flags::DIM) {
foreground.a *= 0.7;
}
(foreground, background)
}
fn is_blank(cell: &alacritty_terminal::term::cell::Cell) -> bool {
cell.c == ' '
&& cell.zerowidth().is_none()
@@ -781,3 +1002,65 @@ fn named_color(named: NamedColor, _foreground: bool, cx: &App) -> Hsla {
NamedColor::DimWhite => Hsla::from(rgb(0xe5e9f0)),
}
}
#[cfg(test)]
mod tests {
use super::{resolve_cell_colors, should_render_cursor};
use crate::terminal::highlight::HighlightStyle;
use alacritty_terminal::term::cell::Flags;
use gpui::Hsla;
fn color(h: f32, s: f32, l: f32) -> Hsla {
Hsla { h, s, l, a: 1.0 }
}
#[test]
fn renders_cursor_only_for_focused_pane_in_focused_terminal_window() {
assert!(should_render_cursor(true, true, true));
assert!(!should_render_cursor(false, true, true));
assert!(!should_render_cursor(true, false, true));
assert!(!should_render_cursor(true, true, false));
}
#[test]
fn applies_highlight_channels_before_inverse_terminal_style() {
let original_foreground = color(0.0, 0.0, 0.8);
let original_background = color(0.0, 0.0, 0.1);
let highlight_foreground = color(0.1, 0.8, 0.5);
let highlight_background = color(0.6, 0.8, 0.4);
let (foreground, background) = resolve_cell_colors(
original_foreground,
original_background,
Some(HighlightStyle {
foreground: Some(highlight_foreground),
background: Some(highlight_background),
}),
Flags::INVERSE,
);
assert_eq!(foreground, highlight_background);
assert_eq!(background, highlight_foreground);
}
#[test]
fn foreground_highlight_preserves_background_and_dim_style() {
let original_foreground = color(0.0, 0.0, 0.8);
let original_background = color(0.0, 0.0, 0.1);
let highlight_foreground = color(0.1, 0.8, 0.5);
let (foreground, background) = resolve_cell_colors(
original_foreground,
original_background,
Some(HighlightStyle {
foreground: Some(highlight_foreground),
background: None,
}),
Flags::DIM,
);
assert_eq!(foreground.alpha(1.0), highlight_foreground);
assert!((foreground.a - 0.7).abs() < f32::EPSILON);
assert_eq!(background, original_background);
}
}
File diff suppressed because it is too large Load Diff
+1218 -35
View File
File diff suppressed because it is too large Load Diff
+2148 -48
View File
File diff suppressed because it is too large Load Diff
+218
View File
@@ -0,0 +1,218 @@
use std::borrow::Cow;
use encoding_rs::{
BIG5, CoderResult, EUC_KR, Encoding, GB18030, GBK, SHIFT_JIS, UTF_8, UTF_16BE, UTF_16LE,
WINDOWS_1252,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TextEncoding {
#[default]
Utf8,
Gb18030,
Gbk,
Big5,
ShiftJis,
EucKr,
Windows1252,
Utf16Le,
Utf16Be,
}
pub(crate) const TERMINAL_ENCODINGS: &[TextEncoding] = &[
TextEncoding::Utf8,
TextEncoding::Gb18030,
TextEncoding::Gbk,
TextEncoding::Big5,
TextEncoding::ShiftJis,
TextEncoding::EucKr,
TextEncoding::Windows1252,
];
pub(crate) const FILE_ENCODINGS: &[TextEncoding] = &[
TextEncoding::Utf8,
TextEncoding::Utf16Le,
TextEncoding::Utf16Be,
TextEncoding::Gb18030,
TextEncoding::Gbk,
TextEncoding::Big5,
TextEncoding::ShiftJis,
TextEncoding::EucKr,
TextEncoding::Windows1252,
];
impl TextEncoding {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Utf8 => "UTF-8",
Self::Gb18030 => "GB18030",
Self::Gbk => "GBK",
Self::Big5 => "Big5",
Self::ShiftJis => "Shift_JIS",
Self::EucKr => "EUC-KR",
Self::Windows1252 => "Windows-1252",
Self::Utf16Le => "UTF-16 LE",
Self::Utf16Be => "UTF-16 BE",
}
}
pub(crate) fn encoding(self) -> &'static Encoding {
match self {
Self::Utf8 => UTF_8,
Self::Gb18030 => GB18030,
Self::Gbk => GBK,
Self::Big5 => BIG5,
Self::ShiftJis => SHIFT_JIS,
Self::EucKr => EUC_KR,
Self::Windows1252 => WINDOWS_1252,
Self::Utf16Le => UTF_16LE,
Self::Utf16Be => UTF_16BE,
}
}
pub(crate) fn detect_bom(bytes: &[u8]) -> Option<Self> {
if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
Some(Self::Utf8)
} else if bytes.starts_with(&[0xFF, 0xFE]) {
Some(Self::Utf16Le)
} else if bytes.starts_with(&[0xFE, 0xFF]) {
Some(Self::Utf16Be)
} else {
None
}
}
pub(crate) fn decode_file(self, bytes: &[u8]) -> (String, bool, bool) {
let bom_len = self.matching_bom_len(bytes);
let (text, had_errors) = self
.encoding()
.decode_without_bom_handling(&bytes[bom_len..]);
(text.into_owned(), had_errors, bom_len > 0)
}
pub(crate) fn encode_file(self, text: &str, with_bom: bool) -> (Vec<u8>, bool) {
if matches!(self, Self::Utf16Le | Self::Utf16Be) {
let mut bytes = Vec::with_capacity(text.len().saturating_mul(2).saturating_add(2));
if with_bom {
bytes.extend_from_slice(if self == Self::Utf16Le {
&[0xFF, 0xFE]
} else {
&[0xFE, 0xFF]
});
}
for code_unit in text.encode_utf16() {
let encoded = if self == Self::Utf16Le {
code_unit.to_le_bytes()
} else {
code_unit.to_be_bytes()
};
bytes.extend_from_slice(&encoded);
}
return (bytes, false);
}
let (encoded, _, had_errors) = self.encoding().encode(text);
let mut bytes = Vec::with_capacity(encoded.len() + 3);
if with_bom {
match self {
Self::Utf8 => bytes.extend_from_slice(&[0xEF, 0xBB, 0xBF]),
Self::Utf16Le => bytes.extend_from_slice(&[0xFF, 0xFE]),
Self::Utf16Be => bytes.extend_from_slice(&[0xFE, 0xFF]),
_ => {}
}
}
bytes.extend_from_slice(&encoded);
(bytes, had_errors)
}
pub(crate) fn default_bom(self) -> bool {
matches!(self, Self::Utf16Le | Self::Utf16Be)
}
pub(crate) fn encode_terminal_input<'a>(self, bytes: &'a [u8]) -> Cow<'a, [u8]> {
if self == Self::Utf8 {
return Cow::Borrowed(bytes);
}
let Ok(text) = std::str::from_utf8(bytes) else {
return Cow::Borrowed(bytes);
};
let (encoded, _, _) = self.encoding().encode(text);
encoded
}
fn matching_bom_len(self, bytes: &[u8]) -> usize {
match self {
Self::Utf8 if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) => 3,
Self::Utf16Le if bytes.starts_with(&[0xFF, 0xFE]) => 2,
Self::Utf16Be if bytes.starts_with(&[0xFE, 0xFF]) => 2,
_ => 0,
}
}
}
pub(crate) struct StreamingDecoder {
decoder: encoding_rs::Decoder,
}
impl StreamingDecoder {
pub(crate) fn new(encoding: TextEncoding) -> Self {
Self {
decoder: encoding.encoding().new_decoder_without_bom_handling(),
}
}
pub(crate) fn decode(&mut self, bytes: &[u8]) -> Vec<u8> {
let initial_capacity = self
.decoder
.max_utf8_buffer_length(bytes.len())
.unwrap_or_else(|| bytes.len().saturating_mul(4).saturating_add(16));
let mut output = String::with_capacity(initial_capacity);
let mut remaining = bytes;
loop {
let (result, read, _) = self.decoder.decode_to_string(remaining, &mut output, false);
remaining = &remaining[read..];
match result {
CoderResult::InputEmpty => break,
CoderResult::OutputFull => {
let additional = self
.decoder
.max_utf8_buffer_length(remaining.len())
.unwrap_or_else(|| remaining.len().saturating_mul(4).saturating_add(16))
.max(16);
output.reserve(additional);
}
}
}
output.into_bytes()
}
}
#[cfg(test)]
mod tests {
use super::{StreamingDecoder, TextEncoding};
#[test]
fn streaming_decoder_preserves_split_multibyte_characters() {
let (encoded, _) = TextEncoding::Gbk.encode_file("中文", false);
let mut decoder = StreamingDecoder::new(TextEncoding::Gbk);
let mut decoded = decoder.decode(&encoded[..1]);
decoded.extend(decoder.decode(&encoded[1..]));
assert_eq!(String::from_utf8(decoded).unwrap(), "中文");
}
#[test]
fn file_encoding_preserves_matching_bom() {
let original = [0xFF, 0xFE, b'A', 0x00];
let (text, had_errors, has_bom) = TextEncoding::Utf16Le.decode_file(&original);
let (encoded, encode_errors) = TextEncoding::Utf16Le.encode_file(&text, has_bom);
assert!(!had_errors);
assert!(!encode_errors);
assert_eq!(encoded, original);
}
}