Compare commits

..
49 Commits
Author SHA1 Message Date
TomZz 4ba3000538 feat: add customizable system monitoring position
- Add settings to position the system monitoring panel at the Bottom, Sidebar, or Hidden.
- Support dynamically reallocating UI space when the monitor panel is moved.
- Implement a simplified, compact card-style system monitoring view for the sidebar (similar to v0.3.5 design).
- Add full localization for monitoring setting and sidebar metrics.
2026-06-13 03:34:29 +08:00
TomZz e7e2c67393 fix: restore body_panels layout sizes on app startup
- Ensure the size of the monitoring panel (the second child of body_panels) is explicitly loaded from configuration rather than falling back to the hardcoded default of 328px.
2026-06-13 03:01:40 +08:00
TomZz 47b03510b2 feat: optimize monitoring card layout for memory and disks
- Hide swap memory progress bar entirely if total swap is 0.
- Add temporary overlay scrollbar for the disk list using ScrollbarShow::Scrolling, allowing users to scroll through long disk lists without displaying a persistent scrollbar UI.
- Fix unused variable and dead code cargo warnings.
2026-06-13 02:56:11 +08:00
TomZz 54c50c871a feat: fix SFTP monitoring panel minimize behavior and layout
- Isolated the monitoring panel from ResizableState when minimized to prevent the GPUI flex layout and proportional resize engine from forcing it out of the window bounds.
- Wrapped the terminal's resizable container with flex_1 to constrain it to available space, preventing the fixed-height 114px panel from overflowing the bottom of the screen.
- Saved the user's dragged panel size (prev_monitoring_size) immediately before minimizing.

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

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

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

- Isolate auto-sync from transfer history UI.

- Clear legacy session config tmp files on application startup.
2026-06-12 07:14:55 +08:00
TomZz f1186e4c84 fix: UI/UX improvements for tabs, sidebar layout, and scrolling
- Redesign TabBar: remove bottom border and adjust height to a compact 40px.
- Fix UI auto-scrolling: TabBar, Open Session dialog, and Saved Sessions sidebar now accurately scroll selected items into view.
- Fix layout bounds for scrollbars so they render continuously and always stay visible.
- Pin sidebar headers (logo, stats) to the top while only the session list scrolls.
2026-06-12 05:32:54 +08:00
TomZz 2595cc4853 fix: prevent IME composition interrupt by only syncing theme when system appearance changes 2026-06-12 04:50:54 +08:00
TomZz 2ae28df8f8 Fix: Prevent auto-dismiss of newly opened dialogs when using keyboard navigation in Open Session selector 2026-06-12 03:38:41 +08:00
TomZz 58fd03386a Feat: Implement intelligent right-click copy/paste and add missing translations 2026-06-12 02:27:09 +08:00
TomZz 890ce85cfc Docs: Update preview image 2026-06-12 01:42:20 +08:00
TomZz d5fb956161 Docs: Update README with v0.3 release highlights 2026-06-12 01:31:07 +08:00
TomZz 4bad75e522 UI: Optimize font selection layout and prioritize built-in font
- Align UI font and Terminal font label widths (180px -> 240px) to match other settings
- Pin 'Maple Mono NF CN' to the top of font dropdown menus
- Add localized '(Built-in)' / '(软件内置)' suffix to the built-in font
- Add a visual separator below the built-in font in the dropdown menu
- Add 'software_builtin' translation keys in en.yml and zh-CN.yml
2026-06-12 01:03:47 +08:00
TomZz 9e28e78319 Merge pull request #6 from ancion/ashell
- Resolves conflicts in src/main.rs with SFTP header layout, font sizing, and resizable panel configs.
2026-06-12 00:50:10 +08:00
TomZz d561232d19 Fix duplicate release notes generation
- Move GitHub Release creation to a separate publish job that runs after all matrix builds finish to prevent concurrent appends to the release body.
2026-06-12 00:34:42 +08:00
TomZz 107fe8942c Add reconnection prompt overlay on unexpected SSH disconnect
- 在 SSH 会话意外断线时复用 connection_progress 显示错误信息
- 区分正常退出与异常断线,避免用户主动执行 exit 时弹出重连提示
- 接入已有的重连 (Retry) 和 取消 (Cancel) 逻辑,允许用户在断线后一键恢复会话
2026-06-12 00:31:53 +08:00
TomZz 06796031e6 Refactor SFTP transfers with concurrency, persistence, and UI improvements
- 使用多线程并发处理多文件/大文件 SFTP 传输以提升速度
- 修复 SFTP 底部状态栏不显示的排版问题
- 新增持久化功能:将传输历史记录保存至本地配置文件夹并在启动时加载
- 修复传输记录中文件和文件夹数量统计及 i18n 汉化的正确显示
- 优化传输记录窗口 UI:增加自适应滚动列表,并集成匹配 SFTP 面板原生样式的右侧长驻滚动条
- 清理 Cargo warning,修复无用变量等问题
2026-06-12 00:26:04 +08:00
ancion 0cfd3a2fc2 chore: Increased maximum height for the SFTP section; clicking a file toggles its selection state. 2026-06-11 17:30:26 +08:00
TomZz 7504bf6448 feat: persist window bounds and layout sizes across restarts 2026-06-11 17:27:37 +08:00
TomZz 09ecd67ca6 feat: localize sftp status messages 2026-06-11 17:02:46 +08:00
ancion 6dfa199dcb feat: add UI font config and font scale and change real time 2026-06-11 16:55:14 +08:00
TomZz 4c5148fccf ci: auto-generate release notes with commits since last release
- Enabled `generate_release_notes: true` in the release workflow. This will automatically include the changelog (commits and PRs) between the current and previous release tags on GitHub.
2026-06-11 02:36:11 +08:00
TomZz 108a9cf95b chore: fix compiler warnings and format code
- Added #[allow(dead_code)] to unused fields, variants, and functions in sftp.rs and terminal.rs
- Renamed unused window variable to _window in main.rs
- Ran cargo fmt to format the codebase
2026-06-11 02:22:49 +08:00
TomZz 9885b2573a fix: persist manual theme mode selection
- Added `theme_mode` field to config to save the manually selected theme mode (Light/Dark).
- The application now correctly remembers and restores the user's manual selection across restarts instead of always defaulting to Light when `follow_system_theme` is false.
2026-06-11 02:19:46 +08:00
TomZz cdf4cc3d53 fix: app fails to exit on windows/linux when last window is closed
- Set QuitMode::Explicit only on macOS to allow the app to stay alive without windows.
- Use the default QuitMode (CloseAll) on Windows and Linux so the application correctly exits when the main window is closed.
2026-06-11 02:15:19 +08:00
TomZz 5b381045f9 feat: generate application icons and integrate into binaries
- Added generated ashell.png, ashell.icns, and ashell.ico to assets/icons/
- Configured gpui WindowOptions to embed and display the icon for Linux
- Added winres build dependency and build.rs to embed Windows .ico into the executable
- Updated package-macos-app.sh and release.yml to bundle macOS .icns into the .app bundle
- Removed manual copying of icons into release zip/tarballs since they are now embedded
2026-06-11 02:12:00 +08:00
TomZz f246c952a2 docs: update README for Linux support and refactor i18n usage
- Updated README.md and README.en.md to include Linux download instructions
- Refactored rust_i18n macro usage by importing t directly (use rust_i18n::t)
2026-06-09 21:44:50 +08:00
TomZz 2276eef191 feat: complete internationalization and ui adjustments
- Add full i18n support for UI components including settings and sidebar
- Add connection progress tracking strings (Connecting, Failed, etc.)
- Fix 'No session' display issue when no SSH/Local tab is active
- Align SFTP 'show hidden files' checkbox text size with adjacent buttons
- Add border to language settings dropdown for better visibility
2026-06-09 19:36:18 +08:00
TomZz bb39626173 feat: add sftp multi-selection, batch download, and drag-to-upload support 2026-06-09 18:44:25 +08:00
TomZz 2cb8ab50aa style: optimize initial window size and positioning on scaled displays 2026-06-09 18:16:11 +08:00
TomZz 785670c428 feat: sync sftp directory with terminal directory 2026-06-09 15:21:36 +08:00
TomZz d33babe552 docs: add Mac installation guide regarding ad-hoc signing 2026-06-09 13:18:51 +08:00
TomZz 985f88fb1a ci: use macos-14 runner for Intel macOS to avoid queue delays 2026-06-09 13:07:40 +08:00
TomZz 8deafbfe4e ci: add GitHub Actions release workflow 2026-06-09 12:43:17 +08:00
TomZz efc57560d8 docs: add Windows platform to download section 2026-06-09 05:50:07 +08:00
TomZz a7428bd47c docs: make Chinese the default README and add download link 2026-06-09 05:48:23 +08:00
TomZz a44bbe1580 docs: add preview image to README 2026-06-09 04:05:53 +08:00
TomZz 56030a0e77 feat: change system theme sync interval to 1s 2026-06-09 03:47:24 +08:00
TomZz 56b41d625a feat: hide console window on windows and auto-sync system theme 2026-06-09 03:38:52 +08:00
29 changed files with 7481 additions and 3436 deletions
+170
View File
@@ -0,0 +1,170 @@
name: Release
# Build native binaries 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).
on:
push:
tags: ["v*"]
workflow_dispatch:
permissions:
contents: write # needed to create / update the GitHub Release
jobs:
build:
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
target: x86_64-pc-windows-msvc
name: windows-x86_64
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
bin: ashell
- os: macos-14 # Apple Silicon
target: aarch64-apple-darwin
name: macos-aarch64
bin: ashell
- os: macos-14 # Intel (cross-compiled from Apple Silicon to avoid queue delays)
target: x86_64-apple-darwin
name: macos-x86_64
bin: ashell
steps:
- uses: actions/checkout@v4
# GPUI needs these system libs on Linux to compile.
- name: Install Linux GUI dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential pkg-config cmake \
libfontconfig1-dev libfreetype6-dev \
libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \
libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev \
libgl1-mesa-dev libegl1-mesa-dev libgtk-3-dev \
libudev-dev
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Build (release)
run: cargo build --release --target ${{ matrix.target }}
- name: Package
shell: bash
run: |
set -eu
VERSION="${GITHUB_REF_NAME:-dev}"
STAGE="ashell-${VERSION}-${{ matrix.name }}"
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.
APP="ashell.app"
CONTENTS="$APP/Contents"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
cp "target/${{ matrix.target }}/release/ashell" "$CONTENTS/MacOS/"
cp "assets/icons/ashell.icns" "$CONTENTS/Resources/ashell.icns"
VERSION_NUM="${VERSION#v}"
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_NUM}</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
EOF
printf 'APPL????' > "$CONTENTS/PkgInfo"
# Ad-hoc code-sign the assembled bundle.
codesign --force --deep --sign - "$APP"
codesign --verify --verbose "$APP"
# Package with ditto (Apple's tool) to preserve signature/metadata
ditto -c -k --keepParent "$APP" "${STAGE}.zip"
echo "ASSET=${STAGE}.zip" >> "$GITHUB_ENV"
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"
fi
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.name }}
path: ${{ env.ASSET }}
publish:
name: Publish Release
needs: build
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- name: Download all workflow artifacts
uses: actions/download-artifact@v4
with:
path: dist
- name: Attach to GitHub Release
uses: softprops/action-gh-release@v2
with:
files: dist/**/*
generate_release_notes: true
fail_on_unmatched_files: true
Generated
+189 -23
View File
@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "accesskit"
@@ -292,19 +292,25 @@ dependencies = [
"chrono",
"directories",
"flate2",
"futures",
"gpui",
"gpui-component",
"gpui-component-assets",
"gpui_platform",
"image",
"menu",
"notify 6.1.1",
"open",
"portable-pty",
"rfd",
"russh",
"russh-keys",
"russh-sftp",
"rust-i18n 3.1.5",
"serde",
"serde_json",
"ssh-key",
"sys-locale",
"sysinfo 0.33.1",
"tar",
"thiserror 1.0.69",
@@ -313,6 +319,7 @@ dependencies = [
"tracing-subscriber",
"uuid",
"walkdir",
"winres",
"zip",
]
@@ -753,7 +760,7 @@ dependencies = [
"bitflags 2.12.1",
"cexpr",
"clang-sys",
"itertools 0.11.0",
"itertools 0.13.0",
"log",
"prettyplease",
"proc-macro2",
@@ -1409,6 +1416,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
@@ -1682,7 +1698,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -1942,7 +1958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2563,7 +2579,7 @@ dependencies = [
"log",
"presser",
"thiserror 2.0.18",
"windows 0.58.0",
"windows 0.62.2",
]
[[package]]
@@ -2691,13 +2707,13 @@ dependencies = [
"lsp-types",
"markdown",
"markup5ever_rcdom",
"notify",
"notify 7.0.0",
"num-traits",
"once_cell",
"paste",
"regex",
"ropey",
"rust-i18n",
"rust-i18n 4.1.0",
"schemars",
"serde",
"serde_json",
@@ -3258,7 +3274,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.58.0",
"windows-core 0.62.2",
]
[[package]]
@@ -3453,6 +3469,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inotify"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify"
version = "0.10.2"
@@ -3542,6 +3569,25 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itertools"
version = "0.11.0"
@@ -4104,6 +4150,18 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.48.0",
]
[[package]]
name = "mio"
version = "1.2.1"
@@ -4122,7 +4180,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4265,6 +4323,25 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "notify"
version = "6.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
dependencies = [
"bitflags 2.12.1",
"crossbeam-channel",
"filetime",
"fsevent-sys",
"inotify 0.9.6",
"kqueue",
"libc",
"log",
"mio 0.8.11",
"walkdir",
"windows-sys 0.48.0",
]
[[package]]
name = "notify"
version = "7.0.0"
@@ -4274,11 +4351,11 @@ dependencies = [
"bitflags 2.12.1",
"filetime",
"fsevent-sys",
"inotify",
"inotify 0.10.2",
"kqueue",
"libc",
"log",
"mio",
"mio 1.2.1",
"notify-types",
"walkdir",
"windows-sys 0.52.0",
@@ -4308,7 +4385,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4702,6 +4779,17 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
dependencies = [
"is-wsl",
"libc",
"pathdiff",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
@@ -4827,6 +4915,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pathfinder_geometry"
version = "0.5.1"
@@ -5984,6 +6078,20 @@ dependencies = [
"walkdir",
]
[[package]]
name = "rust-i18n"
version = "3.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fda2551fdfaf6cc5ee283adc15e157047b92ae6535cf80f6d4962d05717dc332"
dependencies = [
"globwalk",
"once_cell",
"regex",
"rust-i18n-macro 3.1.5",
"rust-i18n-support 3.1.5",
"smallvec",
]
[[package]]
name = "rust-i18n"
version = "4.1.0"
@@ -5992,11 +6100,28 @@ checksum = "55691a65892c33ee2de49c15ea5600c6f4a70e8eeb8e6c3cd96d2a231d230c40"
dependencies = [
"globwalk",
"regex",
"rust-i18n-macro",
"rust-i18n-support",
"rust-i18n-macro 4.1.0",
"rust-i18n-support 4.1.0",
"smallvec",
]
[[package]]
name = "rust-i18n-macro"
version = "3.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22baf7d7f56656d23ebe24f6bb57a5d40d2bce2a5f1c503e692b5b2fa450f965"
dependencies = [
"glob",
"once_cell",
"proc-macro2",
"quote",
"rust-i18n-support 3.1.5",
"serde",
"serde_json",
"serde_yaml",
"syn",
]
[[package]]
name = "rust-i18n-macro"
version = "4.1.0"
@@ -6006,13 +6131,36 @@ dependencies = [
"glob",
"proc-macro2",
"quote",
"rust-i18n-support",
"rust-i18n-support 4.1.0",
"serde",
"serde_json",
"serde_yaml",
"syn",
]
[[package]]
name = "rust-i18n-support"
version = "3.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940ed4f52bba4c0152056d771e563b7133ad9607d4384af016a134b58d758f19"
dependencies = [
"arc-swap",
"base62",
"globwalk",
"itertools 0.11.0",
"lazy_static",
"normpath",
"once_cell",
"proc-macro2",
"regex",
"serde",
"serde_json",
"serde_yaml",
"siphasher",
"toml 0.8.23",
"triomphe",
]
[[package]]
name = "rust-i18n-support"
version = "4.1.0"
@@ -6069,7 +6217,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -6082,7 +6230,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6737,7 +6885,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -6845,7 +6993,7 @@ dependencies = [
"cfg-if",
"libc",
"psm",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7205,7 +7353,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7359,7 +7507,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
"mio",
"mio 1.2.1",
"pin-project-lite",
"socket2",
"tokio-macros",
@@ -7425,6 +7573,15 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
dependencies = [
"serde",
]
[[package]]
name = "toml"
version = "0.8.23"
@@ -7682,7 +7839,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset 0.9.1",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -8442,7 +8599,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -9079,6 +9236,15 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "winres"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c"
dependencies = [
"toml 0.5.11",
]
[[package]]
name = "winsafe"
version = "0.0.19"
+9
View File
@@ -24,6 +24,8 @@ rfd = "0.15"
russh = "0.49"
russh-keys = "0.49"
russh-sftp = "2"
rust-i18n = "3"
sys-locale = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
ssh-key = "0.6"
@@ -36,9 +38,16 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4", "serde"] }
walkdir = "2"
zip = { version = "2", default-features = false, features = ["deflate"] }
image = { version = "0.25.9", default-features = false, features = ["png"] }
futures = "0.3.32"
notify = "6.1.1"
open = "5.1"
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = true
[build-dependencies]
winres = "0.1.12"
+66
View File
@@ -0,0 +1,66 @@
[中文](README.md) | [English](README.en.md)
# ashell
![Preview](preview.png)
`ashell` is a modern, GPUI Component-based desktop terminal client written in Rust.
This project focuses on providing a high-performance and visually appealing shell workspace by combining local and remote environments with a rich set of built-in features.
## 🚀 v0.3 Massive Update!
The brand-new v0.3 introduces comprehensive upgrades to both architecture and user experience:
- ✨ **Global Real-Time Font Settings** (Thanks to [@ancion](https://github.com/ancion) for [PR #6](https://github.com/rust-kotlin/ashell/pull/6)): Customize your global UI fonts and terminal rendering fonts independently. Adjust text size in real-time with automatic persistence.
- ✨ **Concurrent SFTP Transfers**: A dedicated Transfers dialog now supports highly optimized, concurrent file uploads and downloads with precise progress display.
- ✨ **Persistent Workspace State**: Your sidebar width adjustments, terminal themes, font settings, and file visibility preferences are completely restored upon your next launch!
- ✨ **Intelligent SSH Disconnect/Reconnect**: Greatly enhanced SSH connection resilience. Provides a beautiful and localized overlay for one-click re-connection on unexpected disconnections.
- ✨ **Hot-Swappable i18n System**: Deeply optimized internationalization. Switch instantly between English and Simplified Chinese without restarting the application.
- ✨ **Smart Right-Click Actions**: Added intelligent right-click copy and paste for the terminal, featuring a dedicated settings toggle and OS-native keyboard shortcut hints.
## Download
You can download the latest pre-compiled releases for macOS, Windows, and Linux from the [GitHub Releases page](https://github.com/rust-kotlin/ashell/releases/latest).
## Mac Installation Guide
After downloading, please unzip the file and move `ashell.app` to your **Applications** folder.
Since the app uses ad-hoc signing, macOS may warn that the app is "damaged" upon first launch. If this happens, open Terminal and run the following command:
```bash
sudo xattr -cr /Applications/ashell.app
```
## Features
The current version provides a fully-featured GPUI-native workspace:
- **Local & Remote Sessions:** Open local terminal tabs or connect to remote servers via SSH.
- **Advanced SSH Authentication:** Supports both password-based and key-based (file path or inline) SSH connections.
- **Session Management:** Easily save, reopen, edit, and remove your SSH sessions.
- **SFTP Integration:** Built-in SFTP file manager to browse, upload, download, and manage remote files.
- **Robust Terminal Emulator:** Parses terminal output with `alacritty_terminal`, supporting rich ANSI color spans, fast rendering, and complete keyboard input forwarding.
- **System Telemetry:** Real-time visualization of CPU, memory, swap, network, and disk metrics in the left cockpit sidebar.
- **Theming System:** Switch between multiple GPUI Component themes directly from the top toolbar.
- **Embedded Fonts:** Uses embedded Maple Mono NF CN fonts out-of-the-box for excellent CJK character and Nerd Font icon support.
## Run
To run the application locally:
```bash
cargo run --release
```
## Package macOS App
```bash
./scripts/package-macos-app.sh
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).
## License
This project is licensed under the [GPL-3.0-or-later License](LICENSE).
+44 -19
View File
@@ -1,41 +1,66 @@
[English](README.md) | [中文](README.zh-CN.md)
[中文](README.md) | [English](README.en.md)
# ashell
`ashell` is a modern, GPUI Component-based desktop terminal client written in Rust.
![Preview](preview.png)
This project focuses on providing a high-performance and visually appealing shell workspace by combining local and remote environments with a rich set of built-in features.
`ashell` 是一款现代化的、基于 GPUI Component 构建的 Rust 桌面终端客户端。
## Features
该项目旨在通过结合本地和远程环境,并内置丰富的核心功能,提供一个高性能且美观的 Shell 工作区。
The current version provides a fully-featured GPUI-native workspace:
## 🚀 v0.3 版本重磅更新!
- **Local & Remote Sessions:** Open local terminal tabs or connect to remote servers via SSH.
- **Advanced SSH Authentication:** Supports both password-based and key-based (file path or inline) SSH connections.
- **Session Management:** Easily save, reopen, edit, and remove your SSH sessions.
- **SFTP Integration:** Built-in SFTP file manager to browse, upload, download, and manage remote files.
- **Robust Terminal Emulator:** Parses terminal output with `alacritty_terminal`, supporting rich ANSI color spans, fast rendering, and complete keyboard input forwarding.
- **System Telemetry:** Real-time visualization of CPU, memory, swap, network, and disk metrics in the left cockpit sidebar.
- **Theming System:** Switch between multiple GPUI Component themes directly from the top toolbar.
- **Embedded Fonts:** Uses embedded Maple Mono NF CN fonts out-of-the-box for excellent CJK character and Nerd Font icon support.
全新发布的 v0.3 迎来了架构和体验的全面升级:
- ✨ **全局实时字体设置与字号调节**(感谢 [@ancion](https://github.com/ancion) 提供的 [PR #6](https://github.com/rust-kotlin/ashell/pull/6)):支持自定义全局 UI 字体和终端渲染字体,支持实时字号调节并自动记忆。
- ✨ **SFTP 多任务并发传输队列**:引入全新的独立传输对话框,支持多文件并发上传下载,提供精准的进度展示。
- ✨ **持久化工作区记忆**:您对侧边栏的尺寸拖拽、终端主题、字体、隐藏文件开关等一切设置都会在下次启动时原样恢复!
- ✨ **智能断线重连与状态感知**:全面增强的 SSH 网络异常处理机制,断线后直接提供美观的覆盖层用于一键重连或关闭。
- ✨ **多语言热切换增强**:深度优化的 i18n 系统,包含中文与英文在内的所有界面文案现在均支持热切换。
- ✨ **智能右键快捷操作**:新增终端右键智能复制与粘贴功能,提供专属设置项自由控制开关,并支持根据不同操作系统自适应显示原生快捷键提示。
## Run
## 下载
To run the application locally:
您可以从 [GitHub Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载 macOS、Windows 和 Linux 版本的最新预编译程序。
## Mac 安装指南
下载并解压后,请先将 `ashell.app` 拖入或移动到 **应用程序 (Applications)** 目录。
由于应用采用本地签名,初次启动时如果系统提示“App 已损坏,无法打开”,请打开终端(Terminal)并执行以下命令:
```bash
sudo xattr -cr /Applications/ashell.app
```
## 功能特性
当前版本提供了一个功能完备的 GPUI 原生工作区:
- **本地与远程会话**:支持打开本地终端标签页或通过 SSH 连接到远程服务器。
- **高级 SSH 认证**:支持基于密码和基于密钥(文件路径或内联内容)的 SSH 连接方式。
- **会话管理**:可以轻松地保存、重新打开、编辑和删除您的 SSH 会话。
- **SFTP 集成**:内置 SFTP 文件管理器,可以浏览、上传、下载以及管理远程文件。
- **强大的终端模拟器**:使用 `alacritty_terminal` 解析终端输出,支持丰富的 ANSI 颜色代码、极速渲染和完整的键盘输入转发。
- **系统遥测**:在左侧边栏实时可视化显示 CPU、内存、Swap、网络和磁盘的使用指标。
- **主题系统**:支持直接从顶部工具栏切换多种 GPUI Component 颜色主题。
- **内置字体**:开箱即用,内置 Maple Mono NF CN 字体,提供卓越的中日韩(CJK)字符与 Nerd Font 图标支持。
## 运行
在本地运行该应用:
```bash
cargo run --release
```
## Package macOS App
## 打包 macOS 应用
```bash
./scripts/package-macos-app.sh
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).
该打包脚本会创建一个标准的 `.app` 应用程序包。它没有附加 entitlements 文件,并且在签名后会验证是否不存在 `com.apple.security.app-sandbox`(这意味着它在非沙盒模式下运行)。
## License
## 协议
This project is licensed under the [GPL-3.0-or-later License](LICENSE).
本项目采用 [GPL-3.0-or-later 协议](LICENSE) 开源。
-41
View File
@@ -1,41 +0,0 @@
[English](README.md) | [中文](README.zh-CN.md)
# ashell
`ashell` 是一款现代化的、基于 GPUI Component 构建的 Rust 桌面终端客户端。
该项目旨在通过结合本地和远程环境,并内置丰富的核心功能,提供一个高性能且美观的 Shell 工作区。
## 功能特性
当前版本提供了一个功能完备的 GPUI 原生工作区:
- **本地与远程会话**:支持打开本地终端标签页或通过 SSH 连接到远程服务器。
- **高级 SSH 认证**:支持基于密码和基于密钥(文件路径或内联内容)的 SSH 连接方式。
- **会话管理**:可以轻松地保存、重新打开、编辑和删除您的 SSH 会话。
- **SFTP 集成**:内置 SFTP 文件管理器,可以浏览、上传、下载以及管理远程文件。
- **强大的终端模拟器**:使用 `alacritty_terminal` 解析终端输出,支持丰富的 ANSI 颜色代码、极速渲染和完整的键盘输入转发。
- **系统遥测**:在左侧边栏实时可视化显示 CPU、内存、Swap、网络和磁盘的使用指标。
- **主题系统**:支持直接从顶部工具栏切换多种 GPUI Component 颜色主题。
- **内置字体**:开箱即用,内置 Maple Mono NF CN 字体,提供卓越的中日韩(CJK)字符与 Nerd Font 图标支持。
## 运行
在本地运行该应用:
```bash
cargo run --release
```
## 打包 macOS 应用
```bash
./scripts/package-macos-app.sh
open target/release/ashell.app
```
该打包脚本会创建一个标准的 `.app` 应用程序包。它没有附加 entitlements 文件,并且在签名后会验证是否不存在 `com.apple.security.app-sandbox`(这意味着它在非沙盒模式下运行)。
## 协议
本项目采用 [GPL-3.0-or-later 协议](LICENSE) 开源。
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

+8
View File
@@ -0,0 +1,8 @@
fn main() {
#[cfg(windows)]
{
let mut res = winres::WindowsResource::new();
res.set_icon("assets/icons/ashell.ico");
res.compile().unwrap();
}
}
+115
View File
@@ -0,0 +1,115 @@
remote_files: "REMOTE FILES"
open_ssh_tab_sftp: "Open an SSH tab to browse SFTP files."
sftp_connecting: "sftp connecting..."
refresh: "Refresh"
upload_file: "Upload File"
upload_folder: "Upload Folder"
hidden: "Hidden"
download: "Download"
download_folder: "Download Folder"
download_count: "Download (%{count})"
name: "NAME"
size: "SIZE"
modified: "MODIFIED"
new_ssh_connection: "New SSH Connection"
create_or_edit_ssh_session: "Create or edit an SSH session"
new_folder: "New Folder"
create_folder_success: "Created folder: %{name}"
create_folder_failed: "Failed to create folder: %{err}"
delete_selected: "Delete"
confirm_delete: "Confirm Delete"
confirm_delete_desc: "Are you sure you want to delete the selected %{count} items? This action cannot be undone."
system_path_warning: "WARNING: You are attempting to delete critical system paths! This may cause system corruption!"
delete_success: "Successfully deleted %{count} items"
delete_failed: "Failed to delete: %{err}"
deleting_paths: "Deleting %{count} items..."
local_terminal: "Local Terminal"
open_local_shell_tab: "Open a new local shell tab"
connect: "Connect"
edit: "Edit"
delete: "Delete"
host: "Host"
port: "Port"
user: "User"
password: "Password"
key: "Key"
private_key_path: "Private Key Path"
private_key_data: "Private Key Data"
save_and_connect: "Save & Connect"
save: "Save"
cancel: "Cancel"
host_and_user_required: "Host and user are required"
language: "Language"
follow_system: "Follow System"
light_theme: "Light Theme"
dark_theme: "Dark Theme"
use_light_mode: "Use Light Mode"
use_dark_mode: "Use Dark Mode"
english: "English"
chinese: "简体中文"
idle_no_session: "idle / No session"
system: "SYSTEM"
cpu: "CPU"
mem: "MEM"
swap: "SWAP"
net: "NET"
disk: "DISK"
global_load: "global load"
live: "Live"
idle: "idle"
saved: "SAVED"
add_ssh: "+ ssh"
open_local_or_ssh: "Open a local terminal or pick an SSH session."
open_session: "Open Session"
settings: "Settings"
ui_font_size: "UI Font Size"
terminal_font_size: "Terminal Font Size"
system_default: "System Default"
ui_font_family: "UI Font"
terminal_font_family: "Terminal Font"
theme_management_hint: "Theme mode and light/dark theme families are managed from the top-bar theme button."
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"
preview_failed: "Preview failed: %{err}"
downloading_file: "Downloading %{base}..."
download_failed: "Download failed: %{err}"
uploading: "Uploading..."
upload_failed: "Upload failed: %{err}"
downloaded_folder: "Downloaded folder to %{path}"
downloaded_file: "Downloaded file to %{path}"
uploaded_file: "Uploaded file"
uploaded_folder: "Uploaded folder"
uploaded_n_files: "Uploaded %{files} files"
uploaded_n_folders: "Uploaded %{folders} folders"
uploaded_files_and_folders: "Uploaded %{files} files and %{folders} folders"
n_files: "%{files} files"
n_folders: "%{folders} folders"
n_files_and_folders: "%{files} files and %{folders} folders"
reset: "Reset"
reset_layout: "Reset Default Layout"
reset_layout_success: "Layout reset. Window size takes effect on restart."
transfers: "Transfers"
no_transfers_yet: "No transfers yet"
downloading: "Downloading"
paused: "Paused"
completed: "Completed"
failed: "Failed"
cancelled: "Cancelled"
session: "Session"
software_builtin: "Built-in"
retry: "Retry"
right_click_copy_paste: "Right-click Copy/Paste"
copy_paste_hint: "Tip: Regardless of this switch, you can always use %{key} + C/V to copy/paste."
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}"
+117
View File
@@ -0,0 +1,117 @@
remote_files: "远程文件"
open_ssh_tab_sftp: "打开一个 SSH 会话面板以浏览 SFTP 文件。"
sftp_connecting: "正在连接 SFTP..."
refresh: "刷新"
upload_file: "上传文件"
upload_folder: "上传文件夹"
hidden: "显示隐藏文件"
download: "下载"
download_folder: "下载文件夹"
download_count: "下载 (%{count})"
name: "名称"
size: "大小"
modified: "修改时间"
new_ssh_connection: "新建 SSH 连接"
create_or_edit_ssh_session: "创建或编辑一个 SSH 会话"
new_folder: "新建文件夹"
create_folder_success: "成功创建文件夹: %{name}"
create_folder_failed: "创建文件夹失败: %{err}"
delete_selected: "删除"
confirm_delete: "确认删除"
confirm_delete_desc: "确定要删除以下选中的 %{count} 个项吗?此操作无法撤销。"
system_path_warning: "警告:您即将删除系统关键路径!这可能会导致系统损坏!"
delete_success: "成功删除 %{count} 个项"
delete_failed: "删除失败: %{err}"
deleting_paths: "正在删除 %{count} 个项..."
cancel: "取消"
confirm: "确认"
local_terminal: "本地终端"
open_local_shell_tab: "打开一个新的本地终端页面"
connect: "连接"
edit: "编辑"
delete: "删除"
host: "主机 (Host)"
port: "端口 (Port)"
user: "用户 (User)"
password: "密码 (Password)"
key: "密钥 (Key)"
private_key_path: "私钥路径"
private_key_data: "私钥内容"
save_and_connect: "保存并连接"
save: "保存"
cancel: "取消"
host_and_user_required: "主机名和用户名不能为空"
language: "显示语言"
follow_system: "跟随系统"
light_theme: "浅色主题"
dark_theme: "深色主题"
use_light_mode: "使用浅色模式"
use_dark_mode: "使用深色模式"
english: "English"
chinese: "简体中文"
idle_no_session: "空闲 / 未连接会话"
system: "系统"
cpu: "CPU"
mem: "内存"
swap: "虚拟内存"
net: "网络"
disk: "磁盘"
global_load: "全局负载"
live: "实时"
idle: "空闲"
saved: "已保存会话"
add_ssh: "+ SSH"
open_local_or_ssh: "打开本地终端或选择一个 SSH 会话。"
open_session: "打开会话"
settings: "设置"
ui_font_size: "界面字体大小"
terminal_font_size: "终端字体大小"
system_default: "系统默认"
ui_font_family: "界面字体"
terminal_font_family: "终端字体"
theme_management_hint: "深色/浅色模式以及主题配色由顶部栏的样式按钮统一管理。"
monitoring_position: "系统监控显示位置"
position_bottom: "底部"
position_sidebar: "侧边栏"
position_hidden: "不显示"
local_host: "本地主机"
local_terminal_opened: "已打开本地终端"
connecting: "正在连接"
starting_connection: "开始连接..."
connection_failed: "连接失败"
preview_failed: "预览失败: %{err}"
downloading_file: "正在下载 %{base}..."
download_failed: "下载失败: %{err}"
uploading: "正在上传..."
upload_failed: "上传失败: %{err}"
downloaded_folder: "已下载文件夹至 %{path}"
downloaded_file: "已下载文件至 %{path}"
uploaded_file: "已上传文件"
uploaded_folder: "已上传文件夹"
uploaded_n_files: "已上传 %{files} 个文件"
uploaded_n_folders: "已上传 %{folders} 个文件夹"
uploaded_files_and_folders: "已上传 %{files} 个文件及 %{folders} 个文件夹"
n_files: "%{files} 个文件"
n_folders: "%{folders} 个文件夹"
n_files_and_folders: "%{files} 个文件及 %{folders} 个文件夹"
reset: "重置"
reset_layout: "恢复默认布局"
reset_layout_success: "布局占比已恢复默认。窗口大小将在下次启动时生效。"
transfers: "传输记录"
no_transfers_yet: "暂无传输记录"
downloading: "正在下载"
paused: "已暂停"
completed: "已完成"
failed: "失败"
cancelled: "已取消"
session: "会话"
software_builtin: "软件内置"
retry: "重试"
right_click_copy_paste: "右键复制/粘贴"
copy_paste_hint: "提示:不管是否开启此开关,都可以使用 %{key} + C/V 快捷键进行复制和粘贴。"
edit_file: "编辑文件"
edit_file_tooltip: "下载到临时目录,使用默认编辑器打开,并在保存后自动上传。"
auto_saved_and_uploaded: "已自动保存并上传: %{base}"
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

+4
View File
@@ -17,6 +17,8 @@ rm -rf "$APP_DIR"
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
cp "$ROOT_DIR/target/release/$APP_NAME" "$MACOS_DIR/$APP_NAME"
cp "$ROOT_DIR/assets/icons/ashell.icns" "$RESOURCES_DIR/ashell.icns"
cat > "$CONTENTS_DIR/Info.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
@@ -27,6 +29,8 @@ cat > "$CONTENTS_DIR/Info.plist" <<EOF
<string>en</string>
<key>CFBundleExecutable</key>
<string>$APP_NAME</string>
<key>CFBundleIconFile</key>
<string>ashell.icns</string>
<key>CFBundleIdentifier</key>
<string>$BUNDLE_ID</string>
<key>CFBundleInfoDictionaryVersion</key>
+667
View File
@@ -0,0 +1,667 @@
use std::{
cell::{Cell, RefCell},
collections::HashMap,
ops::Range,
rc::Rc,
sync::mpsc,
time::{Duration, Instant},
};
use gpui::{
AppContext as _, Bounds, Context, Entity, FocusHandle, Pixels, Point,
SharedString, Size, UniformListScrollHandle, Window, point,
px, size,
};
use gpui_component::{
Theme, ThemeMode, ThemeRegistry,
input::{InputEvent, InputState},
resizable::ResizableState,
scroll::ScrollbarHandle,
};
use rust_i18n::t;
use tokio::runtime::Runtime;
use crate::{
config::{AuthMethod, ConfigStore},
sftp::SftpHandle,
system::{SystemSampler, SystemSnapshot},
terminal::{self, BackendEvent, TabKind, TerminalTab},
ssh_terminal,
};
pub(crate) struct TerminalScrollbarState {
line_height: Pixels,
total_lines: usize,
viewport_lines: usize,
display_offset: usize,
}
#[derive(Clone, Default)]
pub(crate) struct TerminalScrollbarHandle {
state: Rc<RefCell<Option<TerminalScrollbarState>>>,
pub(crate) future_display_offset: Rc<Cell<Option<usize>>>,
}
impl TerminalScrollbarHandle {
pub(crate) fn update(&self, snapshot: &terminal::RenderSnapshot, line_height: Pixels) {
self.state.replace(Some(TerminalScrollbarState {
line_height,
total_lines: snapshot.history_size + snapshot.rows,
viewport_lines: snapshot.rows,
display_offset: snapshot.display_offset,
}));
}
}
impl ScrollbarHandle for TerminalScrollbarHandle {
fn offset(&self) -> Point<Pixels> {
let state_ref = self.state.borrow();
let Some(state) = state_ref.as_ref() else {
return point(px(0.), px(0.));
};
let scroll_offset = state
.total_lines
.saturating_sub(state.viewport_lines)
.saturating_sub(state.display_offset);
point(px(0.), -(scroll_offset as f32 * state.line_height))
}
fn set_offset(&self, offset: Point<Pixels>) {
let state_ref = self.state.borrow();
let Some(state) = state_ref.as_ref() else {
return;
};
let offset_delta = (offset.y / state.line_height).round() as i32;
let max_offset = state.total_lines.saturating_sub(state.viewport_lines);
let display_offset = (max_offset as i32 + offset_delta).clamp(0, max_offset as i32);
self.future_display_offset
.set(Some(display_offset as usize));
}
fn content_size(&self) -> Size<Pixels> {
let state_ref = self.state.borrow();
let Some(state) = state_ref.as_ref() else {
return size(px(0.), px(0.));
};
size(
px(0.),
state.total_lines.max(state.viewport_lines) as f32 * state.line_height,
)
}
}
pub(crate) struct Ashell {
pub(crate) focus_handle: FocusHandle,
pub(crate) selector_focus_handle: FocusHandle,
pub(crate) host_input: Entity<InputState>,
pub(crate) session_name_input: Entity<InputState>,
pub(crate) port_input: Entity<InputState>,
pub(crate) user_input: Entity<InputState>,
pub(crate) password_input: Entity<InputState>,
pub(crate) key_path_input: Entity<InputState>,
pub(crate) key_inline_input: Entity<InputState>,
pub(crate) sftp_path_input: Entity<InputState>,
pub(crate) ssh_auth_method: AuthMethod,
pub(crate) editing_session_id: Option<String>,
pub(crate) follow_system_theme: bool,
pub(crate) theme_mode: ThemeMode,
pub(crate) light_theme_name: SharedString,
pub(crate) dark_theme_name: SharedString,
pub(crate) ui_font_size: f32,
pub(crate) terminal_font_size: f32,
pub(crate) ui_font_family: SharedString,
pub(crate) terminal_font_family: SharedString,
pub(crate) tabs: Vec<TerminalTab>,
pub(crate) sftp_handles: HashMap<String, SftpHandle>,
pub(crate) active_tab: Option<String>,
pub(crate) selector_selection: usize,
pub(crate) workspace_panels: Entity<ResizableState>,
pub(crate) body_panels: Entity<ResizableState>,
pub(crate) terminal_scrollbar: TerminalScrollbarHandle,
pub(crate) remote_files_scroll_handle: UniformListScrollHandle,
pub(crate) disk_scroll_handle: gpui::ScrollHandle,
pub(crate) tabs_scroll_handle: gpui::ScrollHandle,
pub(crate) selector_scroll_handle: gpui::ScrollHandle,
pub(crate) saved_scroll_handle: gpui::ScrollHandle,
pub(crate) connection_progress: Option<ConnectionProgress>,
pub(crate) pending_sftp_path_sync: Option<String>,
pub(crate) sftp_context_menu: Option<SftpContextMenuState>,
pub(crate) sftp_creating_folder: bool,
pub(crate) sftp_new_folder_input: Entity<InputState>,
pub(crate) sftp_delete_scroll_handle: gpui::ScrollHandle,
pub(crate) show_hidden_files: bool,
pub(crate) transfers: Vec<crate::terminal::Transfer>,
pub(crate) show_transfers_dialog: bool,
pub(crate) system_status: Option<SharedString>,
pub(crate) terminal_bounds: Option<Bounds<Pixels>>,
pub(crate) terminal_selecting: bool,
pub(crate) terminal_marked_text: Option<String>,
pub(crate) sftp_panel_minimized: bool,
pub(crate) prev_monitoring_size: Option<Pixels>,
pub(crate) status: SharedString,
pub(crate) config: ConfigStore,
pub(crate) system_sampler: SystemSampler,
pub(crate) system: SystemSnapshot,
pub(crate) cpu_history: Vec<f32>,
pub(crate) net_rx_history: Vec<f32>,
pub(crate) net_tx_history: Vec<f32>,
pub(crate) last_system_sample: Instant,
pub(crate) last_theme_sync: Instant,
pub(crate) remote_sample_in_flight: bool,
pub(crate) runtime: Runtime,
pub(crate) events_rx: mpsc::Receiver<BackendEvent>,
pub(crate) events_tx: mpsc::Sender<BackendEvent>,
pub(crate) _subscriptions: Vec<gpui::Subscription>,
}
#[derive(Clone)]
pub(crate) enum SelectorEntry {
Local,
NewSsh,
Saved(String),
}
#[derive(Clone)]
pub(crate) struct ConnectionProgress {
pub(crate) tab_id: String,
pub(crate) title: SharedString,
pub(crate) lines: Vec<SharedString>,
pub(crate) failed: bool,
}
#[derive(Clone)]
pub(crate) struct SftpContextMenuState {
pub(crate) remote_path: String,
pub(crate) is_dir: bool,
pub(crate) position: Point<Pixels>,
}
impl Ashell {
pub(crate) fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let host_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("host")));
let session_name_input =
cx.new(|cx| InputState::new(window, cx).placeholder("name (optional)"));
let port_input = cx.new(|cx| InputState::new(window, cx).default_value("22"));
let user_input = cx.new(|cx| InputState::new(window, cx).default_value("root"));
let password_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("password"))
.masked(true)
});
let key_path_input =
cx.new(|cx| InputState::new(window, cx).placeholder("~/.ssh/id_ed25519"));
let key_inline_input = cx.new(|cx| {
InputState::new(window, cx)
.multi_line(true)
.rows(5)
.placeholder("-----BEGIN OPENSSH PRIVATE KEY-----")
});
let sftp_path_input = cx.new(|cx| InputState::new(window, cx).default_value("/"));
let sftp_new_folder_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("new_folder").to_string()));
let _subscriptions = vec![
cx.subscribe_in(&host_input, window, Self::on_input_event),
cx.subscribe_in(&session_name_input, window, Self::on_input_event),
cx.subscribe_in(&port_input, window, Self::on_input_event),
cx.subscribe_in(&user_input, window, Self::on_input_event),
cx.subscribe_in(&password_input, window, Self::on_input_event),
cx.subscribe_in(&key_path_input, window, Self::on_input_event),
cx.subscribe_in(&key_inline_input, window, Self::on_input_event),
cx.subscribe_in(&sftp_path_input, window, Self::on_input_event),
cx.subscribe_in(&sftp_new_folder_input, window, Self::on_input_event),
];
let (events_tx, events_rx) = mpsc::channel();
let workspace_panels = cx.new(|_| ResizableState::default());
let body_panels = cx.new(|_| ResizableState::default());
let mut system_sampler = SystemSampler::new();
let system = system_sampler.sample();
let default_light_theme_name = ThemeRegistry::global(cx).default_light_theme().name.clone();
let default_dark_theme_name = ThemeRegistry::global(cx).default_dark_theme().name.clone();
let config = ConfigStore::load().unwrap_or_else(|err| {
tracing::warn!("failed to load config: {err:#}");
ConfigStore::in_memory()
});
let follow_system_theme =
if config.light_theme_name().is_empty() && config.dark_theme_name().is_empty() {
true
} else {
config.follow_system_theme()
};
let theme_mode = match config.theme_mode() {
"light" => ThemeMode::Light,
"dark" => ThemeMode::Dark,
_ => ThemeMode::Light,
};
let light_theme_name = if config.light_theme_name().is_empty() {
default_light_theme_name
} else {
config.light_theme_name().into()
};
let dark_theme_name = if config.dark_theme_name().is_empty() {
default_dark_theme_name
} else {
config.dark_theme_name().into()
};
let configured_locale = config.locale();
let mut active_locale = configured_locale.to_string();
if active_locale == "system" {
active_locale = sys_locale::get_locale().unwrap_or_else(|| "en".to_string());
if active_locale.starts_with("zh") {
active_locale = "zh-CN".to_string();
} else {
active_locale = "en".to_string();
}
}
rust_i18n::set_locale(&active_locale);
gpui_component::set_locale(&active_locale);
let ui_font_family: SharedString = config.ui_font_family().into();
let terminal_font_family: SharedString = config.terminal_font_family().into();
let mut this = Self {
focus_handle: cx.focus_handle(),
selector_focus_handle: cx.focus_handle(),
host_input,
session_name_input,
port_input,
user_input,
password_input,
key_path_input,
key_inline_input,
sftp_path_input,
ssh_auth_method: AuthMethod::Password,
editing_session_id: None,
follow_system_theme,
theme_mode,
light_theme_name,
dark_theme_name,
ui_font_size: config.ui_font_size(),
terminal_font_size: config.terminal_font_size(),
ui_font_family,
terminal_font_family,
tabs: Vec::new(),
sftp_handles: HashMap::new(),
active_tab: None,
selector_selection: 0,
workspace_panels,
body_panels,
terminal_scrollbar: TerminalScrollbarHandle::default(),
remote_files_scroll_handle: UniformListScrollHandle::new(),
disk_scroll_handle: gpui::ScrollHandle::new(),
tabs_scroll_handle: gpui::ScrollHandle::new(),
selector_scroll_handle: gpui::ScrollHandle::new(),
saved_scroll_handle: gpui::ScrollHandle::new(),
connection_progress: None,
pending_sftp_path_sync: Some("/".into()),
sftp_context_menu: None,
sftp_creating_folder: false,
sftp_new_folder_input,
sftp_delete_scroll_handle: gpui::ScrollHandle::new(),
show_hidden_files: config.show_hidden_files(),
transfers: config.transfers(),
show_transfers_dialog: false,
system_status: None,
terminal_bounds: None,
terminal_selecting: false,
terminal_marked_text: None,
sftp_panel_minimized: false,
prev_monitoring_size: None,
status: "ready".into(),
config,
system_sampler,
system,
cpu_history: Vec::with_capacity(20),
net_rx_history: Vec::with_capacity(20),
net_tx_history: Vec::with_capacity(20),
last_system_sample: Instant::now(),
last_theme_sync: Instant::now(),
remote_sample_in_flight: false,
runtime: Runtime::new().expect("create tokio runtime"),
events_rx,
events_tx,
_subscriptions,
};
this.apply_theme_preferences(window, cx);
// this.open_local(cx);
this.start_event_pump(cx);
this
}
pub(crate) fn on_input_event(
&mut self,
input: &Entity<InputState>,
event: &InputEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if input == &self.sftp_path_input {
if let InputEvent::PressEnter { .. } = event {
let path = self
.sftp_path_input
.read(cx)
.text()
.to_string()
.trim()
.to_string();
self.navigate_sftp(if path.is_empty() { "/".into() } else { path }, cx);
window.prevent_default();
cx.stop_propagation();
}
} else if input == &self.sftp_new_folder_input {
match event {
InputEvent::PressEnter { .. } => {
let name = self.sftp_new_folder_input.read(cx).text().to_string();
if !name.is_empty() {
let base_path = self.sftp_path_input.read(cx).text().to_string();
let path = crate::sftp::join_remote(&base_path, &name);
if let Some(id) = self.active_tab.clone() {
if let Some(handle) = self.sftp_handles.get(&id) {
let _ = handle.commands.send(crate::sftp::SftpCommand::CreateDir(path));
}
}
}
self.sftp_creating_folder = false;
window.prevent_default();
cx.stop_propagation();
}
InputEvent::Blur => {
self.sftp_creating_folder = false;
}
_ => {}
}
}
cx.notify();
}
pub(crate) fn start_event_pump(&self, cx: &mut Context<Self>) {
cx.spawn(async move |this, cx| {
loop {
cx.background_executor()
.timer(Duration::from_millis(16))
.await;
if this
.update(cx, |this, cx| {
this.drain_backend_events();
this.sample_system_if_due();
this.sync_theme_if_due(cx);
cx.notify();
})
.is_err()
{
break;
}
}
})
.detach();
}
pub(crate) fn drain_backend_events(&mut self) {
let mut transfers_changed = false;
while let Ok(event) = self.events_rx.try_recv() {
match event {
BackendEvent::Output { tab_id, bytes } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.feed(&bytes);
}
}
BackendEvent::Status { tab_id, text } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.status = text.clone();
}
if let Some(progress) = self.connection_progress.as_mut() {
if progress.tab_id == tab_id {
progress.lines.push(text.clone().into());
}
}
self.status = text.into();
}
BackendEvent::Connected { tab_id } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.connected = true;
}
self.request_active_system_snapshot();
if self
.connection_progress
.as_ref()
.is_some_and(|progress| progress.tab_id == tab_id)
{
self.connection_progress = None;
}
}
BackendEvent::SftpEntries {
tab_id,
path,
entries,
} => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
if let Some(sftp) = tab.sftp.as_mut() {
sftp.current_path = path;
sftp.entries = entries;
if self.active_tab.as_deref() == Some(tab_id.as_str()) {
self.pending_sftp_path_sync = Some(sftp.current_path.clone());
}
}
}
}
BackendEvent::SftpPreview { tab_id, preview } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
if let Some(sftp) = tab.sftp.as_mut() {
sftp.selected_path = Some(preview.path.clone());
sftp.preview = Some(preview);
}
}
}
BackendEvent::SftpStatus { tab_id, text } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
if let Some(sftp) = tab.sftp.as_mut() {
sftp.status = text.clone();
}
}
self.status = text.into();
}
BackendEvent::RemoteSystem { tab_id, snapshot } => {
self.remote_sample_in_flight = false;
if self.active_tab.as_deref() == Some(tab_id.as_str()) {
self.system_status = None;
self.system = snapshot.clone();
self.cpu_history.push(snapshot.cpu_percent);
if self.cpu_history.len() > 20 {
self.cpu_history.remove(0);
}
self.net_rx_history.push(snapshot.net_rx_rate as f32);
if self.net_rx_history.len() > 20 {
self.net_rx_history.remove(0);
}
self.net_tx_history.push(snapshot.net_tx_rate as f32);
if self.net_tx_history.len() > 20 {
self.net_tx_history.remove(0);
}
}
}
BackendEvent::RemoteSystemUnavailable { tab_id, reason } => {
self.remote_sample_in_flight = false;
if self.active_tab.as_deref() == Some(tab_id.as_str()) {
self.system_status = Some(reason.clone().into());
self.status = reason.into();
}
}
BackendEvent::Closed { tab_id, reason } => {
self.remote_sample_in_flight = false;
let mut tab_title = None;
let mut session_label = None;
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.connected = false;
tab.status = reason.clone();
tab_title = Some(tab.title.clone());
session_label = tab.session.as_ref().map(|session| {
format!("{}@{}:{}", session.user, session.host, session.port)
});
}
if self.active_tab.as_deref() == Some(tab_id.as_str()) {
self.system_status = Some(reason.clone().into());
}
let is_graceful_exit = reason == "Terminal process exited"
|| reason == "SSH session exited";
if let Some(progress) = self.connection_progress.as_mut() {
if progress.tab_id == tab_id {
progress.lines.push(reason.clone().into());
let _ = session_label;
let _ = tab_title;
progress.title = t!("connection_failed").into();
progress.failed = true;
}
} else if let Some(_) = session_label {
if !is_graceful_exit {
self.connection_progress = Some(ConnectionProgress {
tab_id: tab_id.clone(),
title: t!("connection_failed").into(),
lines: vec![reason.clone().into()],
failed: true,
});
}
}
self.status = reason.into();
}
BackendEvent::TransferProgress {
tab_id: _,
id,
transferred,
total,
state,
} => {
if let Some(t) = self.transfers.iter_mut().find(|t| t.info.id == id) {
t.transferred = transferred;
if let Some(total) = total {
t.total = Some(total);
}
t.state = state;
transfers_changed = true;
}
}
BackendEvent::TransferStarted { tab_id, info } => {
let tab_title = self
.tabs
.iter()
.find(|t| t.id == tab_id)
.map(|t| t.title.clone())
.unwrap_or_else(|| "Unknown".to_string());
self.transfers.insert(
0,
crate::terminal::Transfer {
tab_id,
tab_title,
info,
transferred: 0,
total: None,
state: crate::terminal::TransferState::Running,
},
);
transfers_changed = true;
}
BackendEvent::SftpHome { tab_id, home } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
if let Some(sftp) = tab.sftp.as_mut() {
sftp.home_dir = home;
}
}
}
BackendEvent::TerminalTitleChanged { tab_id, title } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.title = title.clone();
}
}
}
}
if transfers_changed {
self.config.set_transfers(self.transfers.clone());
}
}
pub(crate) fn sample_system_if_due(&mut self) {
if self.last_system_sample.elapsed() >= SystemSampler::interval() {
self.last_system_sample = Instant::now();
// When an SSH tab is active with remote data flowing, don't push
// local machine data into history. Instead trigger a remote fetch
// so both the current values and history reflect the remote server.
if matches!(self.active_kind(), Some(TabKind::Ssh)) && self.system_status.is_none() {
self.request_active_system_snapshot();
return;
}
let snapshot = self.system_sampler.sample();
let cpu_usage = snapshot.cpu_percent;
self.cpu_history.push(cpu_usage);
if self.cpu_history.len() > 20 {
self.cpu_history.remove(0);
}
self.net_rx_history.push(snapshot.net_rx_rate as f32);
if self.net_rx_history.len() > 20 {
self.net_rx_history.remove(0);
}
self.net_tx_history.push(snapshot.net_tx_rate as f32);
if self.net_tx_history.len() > 20 {
self.net_tx_history.remove(0);
}
self.system = snapshot;
}
}
pub(crate) fn sync_theme_if_due(&mut self, cx: &mut Context<Self>) {
if self.follow_system_theme && self.last_theme_sync.elapsed() >= Duration::from_secs(1) {
self.last_theme_sync = Instant::now();
Theme::sync_system_appearance(None, cx);
cx.refresh_windows();
}
}
pub(crate) fn request_active_system_snapshot(&mut self) {
if let Some((tab_id, session)) = self.active_ssh_session() {
if self.remote_sample_in_flight {
return;
}
self.remote_sample_in_flight = true;
let events = self.events_tx.clone();
self.runtime.spawn(async move {
match ssh_terminal::sample_remote_system(session).await {
Ok(snapshot) => {
let _ = events.send(BackendEvent::RemoteSystem { tab_id, snapshot });
}
Err(err) => {
let _ = events.send(BackendEvent::RemoteSystemUnavailable {
tab_id,
reason: format!("remote metrics unavailable: {err:#}"),
});
}
}
});
}
}
pub(crate) fn terminal_ime_bounds_for_range(
&self,
range_utf16: Range<usize>,
element_bounds: Bounds<Pixels>,
cell_width: f32,
line_height: f32,
) -> Option<Bounds<Pixels>> {
let snapshot = self.active_snapshot()?;
let cursor = snapshot.cursor?;
let x = element_bounds.origin.x
+ px(cell_width) * cursor.col as f32
+ px(cell_width) * range_utf16.start as f32;
let y = element_bounds.origin.y
+ px(line_height) * cursor.row as f32;
Some(Bounds::new(
point(x, y),
size(
px(cell_width),
px(line_height),
),
))
}
}
+191 -1
View File
@@ -70,24 +70,93 @@ impl Session {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum SavedWindowBounds {
Fullscreen {
x: f32,
y: f32,
width: f32,
height: f32,
},
Maximized {
x: f32,
y: f32,
width: f32,
height: f32,
},
Windowed {
x: f32,
y: f32,
width: f32,
height: f32,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConfigFile {
#[serde(default)]
pub follow_system_theme: bool,
#[serde(default)]
pub theme_mode: String,
#[serde(default)]
pub light_theme_name: String,
#[serde(default)]
pub dark_theme_name: String,
#[serde(default = "default_locale")]
pub locale: String,
#[serde(default = "default_terminal_font_size")]
pub terminal_font_size: f32,
#[serde(default = "default_ui_font_size")]
pub ui_font_size: f32,
#[serde(default)]
pub right_click_copy_paste: bool,
#[serde(default = "default_ui_font_family")]
pub ui_font_family: String,
#[serde(default = "default_terminal_font_family")]
pub terminal_font_family: String,
#[serde(default)]
pub sessions: Vec<Session>,
#[serde(default)]
pub window_bounds: Option<SavedWindowBounds>,
#[serde(default)]
pub workspace_panels: Option<Vec<f32>>,
#[serde(default)]
pub body_panels: Option<Vec<f32>>,
#[serde(default)]
pub transfers: Vec<crate::terminal::Transfer>,
#[serde(default)]
pub show_hidden_files: bool,
#[serde(default = "default_monitoring_position")]
pub monitoring_position: String,
}
fn default_monitoring_position() -> String {
"Sidebar".to_string()
}
fn default_locale() -> String {
"system".to_string()
}
fn default_terminal_font_size() -> f32 {
13.0
}
fn default_ui_font_size() -> f32 {
12.0
}
pub fn default_ui_font_family() -> String {
// ".SystemUIFont" is a GPUI sentinel that resolves to the platform system UI font.
// This matches gpui-component's own Theme default.
".SystemUIFont".to_string()
}
fn default_terminal_font_family() -> String {
"Maple Mono NF CN".to_string()
}
pub struct ConfigStore {
path: PathBuf,
cache: ConfigFile,
@@ -99,6 +168,9 @@ impl ConfigStore {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create config dir {}", parent.display()))?;
let tmp_dir = parent.join("tmp");
let _ = fs::remove_dir_all(&tmp_dir);
let _ = fs::create_dir_all(&tmp_dir);
}
let cache = if path.exists() {
@@ -132,10 +204,18 @@ impl ConfigStore {
&self.cache.sessions
}
pub fn tmp_dir(&self) -> Option<PathBuf> {
self.path.parent().map(|p| p.join("tmp"))
}
pub fn follow_system_theme(&self) -> bool {
self.cache.follow_system_theme
}
pub fn theme_mode(&self) -> &str {
&self.cache.theme_mode
}
pub fn light_theme_name(&self) -> &str {
&self.cache.light_theme_name
}
@@ -144,6 +224,26 @@ impl ConfigStore {
&self.cache.dark_theme_name
}
pub fn locale(&self) -> &str {
if self.cache.locale.is_empty() {
"system"
} else {
&self.cache.locale
}
}
pub fn set_locale(&mut self, locale: &str) {
self.cache.locale = locale.to_string();
}
pub fn monitoring_position(&self) -> &str {
&self.cache.monitoring_position
}
pub fn set_monitoring_position(&mut self, pos: &str) {
self.cache.monitoring_position = pos.to_string();
}
pub fn terminal_font_size(&self) -> f32 {
if self.cache.terminal_font_size <= 0.0 {
default_terminal_font_size()
@@ -155,18 +255,107 @@ impl ConfigStore {
pub fn set_theme_preferences(
&mut self,
follow_system_theme: bool,
theme_mode: impl Into<String>,
light_theme_name: impl Into<String>,
dark_theme_name: impl Into<String>,
) {
self.cache.follow_system_theme = follow_system_theme;
self.cache.theme_mode = theme_mode.into();
self.cache.light_theme_name = light_theme_name.into();
self.cache.dark_theme_name = dark_theme_name.into();
}
pub fn window_bounds(&self) -> Option<&SavedWindowBounds> {
self.cache.window_bounds.as_ref()
}
pub fn workspace_panels(&self) -> Option<&Vec<f32>> {
self.cache.workspace_panels.as_ref()
}
#[allow(dead_code)]
pub fn body_panels(&self) -> Option<&Vec<f32>> {
self.cache.body_panels.as_ref()
}
pub fn transfers(&self) -> Vec<crate::terminal::Transfer> {
self.cache.transfers.clone()
}
pub fn set_transfers(&mut self, transfers: Vec<crate::terminal::Transfer>) {
self.cache.transfers = transfers;
if let Err(err) = self.save() {
tracing::error!("failed to save config: {err:#}");
}
}
pub fn set_layout_state(
&mut self,
window_bounds: Option<SavedWindowBounds>,
workspace_panels: Option<Vec<f32>>,
body_panels: Option<Vec<f32>>,
) {
self.cache.window_bounds = window_bounds;
self.cache.workspace_panels = workspace_panels;
self.cache.body_panels = body_panels;
}
pub fn set_terminal_font_size(&mut self, terminal_font_size: f32) {
self.cache.terminal_font_size = terminal_font_size.max(10.0);
}
pub fn ui_font_size(&self) -> f32 {
if self.cache.ui_font_size <= 0.0 {
default_ui_font_size()
} else {
self.cache.ui_font_size
}
}
pub fn set_ui_font_size(&mut self, ui_font_size: f32) {
self.cache.ui_font_size = ui_font_size.max(8.0);
}
pub fn ui_font_family(&self) -> &str {
if self.cache.ui_font_family.is_empty() {
".SystemUIFont"
} else {
&self.cache.ui_font_family
}
}
pub fn set_ui_font_family(&mut self, family: &str) {
self.cache.ui_font_family = family.to_string();
}
pub fn right_click_copy_paste(&self) -> bool {
self.cache.right_click_copy_paste
}
pub fn set_right_click_copy_paste(&mut self, val: bool) {
self.cache.right_click_copy_paste = val;
}
pub fn terminal_font_family(&self) -> &str {
if self.cache.terminal_font_family.is_empty() {
"Maple Mono NF CN"
} else {
&self.cache.terminal_font_family
}
}
pub fn set_terminal_font_family(&mut self, family: &str) {
self.cache.terminal_font_family = family.to_string();
}
pub fn show_hidden_files(&self) -> bool {
self.cache.show_hidden_files
}
pub fn set_show_hidden_files(&mut self, val: bool) {
self.cache.show_hidden_files = val;
}
pub fn get(&self, id: &str) -> Option<&Session> {
self.cache.sessions.iter().find(|s| s.id == id)
}
@@ -188,6 +377,7 @@ impl ConfigStore {
return Ok(());
}
let raw = serde_json::to_string_pretty(&self.cache)?;
fs::write(&self.path, raw).with_context(|| format!("failed to write {}", self.path.display()))
fs::write(&self.path, raw)
.with_context(|| format!("failed to write {}", self.path.display()))
}
}
+1324
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -5,7 +5,7 @@ use std::{
};
use anyhow::{Context, Result};
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use crate::terminal::{BackendCommand, BackendEvent, BackendTx};
@@ -34,7 +34,10 @@ pub fn spawn_local_terminal(
});
let mut cmd = CommandBuilder::new(&shell);
cmd.env("TERM", std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".into()));
cmd.env(
"TERM",
std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".into()),
);
cmd.env(
"COLORTERM",
std::env::var("COLORTERM").unwrap_or_else(|_| "truecolor".into()),
+129 -3142
View File
File diff suppressed because it is too large Load Diff
+570
View File
@@ -0,0 +1,570 @@
use gpui::{
App, AppContext as _, Context, Entity, KeyDownEvent, MouseButton,
MouseDownEvent, SharedString, Window, px,
};
use gpui_component::{
Theme, WindowExt as _,
input::InputState,
};
use rust_i18n::t;
use uuid::Uuid;
use crate::{
Ashell, ConnectionProgress, SelectorEntry,
config::{AuthMethod, Session},
local_terminal,
sftp,
ssh_terminal,
terminal::{BackendCommand, RenderSnapshot, TabKind, TerminalTab},
DEFAULT_COLS, DEFAULT_ROWS, SIDEBAR_WIDTH, TAB_BAR_HEIGHT, TERMINAL_PADDING_X,
TERMINAL_PADDING_Y,
};
impl Ashell {
pub(crate) fn open_local(&mut self, cx: &mut Context<Self>) {
let id = Uuid::new_v4().to_string();
match local_terminal::spawn_local_terminal(
id.clone(),
DEFAULT_COLS,
DEFAULT_ROWS,
self.events_tx.clone(),
) {
Ok(backend) => {
let title = if cfg!(windows) { "PowerShell" } else { "Local" }.to_string();
let mut tab =
TerminalTab::new_local(id.clone(), title, backend, self.events_tx.clone());
tab.resize(DEFAULT_COLS, DEFAULT_ROWS);
self.tabs.push(tab);
self.active_tab = Some(id);
self.tabs_scroll_handle.scroll_to_item(self.tabs.len() - 1);
self.status = "local terminal opened".into();
}
Err(err) => {
self.status = format!("failed to open local terminal: {err:#}").into();
}
}
cx.notify();
}
pub(crate) fn connect_ssh(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let session_name = self.session_name_input.read(cx).value().trim().to_string();
let host = self.host_input.read(cx).value().trim().to_string();
let port = self
.port_input
.read(cx)
.value()
.trim()
.parse::<u16>()
.unwrap_or(22);
let user = self.user_input.read(cx).value().trim().to_string();
let password = self.password_input.read(cx).value().to_string();
let key_path = self.key_path_input.read(cx).value().trim().to_string();
let key_inline = self.key_inline_input.read(cx).value().to_string();
if host.is_empty() || user.is_empty() {
self.status = t!("host_and_user_required").into();
cx.notify();
return;
}
let name = if session_name.is_empty() {
host.clone()
} else {
session_name
};
let existing_id = self.editing_session_id.clone();
let existing_last_used = existing_id
.as_deref()
.and_then(|id| self.config.get(id))
.and_then(|session| session.last_used.clone());
let mut session = match self.ssh_auth_method {
AuthMethod::Password => Session::password(host, port, user, password),
AuthMethod::Key => {
if key_path.is_empty() && key_inline.trim().is_empty() {
self.status = "private key path or content is required".into();
cx.notify();
return;
}
Session::key(host, port, user, key_path, key_inline)
}
};
session.name = name;
if let Some(id) = existing_id {
session.id = id;
}
session.last_used = existing_last_used;
self.config.upsert(session.clone());
if let Err(err) = self.config.save() {
tracing::warn!("failed to save config: {err:#}");
}
self.open_ssh_session(session, cx);
self.editing_session_id = None;
window.close_dialog(cx);
}
pub(crate) fn set_input_value(
input: &Entity<InputState>,
value: impl Into<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) {
input.update(cx, |state, cx| state.set_value(value, window, cx));
}
pub(crate) fn reset_ssh_form(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.editing_session_id = None;
self.ssh_auth_method = AuthMethod::Password;
Self::set_input_value(&self.session_name_input, "", window, cx);
Self::set_input_value(&self.host_input, "", window, cx);
Self::set_input_value(&self.port_input, "22", window, cx);
Self::set_input_value(&self.user_input, "root", window, cx);
Self::set_input_value(&self.password_input, "", window, cx);
Self::set_input_value(&self.key_path_input, "", window, cx);
Self::set_input_value(&self.key_inline_input, "", window, cx);
}
pub(crate) fn load_session_into_form(
&mut self,
session: &Session,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.editing_session_id = Some(session.id.clone());
self.ssh_auth_method = session.auth;
Self::set_input_value(&self.session_name_input, session.name.clone(), window, cx);
Self::set_input_value(&self.host_input, session.host.clone(), window, cx);
Self::set_input_value(&self.port_input, session.port.to_string(), window, cx);
Self::set_input_value(&self.user_input, session.user.clone(), window, cx);
Self::set_input_value(&self.password_input, session.password.clone(), window, cx);
Self::set_input_value(
&self.key_path_input,
session.private_key_path.clone(),
window,
cx,
);
Self::set_input_value(
&self.key_inline_input,
session.private_key_inline.clone(),
window,
cx,
);
}
pub(crate) fn open_new_ssh_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.reset_ssh_form(window, cx);
self.show_ssh_dialog(window, cx);
}
pub(crate) fn edit_saved_session(
&mut self,
session_id: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(session) = self.config.get(&session_id).cloned() else {
self.status = "saved session not found".into();
cx.notify();
return;
};
self.load_session_into_form(&session, window, cx);
self.show_ssh_dialog(window, cx);
}
pub(crate) fn terminal_cell_width(&self) -> f32 {
(self.terminal_font_size * 0.646).max(6.0)
}
pub(crate) fn terminal_line_height(&self) -> f32 {
(self.terminal_font_size * 1.385).max(self.terminal_font_size + 2.0)
}
pub(crate) fn change_terminal_font_size(&mut self, delta: f32, cx: &mut Context<Self>) {
self.terminal_font_size = (self.terminal_font_size + delta).clamp(10.0, 24.0);
self.config.set_terminal_font_size(self.terminal_font_size);
if let Err(err) = self.config.save() {
tracing::warn!("failed to save terminal font size: {err:#}");
}
self.status = format!("terminal font size: {:.0}px", self.terminal_font_size).into();
cx.notify();
}
pub(crate) fn change_ui_font_size(&mut self, delta: f32, cx: &mut Context<Self>) {
self.ui_font_size = (self.ui_font_size + delta).clamp(8.0, 24.0);
self.config.set_ui_font_size(self.ui_font_size);
if let Err(err) = self.config.save() {
tracing::warn!("failed to save UI font size: {err:#}");
}
Theme::global_mut(cx).font_size = px(self.ui_font_size);
self.status = format!("UI font size: {:.0}px", self.ui_font_size).into();
cx.notify();
}
pub(crate) fn change_ui_font_family(
&mut self,
family: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.ui_font_family = family.into();
self.config.set_ui_font_family(family);
if let Err(err) = self.config.save() {
tracing::warn!("failed to save UI font family: {err:#}");
}
crate::theme::set_theme_font_names(Theme::global_mut(cx), &self.ui_font_family);
cx.notify();
window.refresh();
}
pub(crate) fn change_terminal_font_family(&mut self, family: &str, cx: &mut Context<Self>) {
self.terminal_font_family = family.into();
self.config.set_terminal_font_family(family);
if let Err(err) = self.config.save() {
tracing::warn!("failed to save terminal font family: {err:#}");
}
cx.notify();
}
pub(crate) fn reset_layout(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
self.config.set_layout_state(None, None, None);
let _ = self.config.save();
self.workspace_panels = cx.new(|_| gpui_component::resizable::ResizableState::default());
self.body_panels = cx.new(|_| gpui_component::resizable::ResizableState::default());
self.status = t!("reset_layout_success").into();
cx.notify();
}
pub(crate) fn set_ssh_auth_method(&mut self, method: AuthMethod, cx: &mut Context<Self>) {
self.ssh_auth_method = method;
cx.notify();
}
pub(crate) fn connect_saved_session(&mut self, session_id: String, cx: &mut Context<Self>) {
let Some(session) = self.config.get(&session_id).cloned() else {
self.status = "saved session not found".into();
cx.notify();
return;
};
self.open_ssh_session(session, cx);
}
pub(crate) fn selector_entries(&self) -> Vec<SelectorEntry> {
let mut entries = vec![SelectorEntry::Local, SelectorEntry::NewSsh];
entries.extend(
self.config
.sessions()
.iter()
.map(|session| SelectorEntry::Saved(session.id.clone())),
);
entries
}
pub(crate) fn default_selector_index(&self) -> usize {
if self.config.sessions().is_empty() {
0
} else {
2
}
}
pub(crate) fn move_selector_selection(&mut self, delta: i32, cx: &mut Context<Self>) {
let entries = self.selector_entries();
if entries.is_empty() {
return;
}
let current = self.selector_selection.min(entries.len().saturating_sub(1)) as i32;
let next = (current + delta).clamp(0, entries.len() as i32 - 1) as usize;
if next != self.selector_selection {
self.selector_selection = next;
if next >= 2 {
self.selector_scroll_handle.scroll_to_item(next - 2);
}
cx.notify();
}
}
pub(crate) fn activate_selector_selection(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let entries = self.selector_entries();
let Some(entry) = entries.get(self.selector_selection).cloned() else {
return;
};
match entry {
SelectorEntry::Local => {
self.open_local(cx);
window.close_dialog(cx);
}
SelectorEntry::NewSsh => {
window.close_dialog(cx);
self.open_new_ssh_dialog(window, cx);
}
SelectorEntry::Saved(session_id) => {
self.connect_saved_session(session_id, cx);
window.close_dialog(cx);
}
}
}
pub(crate) fn on_selector_key_down(
&mut self,
event: &KeyDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let key = event.keystroke.key.to_ascii_lowercase();
match key.as_str() {
"up" | "arrowup" => {
self.move_selector_selection(-1, cx);
window.prevent_default();
cx.stop_propagation();
}
"down" | "arrowdown" => {
self.move_selector_selection(1, cx);
window.prevent_default();
cx.stop_propagation();
}
"enter" | "return" => {
self.activate_selector_selection(window, cx);
window.prevent_default();
cx.stop_propagation();
}
_ => {}
}
}
pub(crate) fn open_ssh_session(&mut self, session: Session, cx: &mut Context<Self>) {
let id = Uuid::new_v4().to_string();
let backend = ssh_terminal::spawn_ssh_terminal(
self.runtime.handle(),
id.clone(),
session.clone(),
DEFAULT_COLS,
DEFAULT_ROWS,
self.events_tx.clone(),
);
self.tabs.push(TerminalTab::new_ssh(
id.clone(),
&session,
backend,
self.events_tx.clone(),
));
self.active_tab = Some(id.clone());
self.tabs_scroll_handle.scroll_to_item(self.tabs.len() - 1);
if let Some(session_id) = self.active_session_id() {
if let Some(index) = self.config.sessions().iter().position(|s| s.id == session_id) {
self.saved_scroll_handle.scroll_to_item(index);
}
}
cx.notify();
let sftp_handle = sftp::spawn_sftp(
self.runtime.handle(),
id.clone(),
session,
self.events_tx.clone(),
);
self.sftp_handles.insert(id.clone(), sftp_handle);
self.active_tab = Some(id.clone());
self.pending_sftp_path_sync = Some("/".into());
self.connection_progress = Some(ConnectionProgress {
tab_id: id,
title: t!("connecting").into(),
lines: vec![t!("starting_connection").into()],
failed: false,
});
self.status = "ssh tab opened".into();
cx.notify();
}
pub(crate) fn remove_saved_session(&mut self, session_id: String, cx: &mut Context<Self>) {
self.config.remove(&session_id);
if let Err(err) = self.config.save() {
tracing::warn!("failed to save config: {err:#}");
}
self.status = "session removed".into();
cx.notify();
}
pub(crate) fn retry_connection_progress(&mut self, cx: &mut Context<Self>) {
let Some(progress) = self.connection_progress.clone() else {
return;
};
let Some(ix) = self.tabs.iter().position(|tab| tab.id == progress.tab_id) else {
self.connection_progress = None;
cx.notify();
return;
};
let Some(session) = self.tabs[ix].session.clone() else {
self.connection_progress = None;
cx.notify();
return;
};
self.tabs[ix].backend.send(BackendCommand::Close);
if let Some(handle) = self.sftp_handles.remove(&progress.tab_id) {
handle.close();
}
self.tabs.remove(ix);
if self.active_tab.as_deref() == Some(progress.tab_id.as_str()) {
self.active_tab = self
.tabs
.get(ix)
.or_else(|| self.tabs.get(ix.saturating_sub(1)))
.map(|tab| tab.id.clone());
}
self.connection_progress = None;
self.open_ssh_session(session, cx);
}
pub(crate) fn cancel_connection_progress(&mut self, cx: &mut Context<Self>) {
let Some(progress) = self.connection_progress.clone() else {
return;
};
self.connection_progress = None;
self.close_tab(progress.tab_id, cx);
}
pub(crate) fn activate_tab(&mut self, id: String, window: &mut Window, cx: &mut Context<Self>) {
if let Some(path) = self
.tabs
.iter()
.find(|tab| tab.id == id)
.and_then(|tab| tab.sftp.as_ref())
.map(|sftp| sftp.current_path.clone())
{
self.pending_sftp_path_sync = Some(path);
}
self.active_tab = Some(id.clone());
self.cpu_history.clear();
self.net_rx_history.clear();
self.net_tx_history.clear();
if let Some(index) = self.tabs.iter().position(|t| t.id == id) {
self.tabs_scroll_handle.scroll_to_item(index);
}
if self.tabs.iter().any(|t| t.id == id) {
if let Some(session_id) = self.active_session_id() {
if let Some(index) = self.config.sessions().iter().position(|s| s.id == session_id) {
self.saved_scroll_handle.scroll_to_item(index);
}
}
}
self.remote_sample_in_flight = false;
self.request_active_system_snapshot();
self.focus_handle.focus(window, cx);
cx.notify();
}
pub(crate) fn close_tab(&mut self, id: String, cx: &mut Context<Self>) {
if let Some(ix) = self.tabs.iter().position(|tab| tab.id == id) {
let was_active = self.active_tab.as_deref() == Some(id.as_str());
self.tabs[ix].backend.send(BackendCommand::Close);
if let Some(handle) = self.sftp_handles.remove(&id) {
handle.close();
}
self.tabs.remove(ix);
if was_active
|| self
.active_tab
.as_ref()
.is_some_and(|active_id| !self.tabs.iter().any(|tab| &tab.id == active_id))
{
self.active_tab = self
.tabs
.get(ix)
.or_else(|| self.tabs.get(ix.saturating_sub(1)))
.map(|tab| tab.id.clone());
self.cpu_history.clear();
self.net_rx_history.clear();
self.net_tx_history.clear();
self.remote_sample_in_flight = false;
self.request_active_system_snapshot();
}
cx.notify();
}
}
pub(crate) fn focus_terminal(
&mut self,
event: &MouseDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.focus_handle.focus(window, cx);
if event.button == MouseButton::Left {
self.begin_terminal_selection(event, cx);
}
cx.notify();
}
pub(crate) fn active_snapshot(&self) -> Option<RenderSnapshot> {
self.active_tab
.as_ref()
.and_then(|id| self.tabs.iter().find(|t| &t.id == id))
.map(TerminalTab::render_snapshot)
}
pub(crate) fn active_kind(&self) -> Option<TabKind> {
self.active_tab
.as_ref()
.and_then(|id| self.tabs.iter().find(|t| &t.id == id))
.map(|tab| tab.kind)
}
pub(crate) fn active_title(&self) -> String {
self.active_tab
.as_ref()
.and_then(|id| self.tabs.iter().find(|t| &t.id == id))
.map(|t| t.title.clone())
.unwrap_or_else(|| t!("idle_no_session").into())
}
pub(crate) fn active_ssh_session(&self) -> Option<(String, Session)> {
let active_id = self.active_tab.as_ref()?;
let tab = self.tabs.iter().find(|tab| &tab.id == active_id)?;
if !tab.connected {
return None;
}
Some((tab.id.clone(), tab.session.clone()?))
}
pub(crate) fn active_session_id(&self) -> Option<&str> {
self.active_tab
.as_ref()
.and_then(|id| self.tabs.iter().find(|tab| &tab.id == id))
.and_then(|tab| tab.session.as_ref())
.map(|session| session.id.as_str())
}
pub(crate) fn session_detail(&self, session: &Session) -> String {
format!("{}@{}:{}", session.user, session.host, session.port)
}
pub(crate) fn sync_terminal_size(&mut self, window: &Window, cx: &App) {
let viewport = window.viewport_size();
let sidebar_width = self
.workspace_panels
.read(cx)
.sizes()
.first()
.map(|size| size.as_f32())
.unwrap_or(SIDEBAR_WIDTH);
let terminal_height = self
.body_panels
.read(cx)
.sizes()
.first()
.map(|size| size.as_f32())
.unwrap_or(viewport.height.as_f32() - TAB_BAR_HEIGHT - 248.0);
let width = (viewport.width.as_f32() - sidebar_width - TERMINAL_PADDING_X - 8.0)
.max(self.terminal_cell_width());
let height = (terminal_height - TERMINAL_PADDING_Y).max(self.terminal_line_height());
let cols = (width / self.terminal_cell_width()).floor().max(1.0) as u16;
let rows = (height / self.terminal_line_height()).floor().max(1.0) as u16;
for tab in &mut self.tabs {
tab.resize(cols, rows);
}
}
}
+885 -123
View File
File diff suppressed because it is too large Load Diff
+333
View File
@@ -0,0 +1,333 @@
use gpui::{Context, PathPromptOptions, Pixels, Point, Window};
use crate::{
Ashell, SftpContextMenuState,
sftp::{RemoteEntry, SftpHandle},
terminal,
};
pub(crate) fn is_editable_text_file(filename: &str) -> bool {
let lower = filename.to_lowercase();
let ext = std::path::Path::new(&lower).extension().and_then(|s| s.to_str()).unwrap_or("");
let known_exts = ["txt", "conf", "json", "yaml", "yml", "xml", "ini", "sh", "py", "rs", "js", "ts", "html", "css", "md", "toml", "csv", "log", "cfg"];
if known_exts.contains(&ext) {
return true;
}
let known_names = ["dockerfile", "makefile", ".gitignore", ".env"];
if known_names.contains(&lower.as_str()) {
return true;
}
false
}
impl Ashell {
pub(crate) fn active_sftp(&self) -> Option<&terminal::SftpUiState> {
self.active_tab
.as_ref()
.and_then(|id| self.tabs.iter().find(|tab| &tab.id == id))
.and_then(|tab| tab.sftp.as_ref())
}
pub(crate) fn active_sftp_mut(&mut self) -> Option<&mut terminal::SftpUiState> {
let active_id = self.active_tab.clone()?;
self.tabs
.iter_mut()
.find(|tab| tab.id == active_id)
.and_then(|tab| tab.sftp.as_mut())
}
pub(crate) fn active_sftp_handle(&self) -> Option<&SftpHandle> {
self.active_tab
.as_ref()
.and_then(|id| self.sftp_handles.get(id))
}
pub(crate) fn navigate_sftp(&mut self, path: String, cx: &mut Context<Self>) {
if let Some(handle) = self.active_sftp_handle() {
handle.list_dir(path.clone());
if let Some(sftp) = self.active_sftp_mut() {
sftp.current_path = path;
self.pending_sftp_path_sync = Some(sftp.current_path.clone());
}
cx.notify();
}
}
pub(crate) fn select_sftp_entry(&mut self, entry: RemoteEntry, cx: &mut Context<Self>) {
if entry.is_dir {
self.navigate_sftp(entry.full_path, cx);
return;
}
self.mark_sftp_entry_selected(&entry.full_path, cx);
if let Some(sftp) = self.active_sftp_mut() {
if !sftp.selected_entries.remove(&entry.full_path) {
sftp.selected_entries.insert(entry.full_path);
}
}
}
pub(crate) fn mark_sftp_entry_selected(&mut self, path: &str, cx: &mut Context<Self>) {
if let Some(sftp) = self.active_sftp_mut() {
sftp.selected_path = Some(path.to_string());
}
cx.notify();
}
pub(crate) fn sftp_parent_path(path: &str) -> String {
if path == "/" {
return "/".to_string();
}
path.trim_end_matches('/')
.rsplit_once('/')
.map(|(parent, _)| {
if parent.is_empty() {
"/".to_string()
} else {
parent.to_string()
}
})
.unwrap_or_else(|| "/".to_string())
}
pub(crate) fn refresh_sftp(&mut self, cx: &mut Context<Self>) {
if let Some(path) = self.active_sftp().map(|sftp| sftp.current_path.clone()) {
self.navigate_sftp(path, cx);
}
}
pub(crate) fn sync_sftp_path_input(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(path) = self.pending_sftp_path_sync.take() else {
return;
};
self.sftp_path_input.update(cx, |state, cx| {
state.set_value(path, window, cx);
});
}
pub(crate) fn open_sftp_context_menu(
&mut self,
remote_path: String,
is_dir: bool,
position: Point<Pixels>,
cx: &mut Context<Self>,
) {
self.sftp_context_menu = Some(SftpContextMenuState {
remote_path,
is_dir,
position,
});
cx.notify();
}
pub(crate) fn dismiss_sftp_context_menu(&mut self, cx: &mut Context<Self>) {
if self.sftp_context_menu.take().is_some() {
cx.notify();
}
}
pub(crate) fn trigger_sftp_context_download(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(menu) = self.sftp_context_menu.take() else {
return;
};
self.download_sftp_entry(menu.remote_path, window, cx);
cx.notify();
}
pub(crate) fn trigger_sftp_context_edit(&mut self, cx: &mut Context<Self>) {
let Some(menu) = self.sftp_context_menu.take() else {
return;
};
if let Some(id) = self.active_tab.clone() {
if let Some(handle) = self.sftp_handles.get(&id) {
handle.edit_file(menu.remote_path);
}
}
cx.notify();
}
pub(crate) fn download_sftp_entry(
&mut self,
remote_path: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Select Download Folder".into()),
});
cx.spawn_in(window, async move |this, cx| {
match path_prompt.await {
Ok(Ok(Some(mut paths))) => {
if let Some(folder) = paths.pop() {
handle.download(remote_path, folder.to_string_lossy().to_string());
}
}
Ok(Err(err)) => {
this.update(cx, |this, cx| {
this.status = format!("download picker failed: {err}").into();
cx.notify();
})?;
}
_ => {}
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
pub(crate) fn upload_sftp_files(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
let remote_dir = self
.active_sftp()
.map(|sftp| sftp.current_path.clone())
.unwrap_or_else(|| "/".into());
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: Some("Select File to Upload".into()),
});
cx.spawn_in(window, async move |this, cx| {
match path_prompt.await {
Ok(Ok(Some(mut paths))) => {
if let Some(file) = paths.pop() {
handle.upload_paths(vec![file.to_string_lossy().to_string()], remote_dir);
}
}
Ok(Err(err)) => {
this.update(cx, |this, cx| {
this.status = format!("upload picker failed: {err}").into();
cx.notify();
})?;
}
_ => {}
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
pub(crate) fn upload_sftp_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
let remote_dir = self
.active_sftp()
.map(|sftp| sftp.current_path.clone())
.unwrap_or_else(|| "/".into());
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Select Folder to Upload".into()),
});
cx.spawn_in(window, async move |this, cx| {
match path_prompt.await {
Ok(Ok(Some(mut paths))) => {
if let Some(folder) = paths.pop() {
handle.upload_paths(vec![folder.to_string_lossy().to_string()], remote_dir);
}
}
Ok(Err(err)) => {
this.update(cx, |this, cx| {
this.status = format!("upload picker failed: {err}").into();
cx.notify();
})?;
}
_ => {}
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
pub(crate) fn toggle_sftp_entry(&mut self, path: String, checked: bool, cx: &mut Context<Self>) {
if let Some(sftp) = self.active_sftp_mut() {
if checked {
sftp.selected_entries.insert(path);
} else {
sftp.selected_entries.remove(&path);
}
cx.notify();
}
}
pub(crate) fn toggle_all_sftp_entries(&mut self, checked: bool, cx: &mut Context<Self>) {
if let Some(sftp) = self.active_sftp_mut() {
if checked {
let paths: Vec<String> = sftp.entries.iter().map(|e| e.full_path.clone()).collect();
for path in paths {
sftp.selected_entries.insert(path);
}
} else {
sftp.selected_entries.clear();
}
cx.notify();
}
}
pub(crate) fn download_selected_sftp_entries(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(sftp) = self.active_sftp() else {
return;
};
let selected: Vec<String> = sftp.selected_entries.iter().cloned().collect();
if selected.is_empty() {
return;
}
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Select Download Folder".into()),
});
cx.spawn_in(window, async move |this, cx| {
if let Ok(Ok(Some(mut paths))) = path_prompt.await {
if let Some(folder) = paths.pop() {
let local_dir = folder.to_string_lossy().to_string();
for remote in selected {
let _ = handle.commands.send(crate::sftp::SftpCommand::Download {
remote,
local_dir: local_dir.clone(),
});
}
let _ = this.update(cx, |this, cx| {
if let Some(sftp_mut) = this.active_sftp_mut() {
sftp_mut.selected_entries.clear();
}
cx.notify();
});
}
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
pub(crate) fn upload_sftp_files_batch(&mut self, paths: Vec<String>, _cx: &mut Context<Self>) {
if paths.is_empty() {
return;
}
if let Some(sftp) = self.active_sftp() {
if let Some(handle) = self.active_sftp_handle() {
let _ = handle.commands.send(crate::sftp::SftpCommand::UploadPaths {
locals: paths,
remote_dir: sftp.current_path.clone(),
});
}
}
}
}
+6 -6
View File
@@ -3,19 +3,19 @@ use std::{
sync::Arc,
};
use anyhow::{anyhow, Context, Result};
use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use directories::BaseDirs;
use russh::{
client::{self, Handler},
keys::{decode_secret_key, key::PrivateKeyWithHashAlg, load_secret_key, HashAlg, PrivateKey},
ChannelMsg, Disconnect,
client::{self, Handler},
keys::{HashAlg, PrivateKey, decode_secret_key, key::PrivateKeyWithHashAlg, load_secret_key},
};
use tokio::sync::mpsc;
use crate::{
config::{AuthMethod, Session},
system::{remote_snapshot_from_kv, SystemSnapshot},
system::{SystemSnapshot, remote_snapshot_from_kv},
terminal::{BackendCommand, BackendEvent, BackendTx},
};
@@ -365,7 +365,7 @@ EOF
echo "SWAP_USED=$(( ${swap_total:-0} - ${swap_free:-0} ))"
echo "NET_RX=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))"
echo "NET_TX=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))"
df -kP 2>/dev/null | awk "NR > 1 { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 4
df -kP 2>/dev/null | awk "NR > 1 && \$1 !~ /^(tmpfs|devtmpfs|ramfs|overlay|aufs)\$/ { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 6
exit 0
fi
@@ -404,7 +404,7 @@ EOF
echo "SWAP_USED=${swap_used:-0}"
echo "NET_RX=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))"
echo "NET_TX=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))"
df -kP 2>/dev/null | awk "NR > 1 { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 4
df -kP 2>/dev/null | awk "NR > 1 && \$1 !~ /^(devfs|tmpfs|devtmpfs|ramfs|overlay|aufs)\$/ { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 6
exit 0
fi
+54 -16
View File
@@ -1,8 +1,20 @@
use std::{collections::BTreeMap, time::{Duration, Instant}};
use std::{
collections::BTreeMap,
ffi::OsStr,
time::{Duration, Instant},
};
use anyhow::{anyhow, Result};
use anyhow::{Result, anyhow};
use sysinfo::{Disks, Networks, System};
/// Known virtual/ram filesystems to exclude from disk monitoring.
fn is_real_filesystem(fs: &OsStr) -> bool {
!matches!(
fs.to_str(),
Some("tmpfs" | "devtmpfs" | "ramfs" | "overlay" | "aufs")
)
}
#[derive(Debug, Clone, Default)]
pub struct DiskSample {
pub mount: String,
@@ -19,7 +31,10 @@ pub struct SystemSnapshot {
pub swap_detail: String,
pub net_rx: String,
pub net_tx: String,
pub net_rx_rate: u64,
pub net_tx_rate: u64,
pub disks: Vec<DiskSample>,
pub total_swap: u64,
}
pub struct SystemSampler {
@@ -69,42 +84,48 @@ impl SystemSampler {
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 now = Instant::now();
let elapsed = now.duration_since(self.last_instant).as_secs_f64().max(0.001);
let elapsed = now
.duration_since(self.last_instant)
.as_secs_f64()
.max(0.001);
let rx_rate = (rx_total.saturating_sub(self.last_rx_total) as f64 / elapsed) as u64;
let tx_rate = (tx_total.saturating_sub(self.last_tx_total) as f64 / elapsed) as u64;
self.last_rx_total = rx_total;
self.last_tx_total = tx_total;
self.last_instant = now;
let disks = self
let mut disks: Vec<DiskSample> = self
.disks
.iter()
.filter(|disk| disk.total_space() > 0)
.take(4)
.filter(|disk| disk.total_space() > 0 && is_real_filesystem(disk.file_system()))
.map(|disk| DiskSample {
mount: disk.mount_point().to_string_lossy().to_string(),
available_bytes: disk.available_space(),
total_bytes: disk.total_space(),
})
.collect();
disks.sort_by(|a, b| {
if a.mount == "/" {
return std::cmp::Ordering::Less;
}
if b.mount == "/" {
return std::cmp::Ordering::Greater;
}
a.mount.cmp(&b.mount)
});
SystemSnapshot {
cpu_percent,
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)
),
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)),
net_rx_rate: rx_rate,
net_tx_rate: tx_rate,
disks,
total_swap: swap_total,
}
}
}
@@ -178,6 +199,20 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
let rx_rate = parse_u64(&kv, "NET_RX");
let tx_rate = parse_u64(&kv, "NET_TX");
// Safety filter: exclude entries with zero/negligible total size
// (catches any virtual fs lines that slipped past the script filter)
disks.retain(|d| d.total_bytes >= 1024 * 1024);
disks.sort_by(|a, b| {
if a.mount == "/" {
return std::cmp::Ordering::Less;
}
if b.mount == "/" {
return std::cmp::Ordering::Greater;
}
a.mount.cmp(&b.mount)
});
Ok(SystemSnapshot {
cpu_percent: cpu_percent.clamp(0.0, 1.0),
mem_percent: ratio(mem_used, mem_total),
@@ -186,7 +221,10 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
swap_detail: format!("{}/{}", format_bytes(swap_used), format_bytes(swap_total)),
net_rx: format!("{}/s", format_bytes(rx_rate)),
net_tx: format!("{}/s", format_bytes(tx_rate)),
net_rx_rate: rx_rate,
net_tx_rate: tx_rate,
disks,
total_swap: swap_total,
})
}
+177 -30
View File
@@ -5,10 +5,7 @@ use alacritty_terminal::{
grid::{Dimensions, Scroll},
index::{Column, Point, Side},
selection::{Selection, SelectionRange, SelectionType},
term::{
cell::Cell,
point_to_viewport, viewport_to_point, Config, Term, TermMode,
},
term::{Config, Term, TermMode, cell::Cell, point_to_viewport, viewport_to_point},
vte::ansi::{CursorShape, Processor},
};
use gpui::Keystroke;
@@ -32,15 +29,62 @@ pub enum BackendCommand {
#[derive(Debug, Clone)]
pub enum BackendEvent {
Output { tab_id: String, bytes: Vec<u8> },
Status { tab_id: String, text: String },
Connected { tab_id: String },
SftpEntries { tab_id: String, path: String, entries: Vec<RemoteEntry> },
SftpPreview { tab_id: String, preview: PreviewData },
SftpStatus { tab_id: String, text: String },
RemoteSystem { tab_id: String, snapshot: SystemSnapshot },
RemoteSystemUnavailable { tab_id: String, reason: String },
Closed { tab_id: String, reason: String },
Output {
tab_id: String,
bytes: Vec<u8>,
},
Status {
tab_id: String,
text: String,
},
Connected {
tab_id: String,
},
SftpEntries {
tab_id: String,
path: String,
entries: Vec<RemoteEntry>,
},
SftpPreview {
tab_id: String,
preview: PreviewData,
},
SftpStatus {
tab_id: String,
text: String,
},
RemoteSystem {
tab_id: String,
snapshot: SystemSnapshot,
},
RemoteSystemUnavailable {
tab_id: String,
reason: String,
},
SftpHome {
tab_id: String,
home: String,
},
TransferProgress {
#[allow(dead_code)]
tab_id: String,
id: String,
transferred: u64,
total: Option<u64>,
state: TransferState,
},
TransferStarted {
tab_id: String,
info: TransferInfo,
},
Closed {
tab_id: String,
reason: String,
},
TerminalTitleChanged {
tab_id: String,
title: String,
},
}
#[derive(Clone)]
@@ -118,36 +162,68 @@ pub struct SftpUiState {
pub entries: Vec<RemoteEntry>,
pub selected_path: Option<String>,
pub preview: Option<PreviewData>,
pub selected_entries: std::collections::HashSet<String>,
pub home_dir: String,
}
impl TerminalTab {
pub fn new_local(id: String, title: String, backend: BackendTx) -> Self {
Self::new(id, title, TabKind::Local, "local shell".into(), backend)
pub fn new_local(
id: String,
title: String,
backend: BackendTx,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Self {
Self::new(
id,
title,
TabKind::Local,
"local shell".into(),
backend,
events,
)
}
pub fn new_ssh(id: String, session: &Session, backend: BackendTx) -> Self {
pub fn new_ssh(
id: String,
session: &Session,
backend: BackendTx,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Self {
let mut tab = Self::new(
id,
session.name.clone(),
TabKind::Ssh,
format!("connecting {}@{}:{}", session.user, session.host, session.port),
format!(
"connecting {}@{}:{}",
session.user, session.host, session.port
),
backend,
events,
);
tab.session = Some(session.clone());
tab.connected = false;
tab.sftp = Some(SftpUiState {
current_path: "/".into(),
status: "sftp connecting...".into(),
status: t!("sftp_connecting").to_string(),
entries: Vec::new(),
selected_path: None,
preview: None,
selected_entries: std::collections::HashSet::new(),
home_dir: "/".into(),
});
tab
}
fn new(id: String, title: String, kind: TabKind, status: String, backend: BackendTx) -> Self {
fn new(
id: String,
title: String,
kind: TabKind,
status: String,
backend: BackendTx,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Self {
Self {
id,
id: id.clone(),
title,
kind,
status,
@@ -155,7 +231,7 @@ impl TerminalTab {
session: None,
sftp: None,
processor: Processor::new(),
term: new_term(100, 30, backend.clone()),
term: new_term(100, 30, backend.clone(), id, events),
cols: 100,
rows: 30,
backend,
@@ -260,8 +336,11 @@ impl TerminalTab {
self.term.scroll_display(Scroll::Bottom);
}
#[allow(dead_code)]
pub fn has_selection(&self) -> bool {
self.term.selection_to_string().is_some_and(|text| !text.is_empty())
self.term
.selection_to_string()
.is_some_and(|text| !text.is_empty())
}
pub fn clear_selection(&mut self) {
@@ -269,7 +348,9 @@ impl TerminalTab {
}
pub fn selection_text(&self) -> Option<String> {
self.term.selection_to_string().filter(|text| !text.is_empty())
self.term
.selection_to_string()
.filter(|text| !text.is_empty())
}
pub fn begin_selection(
@@ -297,19 +378,25 @@ impl TerminalTab {
}
pub fn paste_text(&mut self, text: &str) {
let paste_text = text.replace('\x1b', "").replace("\r\n", "\r").replace('\n', "\r");
let paste_text = text
.replace('\x1b', "")
.replace("\r\n", "\r")
.replace('\n', "\r");
self.backend
.send(BackendCommand::Input(paste_text.into_bytes()));
}
}
fn viewport_selection_from_range(
display_offset: usize,
selection: &Option<SelectionRange>,
) -> Option<ViewportSelection> {
let SelectionRange { start, end, is_block } = selection.as_ref().copied()?;
let SelectionRange {
start,
end,
is_block,
} = selection.as_ref().copied()?;
let start = point_to_viewport(display_offset, start)?;
let end = point_to_viewport(display_offset, end)?;
@@ -324,13 +411,17 @@ fn viewport_selection_from_range(
#[derive(Clone)]
struct TerminalListener {
tab_id: String,
backend: BackendTx,
events: std::sync::mpsc::Sender<BackendEvent>,
}
impl EventListener for TerminalListener {
fn send_event(&self, event: Event) {
match event {
Event::PtyWrite(output) => self.backend.send(BackendCommand::Input(output.into_bytes())),
Event::PtyWrite(output) => self
.backend
.send(BackendCommand::Input(output.into_bytes())),
Event::TextAreaSizeRequest(format) => {
let size = alacritty_terminal::event::WindowSize {
num_lines: 30,
@@ -341,19 +432,36 @@ impl EventListener for TerminalListener {
self.backend
.send(BackendCommand::Input(format(size).into_bytes()));
}
Event::Title(title) => {
let _ = self.events.send(BackendEvent::TerminalTitleChanged {
tab_id: self.tab_id.clone(),
title,
});
}
_ => {}
}
}
}
fn new_term(cols: u16, rows: u16, backend: BackendTx) -> Term<TerminalListener> {
use rust_i18n::t;
fn new_term(
cols: u16,
rows: u16,
backend: BackendTx,
tab_id: String,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Term<TerminalListener> {
Term::new(
Config {
scrolling_history: 2000,
..Config::default()
},
&TerminalSize::new(cols, rows),
TerminalListener { backend },
TerminalListener {
tab_id,
backend,
events,
},
)
}
@@ -385,7 +493,11 @@ impl Dimensions for TerminalSize {
}
}
pub fn encode_key(keystroke: &Keystroke, app_cursor_mode: bool, option_as_meta: bool) -> Option<Vec<u8>> {
pub fn encode_key(
keystroke: &Keystroke,
app_cursor_mode: bool,
option_as_meta: bool,
) -> Option<Vec<u8>> {
zed_like_to_esc_str(keystroke, app_cursor_mode, option_as_meta)
.map(|text| text.into_owned().into_bytes())
}
@@ -555,3 +667,38 @@ fn modifier_code(keystroke: &Keystroke) -> u32 {
}
modifier_code + 1
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum TransferType {
Upload,
Download,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum TransferState {
Running,
Paused,
Completed,
Failed(String),
Cancelled,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TransferInfo {
pub id: String,
pub name: String,
pub source: String,
pub target: String,
pub kind: TransferType,
pub total_bytes: Option<u64>,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Transfer {
pub tab_id: String,
pub tab_title: String,
pub info: TransferInfo,
pub transferred: u64,
pub total: Option<u64>,
pub state: TransferState,
}
+66 -33
View File
@@ -3,9 +3,10 @@ use alacritty_terminal::{
vte::ansi::{Color as AnsiColor, CursorShape, NamedColor},
};
use gpui::{
App, Bounds, Element, ElementId, Entity, FocusHandle, Font, FontStyle, FontWeight, GlobalElementId, Hsla, InputHandler, IntoElement,
LayoutId, Pixels, Point, Rgba, StrikethroughStyle, TextRun, TextStyle, UTF16Selection, UnderlineStyle, Window, fill, point, px,
relative, rgb,
App, Bounds, Element, ElementId, Entity, FocusHandle, Font, FontStyle, FontWeight,
GlobalElementId, Hsla, InputHandler, IntoElement, LayoutId, Pixels, Point, Rgba, SharedString,
StrikethroughStyle, TextRun, TextStyle, UTF16Selection, UnderlineStyle, Window, fill, point,
px, relative, rgb,
};
use gpui_component::ActiveTheme as _;
@@ -81,7 +82,13 @@ impl BatchedTextRun {
}
}
fn paint(&self, origin: Point<Pixels>, metrics: TerminalMetrics, window: &mut Window, cx: &mut App) {
fn paint(
&self,
origin: Point<Pixels>,
metrics: TerminalMetrics,
window: &mut Window,
cx: &mut App,
) {
let pos = point(
origin.x + metrics.cell_width * self.col as f32,
origin.y + metrics.line_height * self.row as f32,
@@ -120,7 +127,7 @@ pub struct TerminalElement {
focus_handle: FocusHandle,
snapshot: RenderSnapshot,
marked_text: Option<String>,
font_family: &'static str,
font_family: SharedString,
font_size: Pixels,
line_height: Pixels,
cell_width: Pixels,
@@ -137,6 +144,8 @@ pub struct PrepaintState {
struct TerminalInputHandler {
view: Entity<Ashell>,
element_bounds: Bounds<Pixels>,
cell_width: f32,
line_height: f32,
}
impl InputHandler for TerminalInputHandler {
@@ -146,10 +155,13 @@ impl InputHandler for TerminalInputHandler {
_window: &mut Window,
cx: &mut App,
) -> Option<UTF16Selection> {
self.view.read(cx).terminal_accepts_text_input().then_some(UTF16Selection {
range: 0..0,
reversed: false,
})
self.view
.read(cx)
.terminal_accepts_text_input()
.then_some(UTF16Selection {
range: 0..0,
reversed: false,
})
}
fn marked_text_range(
@@ -209,7 +221,7 @@ impl InputHandler for TerminalInputHandler {
) -> Option<Bounds<Pixels>> {
self.view
.read(cx)
.terminal_ime_bounds_for_range(range_utf16, self.element_bounds)
.terminal_ime_bounds_for_range(range_utf16, self.element_bounds, self.cell_width, self.line_height)
}
fn character_index_for_point(
@@ -240,7 +252,7 @@ impl TerminalElement {
focus_handle: FocusHandle,
snapshot: RenderSnapshot,
marked_text: Option<String>,
font_family: &'static str,
font_family: SharedString,
font_size: Pixels,
line_height: Pixels,
cell_width: Pixels,
@@ -260,7 +272,7 @@ impl TerminalElement {
fn base_text_style(&self, cx: &App) -> TextStyle {
TextStyle {
color: cx.theme().foreground,
font_family: self.font_family.into(),
font_family: self.font_family.clone(),
font_size: self.font_size.into(),
line_height: self.line_height.into(),
..Default::default()
@@ -277,15 +289,21 @@ impl TerminalElement {
fg.a *= 0.7;
}
let underline = cell.flags.intersects(Flags::ALL_UNDERLINES).then(|| UnderlineStyle {
color: Some(fg),
thickness: px(1.0),
wavy: cell.flags.contains(Flags::UNDERCURL),
});
let strikethrough = cell.flags.contains(Flags::STRIKEOUT).then(|| StrikethroughStyle {
color: Some(fg),
thickness: px(1.0),
});
let underline = cell
.flags
.intersects(Flags::ALL_UNDERLINES)
.then(|| UnderlineStyle {
color: Some(fg),
thickness: px(1.0),
wavy: cell.flags.contains(Flags::UNDERCURL),
});
let strikethrough = cell
.flags
.contains(Flags::STRIKEOUT)
.then(|| StrikethroughStyle {
color: Some(fg),
thickness: px(1.0),
});
let weight = if cell.flags.intersects(Flags::BOLD | Flags::DIM_BOLD) {
FontWeight::BOLD
@@ -303,7 +321,7 @@ impl TerminalElement {
color: fg,
background_color: None,
font: Font {
family: self.font_family.into(),
family: self.font_family.clone(),
weight,
style,
..Font::default()
@@ -326,10 +344,9 @@ impl TerminalElement {
continue;
}
let selected = self
.snapshot
.selection
.is_some_and(|selection| selection_contains(selection, render_cell.row, render_cell.col));
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) {
rects.push(LayoutRect {
@@ -477,6 +494,8 @@ impl Element for TerminalElement {
TerminalInputHandler {
view: self.view.clone(),
element_bounds: prepaint.bounds,
cell_width: prepaint.metrics.cell_width.as_f32(),
line_height: prepaint.metrics.line_height.as_f32(),
},
cx,
);
@@ -499,7 +518,7 @@ impl Element for TerminalElement {
&[TextRun {
len: marked_text.len(),
font: Font {
family: self.font_family.into(),
family: self.font_family.clone(),
..Font::default()
},
color: base_style.color,
@@ -525,7 +544,11 @@ impl Element for TerminalElement {
}
if let Some(cursor) = prepaint.cursor {
if self.marked_text.as_ref().is_some_and(|text| !text.is_empty()) {
if self
.marked_text
.as_ref()
.is_some_and(|text| !text.is_empty())
{
return;
}
let x = prepaint.bounds.origin.x + prepaint.metrics.cell_width * cursor.col as f32;
@@ -534,7 +557,10 @@ impl Element for TerminalElement {
CursorShape::Hidden => {}
CursorShape::Beam => {
window.paint_quad(fill(
Bounds::new(point(x, y), gpui::size(px(2.), prepaint.metrics.line_height)),
Bounds::new(
point(x, y),
gpui::size(px(2.), prepaint.metrics.line_height),
),
cursor.color,
));
}
@@ -572,7 +598,10 @@ fn merge_rects(mut rects: Vec<LayoutRect>) -> Vec<LayoutRect> {
for rect in rects {
if let Some(last) = merged.last_mut() {
if last.row == rect.row && last.color == rect.color && last.col + last.cells as i32 == rect.col {
if last.row == rect.row
&& last.color == rect.color
&& last.col + last.cells as i32 == rect.col
{
last.cells += rect.cells;
continue;
}
@@ -601,7 +630,11 @@ fn selection_contains(selection: ViewportSelection, row: i32, col: i32) -> bool
}
fn is_blank(cell: &alacritty_terminal::term::cell::Cell) -> bool {
cell.c == ' ' && cell.zerowidth().is_none() && !cell.flags.intersects(Flags::ALL_UNDERLINES | Flags::STRIKEOUT)
cell.c == ' '
&& cell.zerowidth().is_none()
&& !cell
.flags
.intersects(Flags::ALL_UNDERLINES | Flags::STRIKEOUT)
}
fn is_default_bg(color: AnsiColor) -> bool {
@@ -623,8 +656,8 @@ fn color_to_hsla(color: AnsiColor, foreground: bool, cx: &App) -> Hsla {
fn ansi_index_color(index: u8, _cx: &App) -> Hsla {
const ANSI_16: [u32; 16] = [
0x1f2430, 0xff5c57, 0x5af78e, 0xf3f99d, 0x57c7ff, 0xff6ac1, 0x9aedfe, 0xf1f1f0,
0x686868, 0xff5c57, 0x5af78e, 0xf3f99d, 0x57c7ff, 0xff6ac1, 0x9aedfe, 0xffffff,
0x1f2430, 0xff5c57, 0x5af78e, 0xf3f99d, 0x57c7ff, 0xff6ac1, 0x9aedfe, 0xf1f1f0, 0x686868,
0xff5c57, 0x5af78e, 0xf3f99d, 0x57c7ff, 0xff6ac1, 0x9aedfe, 0xffffff,
];
if (index as usize) < ANSI_16.len() {
+347
View File
@@ -0,0 +1,347 @@
use std::ops::Range;
use alacritty_terminal::index::Side;
use alacritty_terminal::selection::SelectionType;
use gpui::{
ClipboardItem, Context, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent,
MouseUpEvent, Pixels, Point, ScrollDelta, ScrollWheelEvent, Window, px,
};
use crate::{
Ashell,
terminal::{BackendCommand, encode_key},
TerminalBacktabKey, TerminalTabKey,
};
impl Ashell {
pub(crate) fn on_terminal_key_down(
&mut self,
event: &KeyDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if event.keystroke.modifiers.secondary() && event.keystroke.key == "," {
self.show_settings_dialog(window, cx);
window.prevent_default();
cx.stop_propagation();
return;
}
if event.keystroke.modifiers.shift
&& event.keystroke.modifiers.secondary()
&& event.keystroke.key == "o"
{
self.show_selector_dialog(window, cx);
window.prevent_default();
cx.stop_propagation();
return;
}
if event.keystroke.modifiers.secondary() && event.keystroke.key.eq_ignore_ascii_case("c") {
if let Some(text) = self.active_terminal_selection_text() {
cx.write_to_clipboard(ClipboardItem::new_string(text));
window.prevent_default();
cx.stop_propagation();
return;
}
}
if event.keystroke.modifiers.secondary() && event.keystroke.key.eq_ignore_ascii_case("v") {
if let Some(clipboard) = cx.read_from_clipboard() {
if let Some(text) = clipboard.text() {
self.paste_into_terminal(&text, window, cx);
return;
}
}
}
if event.prefer_character_input {
if let Some(text) = event.keystroke.key_char.as_deref() {
if !text.is_empty()
&& !event.keystroke.modifiers.control
&& !event.keystroke.modifiers.function
&& !event.keystroke.modifiers.platform
{
self.send_terminal_input(text.as_bytes().to_vec(), window, cx);
}
}
return;
}
let Some(active_id) = self.active_tab.clone() else {
return;
};
let Some(tab) = self.tabs.iter_mut().find(|t| t.id == active_id) else {
return;
};
if tab.render_snapshot().display_offset > 0 {
tab.scroll_to_bottom();
}
tab.clear_selection();
if let Some(bytes) = encode_key(&event.keystroke, tab.app_cursor_mode(), false) {
tab.backend.send(BackendCommand::Input(bytes));
window.prevent_default();
cx.stop_propagation();
cx.notify();
}
}
pub(crate) fn on_terminal_tab_action(
&mut self,
_: &TerminalTabKey,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.send_terminal_input(vec![b'\t'], window, cx);
}
pub(crate) fn on_terminal_backtab_action(
&mut self,
_: &TerminalBacktabKey,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.send_terminal_input(b"\x1b[Z".to_vec(), window, cx);
}
fn send_terminal_input(&mut self, bytes: Vec<u8>, window: &mut Window, cx: &mut Context<Self>) {
let Some(active_id) = self.active_tab.clone() else {
return;
};
let Some(tab) = self.tabs.iter_mut().find(|t| t.id == active_id) else {
return;
};
if tab.render_snapshot().display_offset > 0 {
tab.scroll_to_bottom();
}
tab.clear_selection();
tab.backend.send(BackendCommand::Input(bytes));
window.prevent_default();
cx.stop_propagation();
cx.notify();
}
fn active_terminal_selection_text(&self) -> Option<String> {
let active_id = self.active_tab.as_ref()?;
self.tabs
.iter()
.find(|tab| &tab.id == active_id)
.and_then(|tab| tab.selection_text())
}
fn paste_into_terminal(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
let Some(active_id) = self.active_tab.clone() else {
return;
};
let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) else {
return;
};
if tab.render_snapshot().display_offset > 0 {
tab.scroll_to_bottom();
}
tab.clear_selection();
tab.paste_text(text);
window.prevent_default();
cx.stop_propagation();
cx.notify();
}
pub(crate) fn terminal_accepts_text_input(&self) -> bool {
self.active_tab.is_some()
}
pub(crate) fn terminal_marked_text_range(&self) -> Option<Range<usize>> {
self.terminal_marked_text
.as_ref()
.map(|text| 0..text.encode_utf16().count())
}
pub(crate) fn set_terminal_marked_text(
&mut self,
text: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.terminal_marked_text = if text.is_empty() { None } else { Some(text) };
window.invalidate_character_coordinates();
cx.notify();
}
pub(crate) fn clear_terminal_marked_text(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.terminal_marked_text.take().is_some() {
window.invalidate_character_coordinates();
cx.notify();
}
}
pub(crate) fn commit_terminal_ime_text(
&mut self,
text: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(active_id) = self.active_tab.clone() else {
return;
};
let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) else {
return;
};
if tab.render_snapshot().display_offset > 0 {
tab.scroll_to_bottom();
}
tab.clear_selection();
self.terminal_marked_text = None;
tab.backend
.send(BackendCommand::Input(text.as_bytes().to_vec()));
window.invalidate_character_coordinates();
cx.notify();
}
pub(crate) fn on_terminal_right_click(
&mut self,
_event: &MouseDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.config.right_click_copy_paste() {
return;
}
let mut handled = false;
if let Some(text) = self.active_terminal_selection_text() {
if !text.is_empty() {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
let active_id = self.active_tab.clone();
if let Some(active_id) = active_id {
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
tab.clear_selection();
}
}
cx.notify();
handled = true;
}
}
if !handled {
if let Some(clipboard_item) = cx.read_from_clipboard() {
if let Some(text) = clipboard_item.text() {
if !text.is_empty() {
self.paste_into_terminal(&text, window, cx);
}
}
}
}
}
pub(crate) fn begin_terminal_selection(&mut self, event: &MouseDownEvent, cx: &mut Context<Self>) {
let click_count = event.click_count.max(1);
let selection_type = match click_count {
1 => SelectionType::Simple,
2 => SelectionType::Semantic,
3 => SelectionType::Lines,
_ => SelectionType::Simple,
};
let Some((row, col, side)) = self.terminal_grid_point_and_side(event.position) else {
return;
};
let Some(active_id) = self.active_tab.clone() else {
return;
};
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
tab.begin_selection(row, col, side, selection_type);
self.terminal_selecting = true;
cx.notify();
}
}
pub(crate) fn on_terminal_mouse_move(
&mut self,
event: &MouseMoveEvent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.terminal_selecting || event.pressed_button != Some(MouseButton::Left) {
return;
}
let Some((row, col, side)) = self.terminal_grid_point_and_side(event.position) else {
return;
};
let Some(active_id) = self.active_tab.clone() else {
return;
};
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
tab.update_selection(row, col, side);
cx.notify();
}
}
pub(crate) fn on_terminal_mouse_up(
&mut self,
_event: &MouseUpEvent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.terminal_selecting = false;
cx.notify();
}
fn terminal_grid_point_and_side(
&self,
position: Point<Pixels>,
) -> Option<(usize, usize, Side)> {
let bounds = self.terminal_bounds?;
if !bounds.contains(&position) {
return None;
}
let local_x = (position.x - bounds.origin.x).max(px(0.));
let local_y = (position.y - bounds.origin.y).max(px(0.));
let cell_width = px(self.terminal_cell_width());
let line_height = px(self.terminal_line_height());
let snapshot = self.active_snapshot()?;
let max_col = snapshot.cols.saturating_sub(1);
let max_row = snapshot.rows.saturating_sub(1);
let col = ((local_x / cell_width).floor() as usize).min(max_col);
let row = ((local_y / line_height).floor() as usize).min(max_row);
let cell_offset_x = px(local_x.as_f32() % cell_width.as_f32());
let side = if cell_offset_x >= (cell_width / 2.) {
Side::Right
} else {
Side::Left
};
Some((row, col, side))
}
pub(crate) fn on_terminal_scroll(
&mut self,
event: &ScrollWheelEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let delta_lines = match event.delta {
ScrollDelta::Lines(point) => point.y.round() as i32,
ScrollDelta::Pixels(point) => {
(point.y.as_f32() / self.terminal_line_height()).round() as i32
}
};
if delta_lines == 0 {
return;
}
let Some(active_id) = self.active_tab.clone() else {
return;
};
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
tab.scroll_history(delta_lines);
window.prevent_default();
cx.stop_propagation();
cx.notify();
}
}
}
+210
View File
@@ -0,0 +1,210 @@
use gpui::{Anchor, Context, IntoElement, SharedString, Window, px};
use gpui_component::{
ActiveTheme as _, IconName, Sizable as _, Theme, ThemeMode, ThemeRegistry,
button::{Button, ButtonVariants as _},
menu::{DropdownMenu as _, PopupMenuItem},
};
use rust_i18n::t;
use crate::Ashell;
pub(crate) fn set_theme_font_names(theme: &mut Theme, ui_font_family: &str) {
theme.font_family = ui_font_family.into();
theme.mono_font_family = ui_font_family.into();
}
impl Ashell {
pub(crate) fn switch_theme_mode(&mut self, mode: ThemeMode, window: &mut Window, cx: &mut Context<Self>) {
self.follow_system_theme = false;
self.theme_mode = mode;
self.apply_theme_preferences(window, cx);
self.status = format!("theme mode: {}", cx.theme().mode.name()).into();
self.persist_theme_preferences();
cx.notify();
}
pub(crate) fn apply_theme(&mut self, name: SharedString, window: &mut Window, cx: &mut Context<Self>) {
let Some(theme_config) = ThemeRegistry::global(cx).themes().get(&name).cloned() else {
self.status = format!("theme not found: {name}").into();
cx.notify();
return;
};
if theme_config.mode.is_dark() {
self.dark_theme_name = name.clone();
} else {
self.light_theme_name = name.clone();
}
self.apply_theme_preferences(window, cx);
self.status = format!("theme: {name}").into();
self.persist_theme_preferences();
window.refresh();
cx.notify();
}
pub(crate) fn set_follow_system_theme(
&mut self,
follow: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.follow_system_theme = follow;
if follow {
self.status = "theme mode: system".into();
} else {
self.status = format!("theme mode: {}", cx.theme().mode.name()).into();
}
self.apply_theme_preferences(window, cx);
self.persist_theme_preferences();
cx.notify();
}
pub(crate) fn set_display_language(&mut self, locale: &str, window: &mut Window, cx: &mut Context<Self>) {
self.config.set_locale(locale);
let mut active_locale = locale.to_string();
if active_locale == "system" {
active_locale = sys_locale::get_locale().unwrap_or_else(|| "en".to_string());
if active_locale.starts_with("zh") {
active_locale = "zh-CN".to_string();
} else {
active_locale = "en".to_string();
}
}
rust_i18n::set_locale(&active_locale);
gpui_component::set_locale(&active_locale);
if let Err(err) = self.config.save() {
tracing::warn!("failed to save language preferences: {err:#}");
}
window.refresh();
cx.notify();
}
pub(crate) fn apply_theme_preferences(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let light_theme = ThemeRegistry::global(cx)
.themes()
.get(&self.light_theme_name)
.cloned()
.unwrap_or_else(|| ThemeRegistry::global(cx).default_light_theme().clone());
let dark_theme = ThemeRegistry::global(cx)
.themes()
.get(&self.dark_theme_name)
.cloned()
.unwrap_or_else(|| ThemeRegistry::global(cx).default_dark_theme().clone());
let theme = Theme::global_mut(cx);
theme.light_theme = light_theme;
theme.dark_theme = dark_theme;
theme.font_size = px(self.ui_font_size);
set_theme_font_names(theme, &self.ui_font_family);
if self.follow_system_theme {
Theme::sync_system_appearance(Some(window), cx);
} else {
Theme::change(self.theme_mode, Some(window), cx);
}
}
pub(crate) fn persist_theme_preferences(&mut self) {
let theme_mode_str = match self.theme_mode {
ThemeMode::Light => "light",
ThemeMode::Dark => "dark",
};
self.config.set_theme_preferences(
self.follow_system_theme,
theme_mode_str,
self.light_theme_name.to_string(),
self.dark_theme_name.to_string(),
);
if let Err(err) = self.config.save() {
tracing::warn!("failed to save theme preferences: {err:#}");
}
}
pub(crate) fn theme_dropdown(&self, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
let themes = ThemeRegistry::global(cx)
.sorted_themes()
.into_iter()
.cloned()
.collect::<Vec<_>>();
let light_themes = themes
.iter()
.filter(|theme| !theme.mode.is_dark())
.map(|theme| theme.name.clone())
.collect::<Vec<_>>();
let dark_themes = themes
.iter()
.filter(|theme| theme.mode.is_dark())
.map(|theme| theme.name.clone())
.collect::<Vec<_>>();
let follow_system = self.follow_system_theme;
let is_dark_mode = cx.theme().mode.is_dark();
let light_theme_name = self.light_theme_name.clone();
let dark_theme_name = self.dark_theme_name.clone();
let icon = if follow_system {
IconName::Sun
} else if is_dark_mode {
IconName::Moon
} else {
IconName::Sun
};
Button::new("theme-dropdown")
.ghost()
.small()
.icon(icon)
.dropdown_menu_with_anchor(Anchor::BottomRight, move |mut menu, window, _| {
menu = menu
.min_w(220.)
.item(
PopupMenuItem::new(t!("follow_system"))
.checked(follow_system)
.on_click(window.listener_for(&view, |this, _, window, cx| {
this.set_follow_system_theme(true, window, cx)
})),
)
.item(
PopupMenuItem::new(t!("use_light_mode"))
.checked(!follow_system && !is_dark_mode)
.on_click(window.listener_for(&view, |this, _, window, cx| {
this.switch_theme_mode(ThemeMode::Light, window, cx)
})),
)
.item(
PopupMenuItem::new(t!("use_dark_mode"))
.checked(!follow_system && is_dark_mode)
.on_click(window.listener_for(&view, |this, _, window, cx| {
this.switch_theme_mode(ThemeMode::Dark, window, cx)
})),
)
.separator()
.label(t!("light_theme").to_string());
for theme_name in light_themes.clone() {
let checked = theme_name == light_theme_name;
menu = menu.item(
PopupMenuItem::new(theme_name.clone())
.checked(checked)
.on_click(window.listener_for(&view, move |this, _, window, cx| {
this.apply_theme(theme_name.clone(), window, cx)
})),
);
}
menu = menu.separator();
menu = menu.label(t!("dark_theme").to_string());
for theme_name in dark_themes.clone() {
let checked = theme_name == dark_theme_name;
menu = menu.item(
PopupMenuItem::new(theme_name.clone())
.checked(checked)
.on_click(window.listener_for(&view, move |this, _, window, cx| {
this.apply_theme(theme_name.clone(), window, cx)
})),
);
}
menu
})
}
}
+1795
View File
File diff suppressed because it is too large Load Diff