Compare commits

...
57 Commits
Author SHA1 Message Date
TomZz c3201e7c30 chore: bump version to v0.3.11 2026-06-15 01:14:48 +08:00
TomZz 2d5062ed75 feat: implement pixel-perfect custom rendering for block elements, powerline and box drawing
- Render block elements (U+2580-U+259F), box drawing lines, and powerline characters using precise GPUI vector paths instead of fonts.

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

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

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

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

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

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

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

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

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

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

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

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

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

- Isolate auto-sync from transfer history UI.

- Clear legacy session config tmp files on application startup.
2026-06-12 07:14:55 +08:00
TomZz f1186e4c84 fix: UI/UX improvements for tabs, sidebar layout, and scrolling
- Redesign TabBar: remove bottom border and adjust height to a compact 40px.
- Fix UI auto-scrolling: TabBar, Open Session dialog, and Saved Sessions sidebar now accurately scroll selected items into view.
- Fix layout bounds for scrollbars so they render continuously and always stay visible.
- Pin sidebar headers (logo, stats) to the top while only the session list scrolls.
2026-06-12 05:32:54 +08:00
TomZz 2595cc4853 fix: prevent IME composition interrupt by only syncing theme when system appearance changes 2026-06-12 04:50:54 +08:00
TomZz 2ae28df8f8 Fix: Prevent auto-dismiss of newly opened dialogs when using keyboard navigation in Open Session selector 2026-06-12 03:38:41 +08:00
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
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
ancion 6dfa199dcb feat: add UI font config and font scale and change real time 2026-06-11 16:55:14 +08:00
26 changed files with 8500 additions and 4157 deletions
Generated
+393 -5
View File
@@ -142,6 +142,19 @@ dependencies = [
"subtle",
]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"const-random",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -264,6 +277,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "as-raw-xcb-connection"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
[[package]]
name = "as-slice"
version = "0.2.1"
@@ -284,7 +303,7 @@ dependencies = [
[[package]]
name = "ashell"
version = "0.1.0"
version = "0.3.11"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -299,6 +318,8 @@ dependencies = [
"gpui_platform",
"image",
"menu",
"notify 6.1.1",
"open",
"portable-pty",
"rfd",
"russh",
@@ -312,8 +333,10 @@ dependencies = [
"sysinfo 0.33.1",
"tar",
"thiserror 1.0.69",
"time",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
"uuid",
"walkdir",
@@ -350,9 +373,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "340e0f6bf7f9ee78549c61454f1460a3ed97c011902ee76b58301bbc6d502a32"
dependencies = [
"enumflags2",
"futures-channel",
"futures-util",
"getrandom 0.4.2",
"serde",
"serde_repr",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"zbus",
]
@@ -971,6 +999,18 @@ dependencies = [
"tracing",
]
[[package]]
name = "calloop-wayland-source"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
dependencies = [
"calloop",
"rustix 1.1.4",
"wayland-backend",
"wayland-client",
]
[[package]]
name = "cbc"
version = "0.1.2"
@@ -1228,6 +1268,26 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "convert_case"
version = "0.10.0"
@@ -1414,6 +1474,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
@@ -1583,6 +1652,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
@@ -2395,6 +2473,16 @@ dependencies = [
"zeroize",
]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix 1.1.4",
"windows-link 0.2.1",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -2696,7 +2784,7 @@ dependencies = [
"lsp-types",
"markdown",
"markup5ever_rcdom",
"notify",
"notify 7.0.0",
"num-traits",
"once_cell",
"paste",
@@ -2749,17 +2837,24 @@ dependencies = [
"accesskit",
"accesskit_unix",
"anyhow",
"as-raw-xcb-connection",
"ashpd 0.13.11",
"bitflags 2.12.1",
"bytemuck",
"calloop",
"calloop-wayland-source",
"collections",
"filedescriptor",
"futures",
"gpui",
"gpui_wgpu",
"http_client",
"image",
"itertools 0.14.0",
"libc",
"log",
"oo7",
"open",
"parking_lot",
"pathfinder_geometry",
"pollster 0.4.0",
@@ -2772,6 +2867,17 @@ dependencies = [
"url",
"util",
"uuid",
"wayland-backend",
"wayland-client",
"wayland-cursor",
"wayland-protocols",
"wayland-protocols-plasma",
"wayland-protocols-wlr",
"x11-clipboard",
"x11rb",
"xkbcommon",
"zed-scap",
"zed-xim",
]
[[package]]
@@ -2912,6 +3018,7 @@ dependencies = [
"wasm-bindgen-futures",
"web-sys",
"wgpu",
"zed-font-kit",
]
[[package]]
@@ -3458,6 +3565,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inotify"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify"
version = "0.10.2"
@@ -3547,6 +3665,25 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itertools"
version = "0.11.0"
@@ -4109,6 +4246,18 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.48.0",
]
[[package]]
name = "mio"
version = "1.2.1"
@@ -4270,6 +4419,25 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "notify"
version = "6.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
dependencies = [
"bitflags 2.12.1",
"crossbeam-channel",
"filetime",
"fsevent-sys",
"inotify 0.9.6",
"kqueue",
"libc",
"log",
"mio 0.8.11",
"walkdir",
"windows-sys 0.48.0",
]
[[package]]
name = "notify"
version = "7.0.0"
@@ -4279,11 +4447,11 @@ dependencies = [
"bitflags 2.12.1",
"filetime",
"fsevent-sys",
"inotify",
"inotify 0.10.2",
"kqueue",
"libc",
"log",
"mio",
"mio 1.2.1",
"notify-types",
"walkdir",
"windows-sys 0.52.0",
@@ -4383,6 +4551,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-derive"
version = "0.4.2"
@@ -4445,6 +4619,15 @@ dependencies = [
"libc",
]
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]]
name = "objc"
version = "0.2.7"
@@ -4707,6 +4890,17 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
dependencies = [
"is-wsl",
"libc",
"pathdiff",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
@@ -4832,6 +5026,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pathfinder_geometry"
version = "0.5.1"
@@ -5197,6 +5397,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -7124,6 +7330,12 @@ dependencies = [
"zeno",
]
[[package]]
name = "symlink"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]]
name = "syn"
version = "2.0.117"
@@ -7359,6 +7571,48 @@ dependencies = [
"zune-jpeg 0.5.15",
]
[[package]]
name = "time"
version = "0.3.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd"
dependencies = [
"deranged",
"itoa",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca"
[[package]]
name = "time-macros"
version = "0.2.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]]
name = "tiny-skia"
version = "0.11.4"
@@ -7418,7 +7672,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
"mio",
"mio 1.2.1",
"pin-project-lite",
"socket2",
"tokio-macros",
@@ -7624,6 +7878,19 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-appender"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
dependencies = [
"crossbeam-channel",
"symlink",
"thiserror 2.0.18",
"time",
"tracing-subscriber",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
@@ -7669,6 +7936,7 @@ dependencies = [
"sharded-slab",
"smallvec",
"thread_local",
"time",
"tracing",
"tracing-core",
"tracing-log",
@@ -8255,6 +8523,17 @@ dependencies = [
"wayland-scanner",
]
[[package]]
name = "wayland-cursor"
version = "0.31.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d"
dependencies = [
"rustix 1.1.4",
"wayland-client",
"xcursor",
]
[[package]]
name = "wayland-protocols"
version = "0.32.12"
@@ -8267,6 +8546,32 @@ dependencies = [
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-plasma"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91"
dependencies = [
"bitflags 2.12.1",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-wlr"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
dependencies = [
"bitflags 2.12.1",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.10"
@@ -9287,6 +9592,36 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "x11-clipboard"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3"
dependencies = [
"libc",
"x11rb",
]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"as-raw-xcb-connection",
"gethostname",
"libc",
"rustix 1.1.4",
"x11rb-protocol",
"xcursor",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xattr"
version = "0.2.3"
@@ -9318,6 +9653,46 @@ dependencies = [
"x11",
]
[[package]]
name = "xcursor"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
[[package]]
name = "xim-ctext"
version = "0.3.0"
source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8"
dependencies = [
"encoding_rs",
]
[[package]]
name = "xim-parser"
version = "0.2.1"
source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8"
dependencies = [
"bitflags 2.12.1",
]
[[package]]
name = "xkbcommon"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9"
dependencies = [
"as-raw-xcb-connection",
"libc",
"memmap2",
"xkeysym",
]
[[package]]
name = "xkeysym"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xml-rs"
version = "0.8.28"
@@ -9590,6 +9965,19 @@ dependencies = [
"workspace-hack",
]
[[package]]
name = "zed-xim"
version = "0.4.0-zed"
source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8"
dependencies = [
"ahash",
"hashbrown 0.14.5",
"log",
"x11rb",
"xim-ctext",
"xim-parser",
]
[[package]]
name = "zeno"
version = "0.3.3"
+7 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "ashell"
version = "0.1.0"
version = "0.3.11"
edition = "2024"
authors = ["ashell contributors"]
description = "A GPUI Component based SSH and local terminal client"
@@ -15,7 +15,7 @@ chrono = { version = "0.4", features = ["serde"] }
directories = "5"
flate2 = "1"
gpui = { git = "https://github.com/zed-industries/zed" }
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "runtime_shaders"] }
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "runtime_shaders", "wayland", "x11"] }
gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "ca85b5588f5feea021f1ee86cb13703dc6f30ec4" }
gpui-component-assets = { git = "https://github.com/longbridge/gpui-component", rev = "ca85b5588f5feea021f1ee86cb13703dc6f30ec4" }
menu = { git = "https://github.com/zed-industries/zed" }
@@ -34,12 +34,16 @@ tar = "0.4"
thiserror = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "net", "time", "fs"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-subscriber = { version = "0.3", features = ["env-filter", "local-time", "time"] }
uuid = { version = "1", features = ["v4", "serde"] }
walkdir = "2"
zip = { version = "2", default-features = false, features = ["deflate"] }
image = { version = "0.25.9", default-features = false, features = ["png"] }
futures = "0.3.32"
notify = "6.1.1"
open = "5.1"
time = { version = "0.3.45", features = ["formatting", "local-offset"] }
tracing-appender = "0.2.5"
[profile.release]
opt-level = 3
+10
View File
@@ -8,6 +8,16 @@
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).
+10
View File
@@ -8,6 +8,16 @@
该项目旨在通过结合本地和远程环境,并内置丰富的核心功能,提供一个高性能且美观的 Shell 工作区。
## 🚀 v0.3 版本重磅更新!
全新发布的 v0.3 迎来了架构和体验的全面升级:
- ✨ **全局实时字体设置与字号调节**(感谢 [@ancion](https://github.com/ancion) 提供的 [PR #6](https://github.com/rust-kotlin/ashell/pull/6)):支持自定义全局 UI 字体和终端渲染字体,支持实时字号调节并自动记忆。
- ✨ **SFTP 多任务并发传输队列**:引入全新的独立传输对话框,支持多文件并发上传下载,提供精准的进度展示。
- ✨ **持久化工作区记忆**:您对侧边栏的尺寸拖拽、终端主题、字体、隐藏文件开关等一切设置都会在下次启动时原样恢复!
- ✨ **智能断线重连与状态感知**:全面增强的 SSH 网络异常处理机制,断线后直接提供美观的覆盖层用于一键重连或关闭。
- ✨ **多语言热切换增强**:深度优化的 i18n 系统,包含中文与英文在内的所有界面文案现在均支持热切换。
- ✨ **智能右键快捷操作**:新增终端右键智能复制与粘贴功能,提供专属设置项自由控制开关,并支持根据不同操作系统自适应显示原生快捷键提示。
## 下载
您可以从 [GitHub Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载 macOS、Windows 和 Linux 版本的最新预编译程序。
+41 -2
View File
@@ -6,12 +6,23 @@ upload_file: "Upload File"
upload_folder: "Upload Folder"
hidden: "Hidden"
download: "Download"
download_folder: "Download Folder"
download_count: "Download (%{count})"
name: "NAME"
size: "SIZE"
modified: "MODIFIED"
new_ssh_connection: "New SSH Connection"
create_or_edit_ssh_session: "Create or edit an SSH session"
new_folder: "New Folder"
create_folder_success: "Created folder: %{name}"
create_folder_failed: "Failed to create folder: %{err}"
delete_selected: "Delete"
confirm_delete: "Confirm Delete"
confirm_delete_desc: "Are you sure you want to delete the selected %{count} items? This action cannot be undone."
system_path_warning: "WARNING: You are attempting to delete critical system paths! This may cause system corruption!"
delete_success: "Successfully deleted %{count} items"
delete_failed: "Failed to delete: %{err}"
deleting_paths: "Deleting %{count} items..."
local_terminal: "Local Terminal"
open_local_shell_tab: "Open a new local shell tab"
connect: "Connect"
@@ -51,8 +62,15 @@ 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"
theme_management_hint: "Theme mode and light/dark theme families are managed from the top-bar theme button."
system_default: "System Default"
ui_font_family: "UI Font"
terminal_font_family: "Terminal Font"
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"
@@ -76,7 +94,7 @@ n_folders: "%{folders} folders"
n_files_and_folders: "%{files} files and %{folders} folders"
reset: "Reset"
reset_layout: "Reset Default Layout"
reset_layout_success: "Layout reset. Window size takes effect on restart."
reset_layout_hint: "Reset panel layout proportion. Window size will take effect on next startup."
transfers: "Transfers"
no_transfers_yet: "No transfers yet"
@@ -86,3 +104,24 @@ 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: "Regardless of this switch, you can always use %{key} + C/V to copy/paste."
edit_file: "Edit File"
edit_file_tooltip: "Download to temporary directory, open in default editor, and auto upload on save."
auto_saved_and_uploaded: "Auto-saved & uploaded: %{base}"
scroll: "Scroll ↓"
clone: "Clone"
settings_general: "General"
settings_key_bindings: "Key Bindings"
settings_help: "Help"
settings_about: "About"
settings_group_appearance: "Appearance"
settings_group_font: "Font"
settings_group_other: "Other"
theme: "Theme"
theme_mode: "Theme Mode"
clear_transfers: "Clear History"
transfers_limit: " (Max 100 records)"
+44 -2
View File
@@ -6,12 +6,25 @@ 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: "连接"
@@ -51,8 +64,15 @@ add_ssh: "+ SSH"
open_local_or_ssh: "打开本地终端或选择一个 SSH 会话。"
open_session: "打开会话"
settings: "设置"
ui_font_size: "界面字体大小"
terminal_font_size: "终端字体大小"
theme_management_hint: "主题模式和亮/暗主题系列可通过顶栏的主题按钮管理。"
system_default: "系统默认"
ui_font_family: "界面字体"
terminal_font_family: "终端字体"
monitoring_position: "系统监控显示位置"
position_bottom: "底部"
position_sidebar: "侧边栏"
position_hidden: "不显示"
local_host: "本地主机"
local_terminal_opened: "已打开本地终端"
connecting: "正在连接"
@@ -76,7 +96,7 @@ n_folders: "%{folders} 个文件夹"
n_files_and_folders: "%{files} 个文件及 %{folders} 个文件夹"
reset: "重置"
reset_layout: "恢复默认布局"
reset_layout_success: "布局占比已恢复默认。窗口大小将在下次启动时生效。"
reset_layout_hint: "重置面板布局占比,窗口大小将在下次启动时生效"
transfers: "传输记录"
no_transfers_yet: "暂无传输记录"
@@ -86,3 +106,25 @@ 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}"
scroll: "滑动查看 ↓"
clone: "克隆"
about_feedback_hint: "如果遇到问题或者有什么建议,请前往我们的 GitHub 项目中反馈。"
settings_general: "常规"
settings_key_bindings: "快捷键"
settings_help: "帮助"
settings_about: "关于"
settings_group_appearance: "外观"
settings_group_font: "字体"
settings_group_other: "其它"
theme: "主题"
theme_mode: "主题模式"
clear_transfers: "清空记录"
transfers_limit: " (最多保留 100 条)"
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

After

Width:  |  Height:  |  Size: 279 KiB

+7
View File
@@ -0,0 +1,7 @@
pub(crate) const DEFAULT_COLS: u16 = 100;
pub(crate) const DEFAULT_ROWS: u16 = 30;
pub(crate) const SIDEBAR_WIDTH: f32 = 306.0;
pub(crate) const TAB_BAR_HEIGHT: f32 = 52.0;
pub(crate) const TERMINAL_PADDING_X: f32 = 32.0;
pub(crate) const TERMINAL_PADDING_Y: f32 = 32.0;
pub(crate) const TERMINAL_KEY_CONTEXT: &str = "AshellTerminal";
+1440
View File
File diff suppressed because it is too large Load Diff
+821
View File
@@ -0,0 +1,821 @@
pub mod constants;
pub mod dialogs;
pub mod startup;
pub mod theme;
pub mod ui;
use std::{
cell::{Cell, RefCell},
collections::HashMap,
ops::Range,
rc::Rc,
sync::mpsc,
time::{Duration, Instant},
};
use gpui::{
AppContext as _, Bounds, Context, Entity, FocusHandle, Pixels, Point,
SharedString, Size, UniformListScrollHandle, Window, point,
px, size,
};
use gpui_component::{
Theme, ThemeMode, ThemeRegistry,
input::{InputEvent, InputState},
resizable::ResizableState,
scroll::ScrollbarHandle,
};
use rust_i18n::t;
use tokio::runtime::Runtime;
use crate::{
session::config::{AuthMethod, ConfigStore},
system::{SystemSampler, SystemSnapshot},
terminal::{self, BackendEvent, TabKind, TerminalTab},
};
#[derive(Clone, Debug)]
pub(crate) enum PaneLayout {
Single(String),
Horizontal(Vec<PaneLayout>, f32), // children, split_ratio (0.0-1.0)
Vertical(Vec<PaneLayout>, f32), // children, split_ratio (0.0-1.0)
}
#[derive(Clone)]
pub(crate) struct TabGroup {
pub(crate) id: String,
pub(crate) title: String,
pub(crate) pane_root: PaneLayout,
pub(crate) sftp: Option<crate::terminal::SftpUiState>,
}
impl PaneLayout {
pub fn tab_ids(&self) -> Vec<&str> {
match self {
PaneLayout::Single(id) => vec![id.as_str()],
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
children.iter().flat_map(|c| c.tab_ids()).collect()
}
}
}
pub fn contains(&self, tab_id: &str) -> bool {
match self {
PaneLayout::Single(id) => id == tab_id,
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
children.iter().any(|c| c.contains(tab_id))
}
}
}
pub fn focused_tab_id(&self, path: &[usize]) -> Option<&str> {
match self {
PaneLayout::Single(id) if path.is_empty() => Some(id.as_str()),
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
let (&first, rest) = path.split_first()?;
children.get(first).and_then(|c| c.focused_tab_id(rest))
}
_ => None,
}
}
pub fn replace_at(&mut self, path: &[usize], replacement: PaneLayout) {
match (self, path) {
(this @ PaneLayout::Single(_), []) => *this = replacement,
(PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _), [first, rest @ ..]) => {
if let Some(child) = children.get_mut(*first) {
child.replace_at(rest, replacement);
}
}
_ => {}
}
}
pub fn remove_tab(&mut self, tab_id: &str) -> bool {
match self {
PaneLayout::Single(id) if id == tab_id => {
*self = PaneLayout::Single(String::new());
true
}
PaneLayout::Single(_) => false,
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
for child in children.iter_mut() {
child.remove_tab(tab_id);
}
children.retain(|c| !matches!(c, PaneLayout::Single(id) if id.is_empty()));
if children.is_empty() {
*self = PaneLayout::Single(String::new());
} else if children.len() == 1 {
if let Some(replacement) = children.pop() {
*self = replacement;
}
}
true
}
}
}
#[allow(dead_code)]
pub fn total_panes(&self) -> usize {
match self {
PaneLayout::Single(_) => 1,
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
children.iter().map(|c| c.total_panes()).sum()
}
}
}
}
pub(crate) struct TerminalScrollbarState {
line_height: Pixels,
total_lines: usize,
viewport_lines: usize,
display_offset: usize,
}
#[derive(Clone, Default)]
pub(crate) struct TerminalScrollbarHandle {
state: Rc<RefCell<Option<TerminalScrollbarState>>>,
pub(crate) future_display_offset: Rc<Cell<Option<usize>>>,
}
impl TerminalScrollbarHandle {
pub(crate) fn update(&self, snapshot: &terminal::RenderSnapshot, line_height: Pixels) {
self.state.replace(Some(TerminalScrollbarState {
line_height,
total_lines: snapshot.history_size + snapshot.rows,
viewport_lines: snapshot.rows,
display_offset: snapshot.display_offset,
}));
}
}
impl ScrollbarHandle for TerminalScrollbarHandle {
fn offset(&self) -> Point<Pixels> {
let state_ref = self.state.borrow();
let Some(state) = state_ref.as_ref() else {
return point(px(0.), px(0.));
};
let scroll_offset = state
.total_lines
.saturating_sub(state.viewport_lines)
.saturating_sub(state.display_offset);
point(px(0.), -(scroll_offset as f32 * state.line_height))
}
fn set_offset(&self, offset: Point<Pixels>) {
let state_ref = self.state.borrow();
let Some(state) = state_ref.as_ref() else {
return;
};
let offset_delta = (offset.y / state.line_height).round() as i32;
let max_offset = state.total_lines.saturating_sub(state.viewport_lines);
let display_offset = (max_offset as i32 + offset_delta).clamp(0, max_offset as i32);
self.future_display_offset
.set(Some(display_offset as usize));
}
fn content_size(&self) -> Size<Pixels> {
let state_ref = self.state.borrow();
let Some(state) = state_ref.as_ref() else {
return size(px(0.), px(0.));
};
size(
px(0.),
state.total_lines.max(state.viewport_lines) as f32 * state.line_height,
)
}
}
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) active_tab: Option<String>,
pub(crate) tab_groups: Vec<TabGroup>,
pub(crate) active_group: Option<String>,
pub(crate) selector_selection: usize,
pub(crate) workspace_panels: Entity<ResizableState>,
pub(crate) body_panels: Entity<ResizableState>,
pub(crate) is_layout_reset: bool,
pub(crate) terminal_scrollbars: HashMap<String, TerminalScrollbarHandle>,
pub(crate) remote_files_scroll_handle: UniformListScrollHandle,
pub(crate) disk_scroll_handle: gpui::ScrollHandle,
pub(crate) tabs_scroll_handle: gpui::ScrollHandle,
pub(crate) selector_scroll_handle: gpui::ScrollHandle,
pub(crate) saved_scroll_handle: gpui::ScrollHandle,
pub(crate) connection_scroll_handle: gpui::ScrollHandle,
pub(crate) connection_progress: Option<ConnectionProgress>,
pub(crate) pending_sftp_path_sync: Option<String>,
pub(crate) sftp_context_menu: Option<SftpContextMenuState>,
pub(crate) sftp_creating_folder: bool,
pub(crate) sftp_new_folder_input: Entity<InputState>,
pub(crate) sftp_delete_scroll_handle: gpui::ScrollHandle,
pub(crate) show_hidden_files: bool,
pub(crate) transfers: Vec<crate::terminal::Transfer>,
pub(crate) show_transfers_dialog: bool,
pub(crate) system_status: Option<SharedString>,
pub(crate) pane_root: PaneLayout,
pub(crate) focused_pane_path: Vec<usize>,
pub(crate) terminal_bounds: HashMap<String, Bounds<Pixels>>,
pub(crate) terminal_selecting: bool,
pub(crate) dragging_splitter: Option<(Vec<usize>, usize)>, // (parent_path, child_index)
pub(crate) drag_split_origin: Option<gpui::Point<Pixels>>,
pub(crate) terminal_marked_text: Option<String>,
pub(crate) sftp_panel_minimized: bool,
pub(crate) 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) system_tab_id: Option<String>,
pub(crate) sftp_handles: std::collections::HashMap<String, crate::sftp::SftpHandle>,
pub(crate) remote_sample_in_flight: bool,
pub(crate) runtime: Runtime,
pub(crate) events_rx: mpsc::Receiver<BackendEvent>,
pub(crate) events_tx: mpsc::Sender<BackendEvent>,
pub(crate) _subscriptions: Vec<gpui::Subscription>,
}
#[derive(Clone)]
pub(crate) enum SelectorEntry {
Local,
NewSsh,
Saved(String),
}
#[derive(Clone)]
pub(crate) struct ConnectionProgress {
pub(crate) tab_id: String,
pub(crate) title: SharedString,
pub(crate) lines: Vec<SharedString>,
pub(crate) failed: bool,
}
#[derive(Clone)]
pub(crate) struct SftpContextMenuState {
pub(crate) remote_path: String,
pub(crate) is_dir: bool,
pub(crate) position: Point<Pixels>,
}
impl Ashell {
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(),
active_tab: None,
tab_groups: Vec::new(),
active_group: None,
pane_root: PaneLayout::Single(String::new()),
focused_pane_path: Vec::new(),
selector_selection: 0,
workspace_panels,
body_panels,
is_layout_reset: false,
terminal_scrollbars: HashMap::new(),
remote_files_scroll_handle: UniformListScrollHandle::new(),
disk_scroll_handle: gpui::ScrollHandle::new(),
tabs_scroll_handle: gpui::ScrollHandle::new(),
selector_scroll_handle: gpui::ScrollHandle::new(),
saved_scroll_handle: gpui::ScrollHandle::new(),
connection_scroll_handle: gpui::ScrollHandle::new(),
connection_progress: None,
pending_sftp_path_sync: Some("/".into()),
sftp_context_menu: None,
sftp_creating_folder: false,
sftp_new_folder_input,
sftp_delete_scroll_handle: gpui::ScrollHandle::new(),
show_hidden_files: config.show_hidden_files(),
transfers: config.transfers(),
show_transfers_dialog: false,
system_status: None,
terminal_bounds: HashMap::new(),
terminal_selecting: false,
terminal_marked_text: None,
dragging_splitter: None,
drag_split_origin: None,
sftp_panel_minimized: false,
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(),
system_tab_id: None,
sftp_handles: std::collections::HashMap::new(),
remote_sample_in_flight: false,
runtime: Runtime::new().expect("create tokio runtime"),
events_rx,
events_tx,
_subscriptions,
};
this.apply_theme_preferences(window, cx);
// this.open_local(cx);
this.start_event_pump(cx);
this
}
pub(crate) fn on_input_event(
&mut self,
input: &Entity<InputState>,
event: &InputEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if input == &self.sftp_path_input {
if let InputEvent::PressEnter { .. } = event {
let path = self
.sftp_path_input
.read(cx)
.text()
.to_string()
.trim()
.to_string();
self.navigate_sftp(if path.is_empty() { "/".into() } else { path }, cx);
window.prevent_default();
cx.stop_propagation();
}
} else if input == &self.sftp_new_folder_input {
match event {
InputEvent::PressEnter { .. } => {
let name = self.sftp_new_folder_input.read(cx).text().to_string();
if !name.is_empty() {
let base_path = self.sftp_path_input.read(cx).text().to_string();
let path = crate::sftp::join_remote(&base_path, &name);
if let Some(handle) = self.active_sftp_handle() {
let _ = handle.commands.send(crate::sftp::SftpCommand::CreateDir(path));
}
}
self.sftp_creating_folder = false;
window.prevent_default();
cx.stop_propagation();
}
InputEvent::Blur => {
self.sftp_creating_folder = false;
}
_ => {}
}
}
cx.notify();
}
pub(crate) fn start_event_pump(&self, cx: &mut Context<Self>) {
cx.spawn(async move |this, cx| {
let mut idle_frames = 0u32;
loop {
cx.background_executor()
.timer(Duration::from_millis(16))
.await;
if this
.update(cx, |this, cx| {
let changed = this.drain_backend_events();
let system_sampled = this.sample_system_if_due();
this.sync_theme_if_due(cx);
if changed || system_sampled {
cx.notify();
idle_frames = 0;
} else {
idle_frames += 1;
if idle_frames >= 60 {
cx.notify();
idle_frames = 0;
}
}
})
.is_err()
{
break;
}
}
})
.detach();
}
pub(crate) fn drain_backend_events(&mut self) -> bool {
let mut changed = false;
let mut transfers_changed = false;
while let Ok(event) = self.events_rx.try_recv() {
changed = true;
match event {
BackendEvent::Output { tab_id, bytes } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.feed(&bytes);
}
}
BackendEvent::Status { tab_id, text } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.status = text.clone();
}
if let Some(progress) = self.connection_progress.as_mut() {
if progress.tab_id == tab_id {
progress.lines.push(text.clone().into());
let _idx = progress.lines.len().saturating_sub(1);
self.connection_scroll_handle.set_offset(point(px(0.), px(-99999.0)));
}
}
self.status = text.into();
}
BackendEvent::Connected { tab_id } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.connected = true;
}
self.sync_system_tab_to_active_group();
self.request_active_system_snapshot();
if self
.connection_progress
.as_ref()
.is_some_and(|progress| progress.tab_id == tab_id && !progress.failed)
{
self.connection_progress = None;
}
}
BackendEvent::SftpEntries {
tab_id,
path,
entries,
} => {
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == tab_id) {
if let Some(sftp) = group.sftp.as_mut() {
sftp.current_path = path;
sftp.entries = entries;
self.pending_sftp_path_sync = Some(sftp.current_path.clone());
}
}
}
BackendEvent::SftpPreview { tab_id, preview } => {
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == tab_id) {
if let Some(sftp) = group.sftp.as_mut() {
sftp.selected_path = Some(preview.path.clone());
sftp.preview = Some(preview);
}
}
}
BackendEvent::SftpStatus { tab_id, text } => {
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == tab_id) {
if let Some(sftp) = group.sftp.as_mut() {
sftp.status = text.clone();
}
}
if self.active_group.as_ref() == Some(&tab_id) {
self.status = text.into();
}
}
BackendEvent::RemoteSystem { tab_id, snapshot } => {
self.remote_sample_in_flight = false;
if self.system_tab_id.as_deref() == Some(tab_id.as_str()) {
self.system_status = None;
self.system = snapshot.clone();
self.cpu_history.push(snapshot.cpu_percent);
if self.cpu_history.len() > 20 {
self.cpu_history.remove(0);
}
self.net_rx_history.push(snapshot.net_rx_rate as f32);
if self.net_rx_history.len() > 20 {
self.net_rx_history.remove(0);
}
self.net_tx_history.push(snapshot.net_tx_rate as f32);
if self.net_tx_history.len() > 20 {
self.net_tx_history.remove(0);
}
}
}
BackendEvent::RemoteSystemUnavailable { tab_id, reason } => {
self.remote_sample_in_flight = false;
if self.system_tab_id.as_deref() == Some(tab_id.as_str()) {
self.system_status = Some(reason.clone().into());
self.status = reason.into();
}
}
BackendEvent::Closed { tab_id, reason } => {
self.remote_sample_in_flight = false;
let mut tab_title = None;
let mut session_label = None;
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.connected = false;
tab.status = reason.clone();
tab_title = Some(tab.title.clone());
session_label = tab.session.as_ref().map(|session| {
format!("{}@{}:{}", session.user, session.host, session.port)
});
}
if self.system_tab_id.as_deref() == Some(tab_id.as_str()) {
self.system_status = Some(reason.clone().into());
}
let is_graceful_exit = reason == "local shell closed"
|| reason == "ssh session closed";
// Auto-close the pane on graceful exit (e.g. user typed exit)
if is_graceful_exit {
self.handle_tab_close(tab_id.clone());
self.status = reason.into();
self.remote_sample_in_flight = false;
return changed;
}
let mut needs_new_progress = false;
if let Some(progress) = self.connection_progress.as_mut() {
if progress.tab_id == tab_id {
progress.lines.push(reason.clone().into());
let _idx = progress.lines.len().saturating_sub(1);
self.connection_scroll_handle.set_offset(point(px(0.), px(-99999.0)));
let _ = session_label;
let _ = tab_title;
progress.title = t!("connection_failed").into();
progress.failed = true;
} else if !progress.failed {
// We were showing connecting progress, but another tab dropped!
// Switch to failed state so the user can see it and retry.
progress.tab_id = tab_id.clone();
let msg = format!("{}: {}", tab_title.unwrap_or_default(), reason);
progress.lines.push(msg.into());
self.connection_scroll_handle.set_offset(point(px(0.), px(-99999.0)));
progress.title = t!("connection_failed").into();
progress.failed = true;
} else {
// Already showing a failure dialog, just append the new failure
let msg = format!("{}: {}", tab_title.unwrap_or_default(), reason);
progress.lines.push(msg.into());
self.connection_scroll_handle.set_offset(point(px(0.), px(-99999.0)));
}
} else if let Some(_) = session_label {
needs_new_progress = true;
}
if needs_new_progress && !is_graceful_exit {
self.connection_progress = Some(ConnectionProgress {
tab_id: tab_id.clone(),
title: t!("connection_failed").into(),
lines: vec![reason.clone().into()],
failed: true,
});
}
self.status = reason.into();
}
BackendEvent::TransferProgress {
tab_id: _,
id,
transferred,
total,
state,
} => {
if let Some(t) = self.transfers.iter_mut().find(|t| t.info.id == id) {
t.transferred = transferred;
if let Some(total) = total {
t.total = Some(total);
}
t.state = state;
transfers_changed = true;
}
}
BackendEvent::TransferStarted { tab_id, info } => {
let tab_title = self
.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,
},
);
if self.transfers.len() > 100 {
self.transfers.truncate(100);
}
transfers_changed = true;
}
BackendEvent::SftpHome { tab_id, home } => {
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == tab_id) {
if let Some(sftp) = group.sftp.as_mut() {
sftp.home_dir = home;
}
}
}
BackendEvent::TerminalTitleChanged { tab_id, title } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.title = title.clone();
}
}
}
}
if transfers_changed {
self.config.set_transfers(self.transfers.clone());
}
changed
}
pub(crate) fn sample_system_if_due(&mut self) -> bool {
if self.last_system_sample.elapsed() >= SystemSampler::interval() {
self.last_system_sample = Instant::now();
// Use system_tab_id (not active_tab) to decide remote vs local sampling
if let Some(ref tab_id) = self.system_tab_id.clone() {
if self.tabs.iter().any(|t| t.id == *tab_id && t.kind == TabKind::Ssh && t.connected) && self.system_status.is_none() {
self.request_active_system_snapshot();
return false;
}
}
let snapshot = self.system_sampler.sample();
let cpu_usage = snapshot.cpu_percent;
self.cpu_history.push(cpu_usage);
if self.cpu_history.len() > 20 {
self.cpu_history.remove(0);
}
self.net_rx_history.push(snapshot.net_rx_rate as f32);
if self.net_rx_history.len() > 20 {
self.net_rx_history.remove(0);
}
self.net_tx_history.push(snapshot.net_tx_rate as f32);
if self.net_tx_history.len() > 20 {
self.net_tx_history.remove(0);
}
self.system = snapshot;
return true;
}
false
}
pub(crate) fn sync_theme_if_due(&mut self, cx: &mut Context<Self>) {
if self.follow_system_theme && self.last_theme_sync.elapsed() >= Duration::from_secs(1) {
self.last_theme_sync = Instant::now();
Theme::sync_system_appearance(None, cx);
cx.refresh_windows();
}
}
pub(crate) fn request_active_system_snapshot(&mut self) {
let Some(ref tab_id) = self.system_tab_id.clone() else { return };
let Some(backend) = (|| {
let tab = self.tabs.iter().find(|t| t.id == *tab_id)?;
if !tab.connected { return None; }
Some(tab.backend.clone())
})() else { return };
if self.remote_sample_in_flight {
return;
}
self.remote_sample_in_flight = true;
backend.send(crate::terminal::BackendCommand::SampleMetrics);
}
pub(crate) fn terminal_ime_bounds_for_range(
&self,
range_utf16: Range<usize>,
element_bounds: Bounds<Pixels>,
cell_width: f32,
line_height: f32,
) -> Option<Bounds<Pixels>> {
let snapshot = self.active_snapshot()?;
let cursor = snapshot.cursor?;
let x = element_bounds.origin.x
+ px(cell_width) * cursor.col as f32
+ px(cell_width) * range_utf16.start as f32;
let y = element_bounds.origin.y
+ px(line_height) * cursor.row as f32;
Some(Bounds::new(
point(x, y),
size(
px(cell_width),
px(line_height),
),
))
}
}
+274
View File
@@ -0,0 +1,274 @@
use gpui::{App, AppContext as _, Bounds, WindowOptions, point, px, size};
use gpui_component::Root;
use crate::session::config::ConfigStore;
use crate::Ashell;
struct LocalMinutelyRoller {
dir: std::path::PathBuf,
prefix: String,
current_minute: u32,
file: Option<std::fs::File>,
}
impl LocalMinutelyRoller {
fn new(dir: std::path::PathBuf, prefix: String) -> Self {
Self { dir, prefix, current_minute: 60, file: None }
}
fn rollover(&mut self, now: chrono::DateTime<chrono::Local>) -> std::io::Result<()> {
use chrono::Timelike;
let minute = now.minute();
if self.current_minute != minute || self.file.is_none() {
let filename = format!("{}-{}.log", self.prefix, now.format("%Y-%m-%d-%H-%M"));
let path = self.dir.join(filename);
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
self.file = Some(file);
self.current_minute = minute;
// Cleanup old files keeping last 6
if let Ok(entries) = std::fs::read_dir(&self.dir) {
let mut files: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with(&self.prefix))
.collect();
files.sort_by_key(|e| e.metadata().and_then(|m| m.modified()).unwrap_or(std::time::SystemTime::UNIX_EPOCH));
if files.len() > 6 {
for file in files.iter().take(files.len() - 6) {
let _ = std::fs::remove_file(file.path());
}
}
}
}
Ok(())
}
}
impl std::io::Write for LocalMinutelyRoller {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let now = chrono::Local::now();
let _ = self.rollover(now);
if let Some(f) = &mut self.file {
f.write(buf)
} else {
Ok(buf.len())
}
}
fn flush(&mut self) -> std::io::Result<()> {
if let Some(f) = &mut self.file {
f.flush()
} else {
Ok(())
}
}
}
pub(crate) fn init_logging() {
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
let log_dir = directories::BaseDirs::new()
.map(|dirs| dirs.home_dir().join(".config").join("ashell").join("log"))
.unwrap_or_else(|| std::path::PathBuf::from("."));
std::fs::create_dir_all(&log_dir).ok();
let roller = LocalMinutelyRoller::new(log_dir.clone(), "ashell".to_string());
let (non_blocking, _guard) = tracing_appender::non_blocking(roller);
// Leak the guard so it lives for the entire duration of the app since GPUI's run might not return
std::mem::forget(_guard);
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
let stdout_layer = if cfg!(debug_assertions) {
Some(tracing_subscriber::fmt::layer().with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339()).with_target(true))
} else {
None
};
let file_layer = tracing_subscriber::fmt::layer()
.with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339())
.with_writer(non_blocking)
.with_ansi(false)
.with_target(true);
tracing_subscriber::registry()
.with(env_filter)
.with(stdout_layer)
.with(file_layer)
.init();
}
#[cfg(target_os = "macos")]
pub(crate) fn sync_macos_launch_environment() {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
let Ok(output) = std::process::Command::new(&shell).args(["-l", "-c", "env -0"]).output() else {
return;
};
if !output.status.success() {
return;
}
for entry in output.stdout.split(|b| *b == 0) {
if entry.is_empty() {
continue;
}
let Some(eq) = entry.iter().position(|b| *b == b'=') else {
continue;
};
let Ok(key) = std::str::from_utf8(&entry[..eq]) else {
continue;
};
let Ok(value) = std::str::from_utf8(&entry[eq + 1..]) else {
continue;
};
let should_import = matches!(
key,
"PATH"
| "MANPATH"
| "INFOPATH"
| "LANG"
| "LC_ALL"
| "LC_CTYPE"
| "SHELL"
| "HOME"
| "HOMEBREW_PREFIX"
| "HOMEBREW_CELLAR"
| "HOMEBREW_REPOSITORY"
) || key.starts_with("LC_");
if should_import {
unsafe {
std::env::set_var(key, value);
}
}
}
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn sync_macos_launch_environment() {}
pub(crate) fn open_main_window(cx: &mut App) {
let mut window_options = WindowOptions::default();
#[cfg(not(target_os = "macos"))]
if let Ok(img) = image::load_from_memory(include_bytes!("../../assets/icons/ashell.png")) {
window_options.icon = Some(std::sync::Arc::new(img.into_rgba8()));
}
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
if let Some(bounds) = config.window_bounds() {
window_options.window_bounds = Some(match bounds {
crate::session::config::SavedWindowBounds::Fullscreen {
x,
y,
width,
height,
} => gpui::WindowBounds::Fullscreen(Bounds::new(
point(px(*x), px(*y)),
size(px(*width), px(*height)),
)),
crate::session::config::SavedWindowBounds::Maximized {
x,
y,
width,
height,
} => gpui::WindowBounds::Maximized(Bounds::new(
point(px(*x), px(*y)),
size(px(*width), px(*height)),
)),
crate::session::config::SavedWindowBounds::Windowed {
x,
y,
width,
height,
} => gpui::WindowBounds::Windowed(Bounds::new(
point(px(*x), px(*y)),
size(px(*width), px(*height)),
)),
});
} else if let Some(display) = cx.displays().first().cloned() {
let display_bounds = display.bounds();
let width = display_bounds.size.width * 0.8;
let height = display_bounds.size.height * 0.9;
let x = display_bounds.origin.x + (display_bounds.size.width - width) / 2.0;
#[cfg(target_os = "macos")]
let y = display_bounds.origin.y;
#[cfg(not(target_os = "macos"))]
let y = display_bounds.origin.y + (display_bounds.size.height - height) / 2.0;
window_options.window_bounds = Some(gpui::WindowBounds::Windowed(Bounds::new(
point(x, y),
size(width, height),
)));
}
cx.open_window(window_options, |window, cx| {
window.activate_window();
window.set_window_title("ashell");
gpui_component::Theme::sync_system_appearance(Some(window), cx);
let view = cx.new(|cx| Ashell::new(window, cx));
tracing::info!("[ui] main application window opened");
let workspace_panels_clone = view.read(cx).workspace_panels.clone();
let body_panels_clone = view.read(cx).body_panels.clone();
let view_clone = view.clone();
window.on_window_should_close(cx, move |window: &mut gpui::Window, cx: &mut gpui::App| {
if view_clone.read(cx).is_layout_reset {
tracing::info!("[ui] layout was reset, skipping save layout state.");
return true;
}
tracing::info!("[ui] main application window closed, saving layout state...");
let mut config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
let current_bounds = window.window_bounds();
let saved_bounds = match current_bounds {
gpui::WindowBounds::Fullscreen(b) => crate::session::config::SavedWindowBounds::Fullscreen {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
},
gpui::WindowBounds::Maximized(b) => crate::session::config::SavedWindowBounds::Maximized {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
},
gpui::WindowBounds::Windowed(b) => crate::session::config::SavedWindowBounds::Windowed {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
},
};
let workspace_sizes: Vec<f32> = workspace_panels_clone
.read(cx)
.sizes()
.iter()
.map(|s| s.into())
.collect();
let body_sizes: Vec<f32> = body_panels_clone
.read(cx)
.sizes()
.iter()
.map(|s| s.into())
.collect();
config.set_layout_state(Some(saved_bounds), Some(workspace_sizes), Some(body_sizes));
let _ = config.save();
true
});
cx.new(|cx| Root::new(view, window, cx))
})
.expect("failed to open window");
}
+148
View File
@@ -0,0 +1,148 @@
use anyhow::{Context as _, Result};
use gpui::{App, Context, SharedString, Window, px};
use gpui_component::{
ActiveTheme as _, Theme, ThemeMode, ThemeRegistry,
};
use crate::Ashell;
pub(crate) const EMBEDDED_THEME_JSONS: &[&str] = &[
include_str!("../../assets/themes/matrix.json"),
include_str!("../../assets/themes/tokyonight.json"),
include_str!("../../assets/themes/gruvbox.json"),
include_str!("../../assets/themes/solarized.json"),
];
pub(crate) fn load_fonts(cx: &mut App) -> Result<()> {
let regular =
std::borrow::Cow::Borrowed(include_bytes!("../../assets/fonts/MapleMono-NF-CN-Regular.ttf").as_slice());
let bold = std::borrow::Cow::Borrowed(include_bytes!("../../assets/fonts/MapleMono-NF-CN-Bold.ttf").as_slice());
cx.text_system()
.add_fonts(vec![regular, bold])
.context("load Maple Mono NF CN fonts")?;
set_theme_font_names(cx.global_mut::<Theme>(), ".SystemUIFont");
Ok(())
}
pub(crate) fn load_embedded_themes(cx: &mut App) {
let registry = ThemeRegistry::global_mut(cx);
for theme_json in EMBEDDED_THEME_JSONS {
if let Err(err) = registry.load_themes_from_str(theme_json) {
tracing::warn!("failed to load embedded theme: {err:#}");
}
}
}
pub(crate) fn set_theme_font_names(theme: &mut Theme, ui_font_family: &str) {
theme.font_family = ui_font_family.into();
theme.mono_font_family = ui_font_family.into();
}
impl Ashell {
pub(crate) fn switch_theme_mode(&mut self, mode: ThemeMode, window: &mut Window, cx: &mut Context<Self>) {
self.follow_system_theme = false;
self.theme_mode = mode;
self.apply_theme_preferences(window, cx);
self.status = format!("theme mode: {}", cx.theme().mode.name()).into();
self.persist_theme_preferences();
cx.notify();
}
pub(crate) fn apply_theme(&mut self, name: SharedString, window: &mut Window, cx: &mut Context<Self>) {
let Some(theme_config) = ThemeRegistry::global(cx).themes().get(&name).cloned() else {
self.status = format!("theme not found: {name}").into();
cx.notify();
return;
};
if theme_config.mode.is_dark() {
self.dark_theme_name = name.clone();
} else {
self.light_theme_name = name.clone();
}
self.apply_theme_preferences(window, cx);
self.status = format!("theme: {name}").into();
self.persist_theme_preferences();
window.refresh();
cx.notify();
}
pub(crate) fn set_follow_system_theme(
&mut self,
follow: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.follow_system_theme = follow;
if follow {
self.status = "theme mode: system".into();
} else {
self.status = format!("theme mode: {}", cx.theme().mode.name()).into();
}
self.apply_theme_preferences(window, cx);
self.persist_theme_preferences();
cx.notify();
}
pub(crate) fn set_display_language(&mut self, locale: &str, window: &mut Window, cx: &mut Context<Self>) {
self.config.set_locale(locale);
let mut active_locale = locale.to_string();
if active_locale == "system" {
active_locale = sys_locale::get_locale().unwrap_or_else(|| "en".to_string());
if active_locale.starts_with("zh") {
active_locale = "zh-CN".to_string();
} else {
active_locale = "en".to_string();
}
}
rust_i18n::set_locale(&active_locale);
gpui_component::set_locale(&active_locale);
if let Err(err) = self.config.save() {
tracing::warn!("failed to save language preferences: {err:#}");
}
window.refresh();
cx.notify();
}
pub(crate) fn apply_theme_preferences(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let light_theme = ThemeRegistry::global(cx)
.themes()
.get(&self.light_theme_name)
.cloned()
.unwrap_or_else(|| ThemeRegistry::global(cx).default_light_theme().clone());
let dark_theme = ThemeRegistry::global(cx)
.themes()
.get(&self.dark_theme_name)
.cloned()
.unwrap_or_else(|| ThemeRegistry::global(cx).default_dark_theme().clone());
let theme = Theme::global_mut(cx);
theme.light_theme = light_theme;
theme.dark_theme = dark_theme;
theme.font_size = px(self.ui_font_size);
set_theme_font_names(theme, &self.ui_font_family);
if self.follow_system_theme {
Theme::sync_system_appearance(Some(window), cx);
} else {
Theme::change(self.theme_mode, Some(window), cx);
}
}
pub(crate) fn persist_theme_preferences(&mut self) {
let theme_mode_str = match self.theme_mode {
ThemeMode::Light => "light",
ThemeMode::Dark => "dark",
};
self.config.set_theme_preferences(
self.follow_system_theme,
theme_mode_str,
self.light_theme_name.to_string(),
self.dark_theme_name.to_string(),
);
if let Err(err) = self.config.save() {
tracing::warn!("failed to save theme preferences: {err:#}");
}
}
}
+2097
View File
File diff suppressed because it is too large Load Diff
+30 -17
View File
@@ -94,27 +94,40 @@ pub fn spawn_local_terminal(
let write_tab = tab_id.clone();
let write_events = events.clone();
thread::spawn(move || {
while let Ok(command) = cmd_rx.recv() {
match command {
BackendCommand::Input(bytes) => {
if let Err(err) = writer.write_all(&bytes) {
loop {
match cmd_rx.recv_timeout(std::time::Duration::from_millis(100)) {
Ok(command) => match command {
BackendCommand::Input(bytes) => {
if let Err(err) = writer.write_all(&bytes) {
let _ = write_events.send(BackendEvent::Closed {
tab_id: write_tab.clone(),
reason: format!("local write error: {err}"),
});
break;
}
let _ = writer.flush();
}
BackendCommand::Resize { cols, rows } => {
let _ = master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
}
BackendCommand::Close => break,
BackendCommand::SampleMetrics => {}
},
Err(mpsc::RecvTimeoutError::Timeout) => {
if let Ok(Some(status)) = child.try_wait() {
let _ = write_events.send(BackendEvent::Closed {
tab_id: write_tab.clone(),
reason: format!("local write error: {err}"),
tab_id: write_tab,
reason: format!("local shell exited: {status}"),
});
break;
return;
}
let _ = writer.flush();
}
BackendCommand::Resize { cols, rows } => {
let _ = master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
}
BackendCommand::Close => break,
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
let _ = child.kill();
+2
View File
@@ -0,0 +1,2 @@
pub mod local;
pub mod ssh;
+125 -35
View File
@@ -14,7 +14,7 @@ use russh::{
use tokio::sync::mpsc;
use crate::{
config::{AuthMethod, Session},
session::config::{AuthMethod, Session},
system::{SystemSnapshot, remote_snapshot_from_kv},
terminal::{BackendCommand, BackendEvent, BackendTx},
};
@@ -49,10 +49,12 @@ pub fn spawn_ssh_terminal(
BackendTx::Ssh(cmd_tx)
}
pub async fn sample_remote_system(session: Session) -> Result<SystemSnapshot> {
let (events_tx, _events_rx) = std::sync::mpsc::channel();
let handle = connect_and_authenticate("remote-metrics", &session, &events_tx).await?;
async fn sample_remote_system_with_handle(
handle: Arc<tokio::sync::Mutex<russh::client::Handle<ClientHandler>>>,
) -> Result<SystemSnapshot> {
let mut channel = handle
.lock()
.await
.channel_open_session()
.await
.context("open metrics session")?;
@@ -72,9 +74,6 @@ pub async fn sample_remote_system(session: Session) -> Result<SystemSnapshot> {
}
}
let _ = handle
.disconnect(Disconnect::ByApplication, "metrics done", "")
.await;
let output = String::from_utf8_lossy(&stdout);
remote_snapshot_from_kv(&output)
}
@@ -95,9 +94,11 @@ async fn run_ssh(
),
});
let handle = connect_and_authenticate(&tab_id, &session, &events).await?;
let handle = Arc::new(tokio::sync::Mutex::new(connect_and_authenticate(&tab_id, &session, &events).await?));
let mut channel = handle
.lock()
.await
.channel_open_session()
.await
.context("open session")?;
@@ -115,24 +116,48 @@ async fn run_ssh(
tab_id: tab_id.clone(),
});
let exit_reason;
let mut is_graceful_close = false;
loop {
tokio::select! {
command = commands.recv() => {
match command {
Some(BackendCommand::Input(bytes)) => {
if let Err(err) = channel.data(bytes.as_slice()).await {
let _ = events.send(BackendEvent::Closed {
tab_id: tab_id.clone(),
reason: format!("ssh write error: {err}"),
});
tracing::error!("[ssh] write error on tab {}: {}", tab_id, err);
exit_reason = format!("ssh write error: {err}");
break;
}
}
Some(BackendCommand::Resize { cols, rows }) => {
let _ = channel.window_change(cols.into(), rows.into(), 0, 0).await;
}
Some(BackendCommand::SampleMetrics) => {
let handle_clone = handle.clone();
let tab_id_clone = tab_id.clone();
let events_clone = events.clone();
tokio::spawn(async move {
match sample_remote_system_with_handle(handle_clone).await {
Ok(snapshot) => {
let _ = events_clone.send(BackendEvent::RemoteSystem {
tab_id: tab_id_clone,
snapshot,
});
}
Err(err) => {
let _ = events_clone.send(BackendEvent::RemoteSystemUnavailable {
tab_id: tab_id_clone,
reason: format!("remote metrics unavailable: {err:#}"),
});
}
}
});
}
Some(BackendCommand::Close) | None => {
tracing::info!("[ssh] local client closed the session for tab {}", tab_id);
let _ = channel.eof().await;
exit_reason = "ssh session closed".to_string();
break;
}
}
@@ -145,7 +170,29 @@ async fn run_ssh(
bytes: data.to_vec(),
});
}
Some(ChannelMsg::Close) | None => break,
Some(ChannelMsg::ExitStatus { exit_status: _ }) | Some(ChannelMsg::Eof) => {
is_graceful_close = true;
}
Some(ChannelMsg::Close) => {
if is_graceful_close {
tracing::info!("[ssh] session gracefully closed by server for tab {}", tab_id);
exit_reason = "ssh session closed".to_string();
} else {
tracing::warn!("[ssh] connection abruptly closed by server for tab {}", tab_id);
exit_reason = "ssh connection lost (abrupt close)".to_string();
}
break;
}
None => {
if is_graceful_close {
tracing::info!("[ssh] network stream ended gracefully for tab {}", tab_id);
exit_reason = "ssh session closed".to_string();
} else {
tracing::warn!("[ssh] network drop detected for tab {}", tab_id);
exit_reason = "ssh connection lost (network drop)".to_string();
}
break;
}
_ => {}
}
}
@@ -153,11 +200,13 @@ async fn run_ssh(
}
let _ = handle
.lock()
.await
.disconnect(Disconnect::ByApplication, "bye", "")
.await;
let _ = events.send(BackendEvent::Closed {
tab_id,
reason: "ssh session closed".into(),
reason: exit_reason,
});
Ok(())
}
@@ -169,9 +218,12 @@ async fn connect_and_authenticate(
) -> Result<russh::client::Handle<ClientHandler>> {
let config = Arc::new(client::Config {
inactivity_timeout: Some(std::time::Duration::from_secs(600)),
keepalive_interval: Some(std::time::Duration::from_secs(10)),
keepalive_max: 3,
..Default::default()
});
let addr = format!("{}:{}", session.host, session.port);
tracing::info!("[ssh] initiating tcp connection to {} (user: {})", addr, session.user);
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!("opening tcp connection to {addr}"),
@@ -179,9 +231,12 @@ async fn connect_and_authenticate(
let mut handle = client::connect(config, addr.as_str(), ClientHandler)
.await
.with_context(|| format!("connect {addr} failed"))?;
tracing::debug!("[ssh] tcp connected to {}", addr);
let authed = match session.auth {
AuthMethod::Password => {
tracing::info!("[ssh] sending password authentication for {}@{}", session.user, addr);
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!(
@@ -196,6 +251,7 @@ async fn connect_and_authenticate(
}
AuthMethod::Key => {
let source = key_source_label(session);
tracing::info!("[ssh] sending key authentication for {}@{} (key source: {})", session.user, addr, source);
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!("connected to {addr}, loading private key from {source}"),
@@ -206,20 +262,36 @@ async fn connect_and_authenticate(
tab_id: tab_id.to_string(),
text: format!("private key loaded from {source}, algorithm {algorithm}, sending public key authentication for {}", session.user),
});
let key = private_key_with_alg(keypair).context("invalid private key")?;
handle
.authenticate_publickey(&session.user, key)
.await
.with_context(|| {
format!(
"public key authentication failed for {}@{}:{} using {} ({})",
session.user, session.host, session.port, source, algorithm
)
})?
let keys = private_keys_with_algs(keypair).context("invalid private key")?;
let mut success = false;
for key in keys {
match handle.authenticate_publickey(&session.user, key).await {
Ok(true) => {
success = true;
break;
}
Ok(false) => {
tracing::debug!("[ssh] public key auth failed with algorithm, trying next");
continue;
}
Err(e) => {
tracing::debug!("[ssh] public key auth error: {:?}, trying next", e);
continue;
}
}
}
if !success {
return Err(anyhow::anyhow!(
"public key authentication failed for {}@{}:{} using {} ({})",
session.user, session.host, session.port, source, algorithm
));
}
success
}
};
if !authed {
tracing::warn!("[ssh] authentication failed for {}@{}", session.user, addr);
let _ = handle
.disconnect(Disconnect::ByApplication, "auth failed", "")
.await;
@@ -241,6 +313,8 @@ async fn connect_and_authenticate(
));
}
tracing::info!("[ssh] authentication successful for {}@{}", session.user, addr);
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!(
@@ -281,16 +355,31 @@ fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
Err(anyhow!(errors.join("; ")))
}
fn private_key_with_alg(keypair: PrivateKey) -> Result<PrivateKeyWithHashAlg> {
let hash_alg = if keypair.algorithm().is_rsa() {
Some(HashAlg::Sha512)
fn private_keys_with_algs(keypair: PrivateKey) -> Result<Vec<PrivateKeyWithHashAlg>> {
let mut algs = Vec::new();
let key_arc = Arc::new(keypair);
if key_arc.algorithm().is_rsa() {
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha512)) {
algs.push(k);
}
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha256)) {
algs.push(k);
}
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
algs.push(k);
}
} else {
None
};
Ok(
PrivateKeyWithHashAlg::new(Arc::new(keypair.clone()), hash_alg)
.or_else(|_| PrivateKeyWithHashAlg::new(Arc::new(keypair), Some(HashAlg::Sha256)))?,
)
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
algs.push(k);
}
}
if algs.is_empty() {
return Err(anyhow!("Failed to construct PrivateKeyWithHashAlg for any supported hash algorithm"));
}
Ok(algs)
}
fn normalize_inline_private_key(value: &str) -> String {
@@ -365,7 +454,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 +493,7 @@ EOF
echo "SWAP_USED=${swap_used:-0}"
echo "NET_RX=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))"
echo "NET_TX=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))"
df -kP 2>/dev/null | awk "NR > 1 { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 4
df -kP 2>/dev/null | awk "NR > 1 && \$1 !~ /^(devfs|tmpfs|devtmpfs|ramfs|overlay|aufs)\$/ { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 6
exit 0
fi
@@ -417,6 +506,7 @@ echo "NET_RX=0"
echo "NET_TX=0"
'"#;
#[derive(Clone)]
struct ClientHandler;
#[async_trait]
+16 -4008
View File
File diff suppressed because it is too large Load Diff
+125 -2
View File
@@ -107,6 +107,14 @@ pub struct ConfigFile {
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)]
@@ -117,6 +125,14 @@ pub struct ConfigFile {
pub body_panels: Option<Vec<f32>>,
#[serde(default)]
pub transfers: Vec<crate::terminal::Transfer>,
#[serde(default)]
pub show_hidden_files: bool,
#[serde(default = "default_monitoring_position")]
pub monitoring_position: String,
}
fn default_monitoring_position() -> String {
"Sidebar".to_string()
}
fn default_locale() -> String {
@@ -124,7 +140,21 @@ fn default_locale() -> String {
}
fn default_terminal_font_size() -> f32 {
13.0
18.0
}
fn default_ui_font_size() -> f32 {
14.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 {
@@ -138,6 +168,19 @@ impl ConfigStore {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create config dir {}", parent.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(mut perms) = fs::metadata(parent).map(|m| m.permissions()) {
perms.set_mode(0o700);
let _ = fs::set_permissions(parent, perms);
}
}
let tmp_dir = parent.join("tmp");
let _ = fs::remove_dir_all(&tmp_dir);
let _ = fs::create_dir_all(&tmp_dir);
}
let cache = if path.exists() {
@@ -171,6 +214,10 @@ impl ConfigStore {
&self.cache.sessions
}
pub fn tmp_dir(&self) -> Option<PathBuf> {
self.path.parent().map(|p| p.join("tmp"))
}
pub fn follow_system_theme(&self) -> bool {
self.cache.follow_system_theme
}
@@ -199,6 +246,18 @@ impl ConfigStore {
self.cache.locale = locale.to_string();
}
pub fn monitoring_position(&self) -> &str {
if self.cache.monitoring_position.is_empty() {
"Sidebar"
} else {
&self.cache.monitoring_position
}
}
pub fn set_monitoring_position(&mut self, pos: &str) {
self.cache.monitoring_position = pos.to_string();
}
pub fn terminal_font_size(&self) -> f32 {
if self.cache.terminal_font_size <= 0.0 {
default_terminal_font_size()
@@ -228,6 +287,7 @@ impl ConfigStore {
self.cache.workspace_panels.as_ref()
}
#[allow(dead_code)]
pub fn body_panels(&self) -> Option<&Vec<f32>> {
self.cache.body_panels.as_ref()
}
@@ -258,6 +318,58 @@ impl ConfigStore {
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)
}
@@ -280,6 +392,17 @@ impl ConfigStore {
}
let raw = serde_json::to_string_pretty(&self.cache)?;
fs::write(&self.path, raw)
.with_context(|| format!("failed to write {}", self.path.display()))
.with_context(|| format!("failed to write {}", self.path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(mut perms) = fs::metadata(&self.path).map(|m| m.permissions()) {
perms.set_mode(0o600);
let _ = fs::set_permissions(&self.path, perms);
}
}
Ok(())
}
}
+1233
View File
File diff suppressed because it is too large Load Diff
+482 -53
View File
@@ -1,3 +1,5 @@
pub mod ops;
use std::{
fs,
path::{Path, PathBuf},
@@ -27,7 +29,7 @@ use zip::read::ZipArchive;
use rust_i18n::t;
use crate::{
config::{AuthMethod, Session},
session::config::{AuthMethod, Session},
terminal::BackendEvent,
};
@@ -58,6 +60,15 @@ pub enum SftpCommand {
remote: String,
local_dir: String,
},
EditFile {
remote_path: String,
},
CreateDir(String),
DeletePaths(Vec<String>),
UploadEditedFile {
local_path: String,
remote_path: String,
},
UploadPaths {
locals: Vec<String>,
remote_dir: String,
@@ -77,11 +88,17 @@ impl TransferStateFlag {
pub fn new() -> Self {
Self(Arc::new(AtomicU8::new(0)))
}
pub fn pause(&self) { self.0.store(1, Ordering::SeqCst); }
pub fn resume(&self) { self.0.store(0, Ordering::SeqCst); }
pub fn cancel(&self) { self.0.store(2, Ordering::SeqCst); }
pub fn pause(&self) {
self.0.store(1, Ordering::SeqCst);
}
pub fn resume(&self) {
self.0.store(0, Ordering::SeqCst);
}
pub fn cancel(&self) {
self.0.store(2, Ordering::SeqCst);
}
pub async fn yield_if_paused(
&self,
events: &std::sync::mpsc::Sender<crate::terminal::BackendEvent>,
@@ -161,6 +178,10 @@ impl SftpHandle {
.send(SftpCommand::UploadPaths { locals, remote_dir });
}
pub fn edit_file(&self, remote_path: String) {
let _ = self.commands.send(SftpCommand::EditFile { remote_path });
}
pub fn close(&self) {
let _ = self.commands.send(SftpCommand::Close);
}
@@ -187,7 +208,15 @@ pub fn spawn_sftp(
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let cmd_tx_clone = cmd_tx.clone();
let join = runtime.spawn(async move {
if let Err(err) = run_sftp(tab_id.clone(), session, cmd_rx, cmd_tx_clone, events.clone()).await {
if let Err(err) = run_sftp(
tab_id.clone(),
session,
cmd_rx,
cmd_tx_clone,
events.clone(),
)
.await
{
let _ = events.send(BackendEvent::SftpStatus {
tab_id,
text: format!("sftp error: {err:#}"),
@@ -229,9 +258,16 @@ async fn run_sftp(
.canonicalize(".")
.await
.unwrap_or_else(|_| "/".to_string());
let _ = events.send(BackendEvent::SftpHome {
tab_id: tab_id.clone(),
home: home.clone(),
});
emit_entries(&events, &tab_id, &sftp, &home).await?;
let mut active_transfers: std::collections::HashMap<String, TransferStateFlag> = std::collections::HashMap::new();
let mut active_transfers: std::collections::HashMap<String, TransferStateFlag> =
std::collections::HashMap::new();
while let Some(command) = commands.recv().await {
match command {
@@ -308,16 +344,33 @@ async fn run_sftp(
let commands_tx_clone = commands_tx.clone();
tokio::spawn(async move {
let Ok(channel) = handle_clone.channel_open_session().await else { return };
let Ok(_) = channel.request_subsystem(true, "sftp").await else { return };
let Ok(sftp_session) = SftpSession::new(channel.into_stream()).await else { return };
let Ok(channel) = handle_clone.channel_open_session().await else {
return;
};
let Ok(_) = channel.request_subsystem(true, "sftp").await else {
return;
};
let Ok(sftp_session) = SftpSession::new(channel.into_stream()).await else {
return;
};
let _ = events_clone.send(BackendEvent::SftpStatus {
tab_id: tab_id_clone.clone(),
text: t!("downloading_file", base = base_name(&remote)).to_string(),
});
match download_path_impl(&handle_clone, &sftp_session, &remote, Path::new(&local_dir), flag, &events_clone, &tab_id_clone, &id).await {
match download_path_impl(
&handle_clone,
&sftp_session,
&remote,
Path::new(&local_dir),
flag,
&events_clone,
&tab_id_clone,
&id,
)
.await
{
Ok(summary) => {
let _ = events_clone.send(BackendEvent::SftpStatus {
tab_id: tab_id_clone,
@@ -364,7 +417,12 @@ async fn run_sftp(
} else if file_count == 0 && folder_count > 0 {
t!("n_folders", folders = folder_count).to_string()
} else {
t!("n_files_and_folders", files = file_count, folders = folder_count).to_string()
t!(
"n_files_and_folders",
files = file_count,
folders = folder_count
)
.to_string()
}
};
@@ -387,16 +445,32 @@ async fn run_sftp(
let commands_tx_clone = commands_tx.clone();
tokio::spawn(async move {
let Ok(channel) = handle_clone.channel_open_session().await else { return };
let Ok(_) = channel.request_subsystem(true, "sftp").await else { return };
let Ok(sftp_session) = SftpSession::new(channel.into_stream()).await else { return };
let Ok(channel) = handle_clone.channel_open_session().await else {
return;
};
let Ok(_) = channel.request_subsystem(true, "sftp").await else {
return;
};
let Ok(sftp_session) = SftpSession::new(channel.into_stream()).await else {
return;
};
let _ = events_clone.send(BackendEvent::SftpStatus {
tab_id: tab_id_clone.clone(),
text: t!("uploading").to_string(),
});
match upload_paths_impl(&sftp_session, &locals, &remote_dir, flag, &events_clone, &tab_id_clone, &id).await {
match upload_paths_impl(
&sftp_session,
&locals,
&remote_dir,
flag,
&events_clone,
&tab_id_clone,
&id,
)
.await
{
Ok(summary) => {
let _ = events_clone.send(BackendEvent::SftpStatus {
tab_id: tab_id_clone.clone(),
@@ -422,6 +496,226 @@ async fn run_sftp(
let _ = commands_tx_clone.send(SftpCommand::TransferFinished(id));
});
}
SftpCommand::EditFile { remote_path } => {
let id = uuid::Uuid::new_v4().to_string();
let config = crate::session::config::ConfigStore::load().unwrap();
let tmp_dir = config.tmp_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
let base = base_name(&remote_path);
let local_path = tmp_dir.join(format!("{}-{}", id, base));
let handle_clone = handle.clone();
let commands_tx_clone = commands_tx.clone();
let events_clone = events.clone();
let tab_id_clone = tab_id.clone();
tokio::spawn(async move {
let flag = TransferStateFlag::new();
let Ok(channel) = handle_clone.channel_open_session().await else {
return;
};
let Ok(_) = channel.request_subsystem(true, "sftp").await else {
return;
};
let Ok(sftp_session) = SftpSession::new(channel.into_stream()).await else {
return;
};
let _ = events_clone.send(BackendEvent::SftpStatus {
tab_id: tab_id_clone.clone(),
text: t!("downloading_file", base = base).to_string(),
});
if let Err(err) = download_file_impl(
&sftp_session,
&remote_path,
&local_path,
&flag,
&events_clone,
&tab_id_clone,
"edit-download",
)
.await
{
let _ = events_clone.send(BackendEvent::SftpStatus {
tab_id: tab_id_clone.clone(),
text: format!("Edit download failed: {err:#}"),
});
return;
}
if let Err(err) = open::that(&local_path) {
let _ = events_clone.send(BackendEvent::SftpStatus {
tab_id: tab_id_clone.clone(),
text: format!("Failed to open editor: {err:#}"),
});
return;
}
use notify::Watcher;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let mut watcher = match notify::recommended_watcher(
move |res: notify::Result<notify::Event>| {
if let Ok(event) = res {
if event.kind.is_modify() {
let _ = tx.send(());
}
}
},
) {
Ok(w) => w,
Err(_) => return,
};
if let Err(_) = watcher.watch(&local_path, notify::RecursiveMode::NonRecursive)
{
return;
}
while let Some(_) = rx.recv().await {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
while let Ok(_) = rx.try_recv() {} // drain pending
if commands_tx_clone.send(SftpCommand::UploadEditedFile {
local_path: local_path.to_string_lossy().to_string(),
remote_path: remote_path.clone(),
}).is_err() {
break;
}
}
});
}
SftpCommand::UploadEditedFile {
local_path,
remote_path,
} => {
let handle_clone = handle.clone();
let events_clone = events.clone();
let tab_id_clone = tab_id.clone();
tokio::spawn(async move {
let flag = TransferStateFlag::new();
let Ok(channel) = handle_clone.channel_open_session().await else {
return;
};
let Ok(_) = channel.request_subsystem(true, "sftp").await else {
return;
};
let Ok(sftp_session) = SftpSession::new(channel.into_stream()).await else {
return;
};
let transferred = Arc::new(AtomicU64::new(0));
match upload_file_impl(
&sftp_session,
Path::new(&local_path),
&remote_path,
&flag,
&events_clone,
&tab_id_clone,
"edit-upload",
transferred,
None,
)
.await
{
Ok(_) => {
let now = chrono::Local::now().format("%H:%M:%S");
let _ = events_clone.send(BackendEvent::SftpStatus {
tab_id: tab_id_clone.clone(),
text: format!("{} ({})", t!("auto_saved_and_uploaded", base = base_name(&remote_path)), now),
});
}
Err(err) => {
let _ = events_clone.send(BackendEvent::SftpStatus {
tab_id: tab_id_clone.clone(),
text: format!("Auto-upload failed: {err:#}"),
});
}
}
});
}
SftpCommand::CreateDir(path) => {
let actual_path = if path == "~" {
home.clone()
} else if let Some(rest) = path.strip_prefix("~/") {
crate::sftp::join_remote(&home, rest)
} else {
path.clone()
};
tracing::info!("[sftp] creating directory: '{}'", actual_path);
match sftp.create_dir(&actual_path).await {
Ok(_) => {
let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(),
text: t!("create_folder_success", name = base_name(&actual_path)).to_string(),
});
// Re-fetch the parent directory to show the newly created folder
if let Some(parent) = parent_dir(&actual_path) {
let _ = commands_tx.send(SftpCommand::ListDir(parent));
} else {
let _ = commands_tx.send(SftpCommand::ListDir("/".to_string()));
}
}
Err(err) => {
let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(),
text: t!("create_folder_failed", err = format!("{err:#}")).to_string(),
});
}
}
}
SftpCommand::DeletePaths(paths) => {
tracing::info!("[sftp] batch deleting {} paths", paths.len());
let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(),
text: t!("deleting_paths", count = paths.len()).to_string(),
});
let mut errors = Vec::new();
for path in paths.clone() {
let actual_path = if path == "~" {
home.clone()
} else if let Some(rest) = path.strip_prefix("~/") {
crate::sftp::join_remote(&home, rest)
} else {
path.clone()
};
if let Err(e) = recursive_delete(&sftp, actual_path).await {
errors.push(format!("{path}: {e:#}"));
}
}
if errors.is_empty() {
let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(),
text: t!("delete_success", count = paths.len()).to_string(),
});
} else {
let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(),
text: t!("delete_failed", err = errors.join(", ")).to_string(),
});
}
if let Some(first) = paths.first() {
let actual_path = if first == "~" {
home.clone()
} else if let Some(rest) = first.strip_prefix("~/") {
crate::sftp::join_remote(&home, rest)
} else {
first.clone()
};
if let Some(parent) = parent_dir(&actual_path) {
let _ = commands_tx.send(SftpCommand::ListDir(parent));
} else {
let _ = commands_tx.send(SftpCommand::ListDir("/".to_string()));
}
}
}
}
}
@@ -431,6 +725,43 @@ async fn run_sftp(
Ok(())
}
use std::future::Future;
use std::pin::Pin;
fn recursive_delete<'a>(
sftp: &'a SftpSession,
path: String,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async move {
match sftp.read_dir(&path).await {
Ok(entries) => {
for entry in entries {
let name = entry.file_name();
if name == "." || name == ".." {
continue;
}
let child_path = crate::sftp::join_remote(&path, &name);
let meta = entry.metadata();
let permissions = meta.permissions.unwrap_or(0);
let is_dir = (permissions & 0o170_000) == 0o040_000;
if is_dir {
recursive_delete(sftp, child_path).await?;
} else {
sftp.remove_file(&child_path).await.with_context(|| format!("Failed to delete file {child_path}"))?;
}
}
sftp.remove_dir(&path).await.with_context(|| format!("Failed to delete dir {path}"))?;
}
Err(_) => {
sftp.remove_file(&path).await.with_context(|| format!("Failed to delete {path}"))?;
}
}
Ok(())
})
}
async fn emit_entries(
events: &std::sync::mpsc::Sender<BackendEvent>,
tab_id: &str,
@@ -469,11 +800,28 @@ async fn connect_and_authenticate(
.context("password authentication failed")?,
AuthMethod::Key => {
let keypair = load_session_private_key(session)?;
let key = private_key_with_alg(keypair).context("invalid private key")?;
handle
.authenticate_publickey(&session.user, key)
.await
.context("public key authentication failed")?
let keys = private_keys_with_algs(keypair).context("invalid private key")?;
let mut success = false;
for key in keys {
match handle.authenticate_publickey(&session.user, key).await {
Ok(true) => {
success = true;
break;
}
Ok(false) => {
tracing::debug!("[sftp] public key auth failed with algorithm, trying next");
continue;
}
Err(e) => {
tracing::debug!("[sftp] public key auth error: {:?}, trying next", e);
continue;
}
}
}
if !success {
return Err(anyhow!("public key authentication failed for {}@{}:{}", session.user, session.host, session.port));
}
success
}
};
@@ -525,16 +873,31 @@ fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
Err(anyhow!(errors.join("; ")))
}
fn private_key_with_alg(keypair: PrivateKey) -> Result<PrivateKeyWithHashAlg> {
let hash_alg = if keypair.algorithm().is_rsa() {
Some(HashAlg::Sha512)
fn private_keys_with_algs(keypair: PrivateKey) -> Result<Vec<PrivateKeyWithHashAlg>> {
let mut algs = Vec::new();
let key_arc = Arc::new(keypair);
if key_arc.algorithm().is_rsa() {
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha512)) {
algs.push(k);
}
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha256)) {
algs.push(k);
}
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
algs.push(k);
}
} else {
None
};
Ok(
PrivateKeyWithHashAlg::new(Arc::new(keypair.clone()), hash_alg)
.or_else(|_| PrivateKeyWithHashAlg::new(Arc::new(keypair), Some(HashAlg::Sha256)))?,
)
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
algs.push(k);
}
}
if algs.is_empty() {
return Err(anyhow!("Failed to construct PrivateKeyWithHashAlg for any supported hash algorithm"));
}
Ok(algs)
}
fn normalize_inline_private_key(value: &str) -> String {
@@ -571,7 +934,23 @@ fn base_name(path: &str) -> String {
.to_string()
}
fn join_remote(parent: &str, child: &str) -> String {
pub(crate) fn parent_dir(path: &str) -> Option<String> {
if path == "/" || path.is_empty() {
return None;
}
let trimmed = path.trim_end_matches('/');
if let Some(idx) = trimmed.rfind('/') {
if idx == 0 {
Some("/".to_string())
} else {
Some(trimmed[..idx].to_string())
}
} else {
Some("/".to_string())
}
}
pub(crate) fn join_remote(parent: &str, child: &str) -> String {
if parent == "/" {
format!("/{child}")
} else {
@@ -736,8 +1115,17 @@ async fn download_path_impl(
base_name(remote),
Uuid::new_v4()
));
let extracted_to =
download_remote_directory_archive(handle, sftp, remote, &local_archive, &flag, events, tab_id, id).await?;
let extracted_to = download_remote_directory_archive(
handle,
sftp,
remote,
&local_archive,
&flag,
events,
tab_id,
id,
)
.await?;
return Ok(t!("downloaded_folder", path = extracted_to.display()).to_string());
}
@@ -763,9 +1151,27 @@ async fn download_dir_recursive(
for entry in entries {
let local_path = local_dir.join(&entry.name);
if entry.is_dir {
Box::pin(download_dir_recursive(sftp, &entry.full_path, &local_path, flag, events, tab_id, id)).await?;
Box::pin(download_dir_recursive(
sftp,
&entry.full_path,
&local_path,
flag,
events,
tab_id,
id,
))
.await?;
} else {
download_file_impl(sftp, &entry.full_path, &local_path, flag, events, tab_id, id).await?;
download_file_impl(
sftp,
&entry.full_path,
&local_path,
flag,
events,
tab_id,
id,
)
.await?;
let _ = maybe_extract_archive(&local_path).await;
}
}
@@ -794,7 +1200,16 @@ async fn download_remote_directory_archive(
.join(base_name(remote_dir));
let archive_download = async {
download_file_impl(sftp, &remote_archive, local_archive, flag, events, tab_id, id).await?;
download_file_impl(
sftp,
&remote_archive,
local_archive,
flag,
events,
tab_id,
id,
)
.await?;
extract_archive_to(
local_archive,
local_archive.parent().unwrap_or_else(|| Path::new(".")),
@@ -839,7 +1254,8 @@ async fn download_file_impl(
let mut buffer = vec![0u8; 128 * 1024];
loop {
flag.yield_if_paused(events, tab_id, id, transferred, total).await?;
flag.yield_if_paused(events, tab_id, id, transferred, total)
.await?;
let read = remote_file
.read(&mut buffer)
.await
@@ -851,7 +1267,7 @@ async fn download_file_impl(
.write_all(&buffer[..read])
.await
.with_context(|| format!("write {}", local.display()))?;
transferred += read as u64;
let _ = events.send(BackendEvent::TransferProgress {
tab_id: tab_id.to_string(),
@@ -862,7 +1278,7 @@ async fn download_file_impl(
});
}
local_file.flush().await.context("flush local file")?;
let _ = events.send(BackendEvent::TransferProgress {
tab_id: tab_id.to_string(),
id: id.to_string(),
@@ -870,7 +1286,7 @@ async fn download_file_impl(
total,
state: crate::terminal::TransferState::Completed,
});
Ok(())
}
@@ -886,7 +1302,7 @@ async fn upload_paths_impl(
create_remote_dir_all(sftp, remote_dir).await?;
let mut file_count = 0usize;
let mut folder_count = 0usize;
let mut total_bytes = 0u64;
let mut files_to_upload = Vec::new();
let mut dirs_to_create = Vec::new();
@@ -902,14 +1318,20 @@ async fn upload_paths_impl(
for entry in WalkDir::new(&p) {
let entry = entry?;
let path = entry.path();
if path == p { continue; }
if path == p {
continue;
}
if let Ok(meta) = tokio::fs::metadata(&path).await {
let relative = path.strip_prefix(&p)?;
let remote_path = if relative.as_os_str().is_empty() {
remote_root.clone()
} else {
let rel = relative.components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::<Vec<_>>().join("/");
let rel = relative
.components()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>()
.join("/");
join_remote(&remote_root, &rel)
};
@@ -929,6 +1351,7 @@ async fn upload_paths_impl(
}
}
// Create directories sequentially first
for dir in dirs_to_create {
create_remote_dir_all(sftp, &dir).await?;
@@ -955,7 +1378,8 @@ async fn upload_paths_impl(
&id_clone,
transferred_clone,
Some(total_bytes),
).await
)
.await
});
}
@@ -964,7 +1388,7 @@ async fn upload_paths_impl(
while let Some(res) = stream.next().await {
res?;
}
let _ = events.send(BackendEvent::TransferProgress {
tab_id: tab_id.to_string(),
id: id.to_string(),
@@ -972,7 +1396,7 @@ async fn upload_paths_impl(
total: Some(total_bytes),
state: crate::terminal::TransferState::Completed,
});
let summary = if file_count == 1 && folder_count == 0 {
t!("uploaded_file").to_string()
} else if file_count == 0 && folder_count == 1 {
@@ -982,14 +1406,19 @@ async fn upload_paths_impl(
} else if file_count == 0 && folder_count > 0 {
t!("uploaded_n_folders", folders = folder_count).to_string()
} else {
t!("uploaded_files_and_folders", files = file_count, folders = folder_count).to_string()
t!(
"uploaded_files_and_folders",
files = file_count,
folders = folder_count
)
.to_string()
};
Ok(summary)
}
async fn upload_file_impl(
sftp: &SftpSession,
local_file: &Path,
sftp: &SftpSession,
local_file: &Path,
remote_path: &str,
flag: &TransferStateFlag,
events: &std::sync::mpsc::Sender<BackendEvent>,
@@ -1018,7 +1447,7 @@ async fn upload_file_impl(
.write_all(&buffer[..read])
.await
.with_context(|| format!("write remote {remote_path}"))?;
let new_cur = transferred.fetch_add(read as u64, Ordering::Relaxed) + read as u64;
let _ = events.send(BackendEvent::TransferProgress {
tab_id: tab_id.to_string(),
+337
View File
@@ -0,0 +1,337 @@
use gpui::{Context, PathPromptOptions, Pixels, Point, Window};
use crate::{
Ashell, SftpContextMenuState,
sftp::{RemoteEntry, SftpHandle},
terminal,
};
pub(crate) fn is_editable_text_file(filename: &str) -> bool {
let lower = filename.to_lowercase();
let ext = std::path::Path::new(&lower).extension().and_then(|s| s.to_str()).unwrap_or("");
let known_exts = ["txt", "conf", "json", "yaml", "yml", "xml", "ini", "sh", "py", "rs", "js", "ts", "html", "css", "md", "toml", "csv", "log", "cfg"];
if known_exts.contains(&ext) {
return true;
}
let known_names = ["dockerfile", "makefile", ".gitignore", ".env"];
if known_names.contains(&lower.as_str()) {
return true;
}
false
}
impl Ashell {
pub(crate) fn active_sftp(&self) -> Option<&terminal::SftpUiState> {
self.active_group.as_ref()
.and_then(|id| self.tab_groups.iter().find(|g| &g.id == id))
.and_then(|g| g.sftp.as_ref())
}
pub(crate) fn active_sftp_mut(&mut self) -> Option<&mut terminal::SftpUiState> {
let active_id = self.active_group.clone()?;
self.tab_groups.iter_mut().find(|g| g.id == active_id)
.and_then(|g| g.sftp.as_mut())
}
pub(crate) fn active_sftp_handle(&self) -> Option<&SftpHandle> {
self.active_group.as_ref()
.and_then(|id| self.sftp_handles.get(id))
}
pub(crate) fn navigate_sftp(&mut self, path: String, cx: &mut Context<Self>) {
if let Some(handle) = self.active_sftp_handle() {
tracing::info!("[sftp] navigating to directory: '{}'", path);
handle.list_dir(path.clone());
if let Some(sftp) = self.active_sftp_mut() {
sftp.current_path = path;
self.pending_sftp_path_sync = Some(sftp.current_path.clone());
}
cx.notify();
}
}
pub(crate) fn select_sftp_entry(&mut self, entry: RemoteEntry, cx: &mut Context<Self>) {
if entry.is_dir {
self.navigate_sftp(entry.full_path, cx);
return;
}
self.mark_sftp_entry_selected(&entry.full_path, cx);
if let Some(sftp) = self.active_sftp_mut() {
if !sftp.selected_entries.remove(&entry.full_path) {
sftp.selected_entries.insert(entry.full_path);
}
}
}
pub(crate) fn mark_sftp_entry_selected(&mut self, path: &str, cx: &mut Context<Self>) {
if let Some(sftp) = self.active_sftp_mut() {
sftp.selected_path = Some(path.to_string());
}
cx.notify();
}
pub(crate) fn sftp_parent_path(path: &str) -> String {
if path == "/" {
return "/".to_string();
}
path.trim_end_matches('/')
.rsplit_once('/')
.map(|(parent, _)| {
if parent.is_empty() {
"/".to_string()
} else {
parent.to_string()
}
})
.unwrap_or_else(|| "/".to_string())
}
pub(crate) fn refresh_sftp(&mut self, cx: &mut Context<Self>) {
if let Some(path) = self.active_sftp().map(|sftp| sftp.current_path.clone()) {
self.navigate_sftp(path, cx);
}
}
pub(crate) fn sync_sftp_path_input(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(path) = self.pending_sftp_path_sync.take() else {
return;
};
self.sftp_path_input.update(cx, |state, cx| {
state.set_value(path, window, cx);
});
}
pub(crate) fn open_sftp_context_menu(
&mut self,
remote_path: String,
is_dir: bool,
position: Point<Pixels>,
cx: &mut Context<Self>,
) {
self.sftp_context_menu = Some(SftpContextMenuState {
remote_path,
is_dir,
position,
});
cx.notify();
}
pub(crate) fn dismiss_sftp_context_menu(&mut self, cx: &mut Context<Self>) {
if self.sftp_context_menu.take().is_some() {
cx.notify();
}
}
pub(crate) fn trigger_sftp_context_download(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(menu) = self.sftp_context_menu.take() else {
return;
};
self.download_sftp_entry(menu.remote_path, window, cx);
cx.notify();
}
pub(crate) fn trigger_sftp_context_edit(&mut self, cx: &mut Context<Self>) {
let Some(menu) = self.sftp_context_menu.take() else {
return;
};
if let Some(handle) = self.active_sftp_handle() {
tracing::info!("[sftp] triggering edit for file: '{}'", menu.remote_path);
handle.edit_file(menu.remote_path);
}
cx.notify();
}
pub(crate) fn download_sftp_entry(
&mut self,
remote_path: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Select Download Folder".into()),
});
cx.spawn_in(window, async move |this, cx| {
match path_prompt.await {
Ok(Ok(Some(mut paths))) => {
if let Some(folder) = paths.pop() {
let local_path = folder.to_string_lossy().to_string();
tracing::info!("[sftp] initiating download of '{}' to '{}'", remote_path, local_path);
handle.download(remote_path, local_path);
}
}
Ok(Err(err)) => {
this.update(cx, |this, cx| {
this.status = format!("download picker failed: {err}").into();
cx.notify();
})?;
}
_ => {}
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
pub(crate) fn upload_sftp_files(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
let remote_dir = self
.active_sftp()
.map(|sftp| sftp.current_path.clone())
.unwrap_or_else(|| "/".into());
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: Some("Select File to Upload".into()),
});
cx.spawn_in(window, async move |this, cx| {
match path_prompt.await {
Ok(Ok(Some(mut paths))) => {
if let Some(file) = paths.pop() {
let local_path = file.to_string_lossy().to_string();
tracing::info!("[sftp] initiating upload of file '{}' to '{}'", local_path, remote_dir);
handle.upload_paths(vec![local_path], remote_dir);
}
}
Ok(Err(err)) => {
this.update(cx, |this, cx| {
this.status = format!("upload picker failed: {err}").into();
cx.notify();
})?;
}
_ => {}
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
pub(crate) fn upload_sftp_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
let remote_dir = self
.active_sftp()
.map(|sftp| sftp.current_path.clone())
.unwrap_or_else(|| "/".into());
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Select Folder to Upload".into()),
});
cx.spawn_in(window, async move |this, cx| {
match path_prompt.await {
Ok(Ok(Some(mut paths))) => {
if let Some(folder) = paths.pop() {
let local_path = folder.to_string_lossy().to_string();
tracing::info!("[sftp] initiating upload of folder '{}' to '{}'", local_path, remote_dir);
handle.upload_paths(vec![local_path], remote_dir);
}
}
Ok(Err(err)) => {
this.update(cx, |this, cx| {
this.status = format!("upload picker failed: {err}").into();
cx.notify();
})?;
}
_ => {}
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
pub(crate) fn toggle_sftp_entry(&mut self, path: String, checked: bool, cx: &mut Context<Self>) {
if let Some(sftp) = self.active_sftp_mut() {
if checked {
sftp.selected_entries.insert(path);
} else {
sftp.selected_entries.remove(&path);
}
cx.notify();
}
}
pub(crate) fn toggle_all_sftp_entries(&mut self, checked: bool, cx: &mut Context<Self>) {
if let Some(sftp) = self.active_sftp_mut() {
if checked {
let paths: Vec<String> = sftp.entries.iter().map(|e| e.full_path.clone()).collect();
for path in paths {
sftp.selected_entries.insert(path);
}
} else {
sftp.selected_entries.clear();
}
cx.notify();
}
}
pub(crate) fn download_selected_sftp_entries(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(sftp) = self.active_sftp() else {
return;
};
let selected: Vec<String> = sftp.selected_entries.iter().cloned().collect();
if selected.is_empty() {
return;
}
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Select Download Folder".into()),
});
cx.spawn_in(window, async move |this, cx| {
if let Ok(Ok(Some(mut paths))) = path_prompt.await {
if let Some(folder) = paths.pop() {
let local_dir = folder.to_string_lossy().to_string();
tracing::info!("[sftp] initiating batch download of {} entries to '{}'", selected.len(), local_dir);
for remote in selected {
let _ = handle.commands.send(crate::sftp::SftpCommand::Download {
remote,
local_dir: local_dir.clone(),
});
}
let _ = this.update(cx, |this, cx| {
if let Some(sftp_mut) = this.active_sftp_mut() {
sftp_mut.selected_entries.clear();
}
cx.notify();
});
}
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
pub(crate) fn upload_sftp_files_batch(&mut self, paths: Vec<String>, _cx: &mut Context<Self>) {
if paths.is_empty() {
return;
}
if let Some(sftp) = self.active_sftp() {
if let Some(handle) = self.active_sftp_handle() {
tracing::info!("[sftp] initiating batch upload of {} files to '{}'", paths.len(), sftp.current_path);
let _ = handle.commands.send(crate::sftp::SftpCommand::UploadPaths {
locals: paths,
remote_dir: sftp.current_path.clone(),
});
}
}
}
}
+43 -3
View File
@@ -1,11 +1,20 @@
use std::{
collections::BTreeMap,
ffi::OsStr,
time::{Duration, Instant},
};
use anyhow::{Result, anyhow};
use sysinfo::{Disks, Networks, System};
/// Known virtual/ram filesystems to exclude from disk monitoring.
fn is_real_filesystem(fs: &OsStr) -> bool {
!matches!(
fs.to_str(),
Some("tmpfs" | "devtmpfs" | "ramfs" | "overlay" | "aufs")
)
}
#[derive(Debug, Clone, Default)]
pub struct DiskSample {
pub mount: String,
@@ -22,7 +31,10 @@ pub struct SystemSnapshot {
pub swap_detail: String,
pub net_rx: String,
pub net_tx: String,
pub net_rx_rate: u64,
pub net_tx_rate: u64,
pub disks: Vec<DiskSample>,
pub total_swap: u64,
}
pub struct SystemSampler {
@@ -82,17 +94,25 @@ impl SystemSampler {
self.last_tx_total = tx_total;
self.last_instant = now;
let disks = self
let mut disks: Vec<DiskSample> = self
.disks
.iter()
.filter(|disk| disk.total_space() > 0)
.take(4)
.filter(|disk| disk.total_space() > 0 && is_real_filesystem(disk.file_system()))
.map(|disk| DiskSample {
mount: disk.mount_point().to_string_lossy().to_string(),
available_bytes: disk.available_space(),
total_bytes: disk.total_space(),
})
.collect();
disks.sort_by(|a, b| {
if a.mount == "/" {
return std::cmp::Ordering::Less;
}
if b.mount == "/" {
return std::cmp::Ordering::Greater;
}
a.mount.cmp(&b.mount)
});
SystemSnapshot {
cpu_percent,
@@ -102,7 +122,10 @@ impl SystemSampler {
swap_detail: format!("{}/{}", format_bytes(swap_used), format_bytes(swap_total)),
net_rx: format!("{}/s", format_bytes(rx_rate)),
net_tx: format!("{}/s", format_bytes(tx_rate)),
net_rx_rate: rx_rate,
net_tx_rate: tx_rate,
disks,
total_swap: swap_total,
}
}
}
@@ -176,6 +199,20 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
let rx_rate = parse_u64(&kv, "NET_RX");
let tx_rate = parse_u64(&kv, "NET_TX");
// Safety filter: exclude entries with zero/negligible total size
// (catches any virtual fs lines that slipped past the script filter)
disks.retain(|d| d.total_bytes >= 1024 * 1024);
disks.sort_by(|a, b| {
if a.mount == "/" {
return std::cmp::Ordering::Less;
}
if b.mount == "/" {
return std::cmp::Ordering::Greater;
}
a.mount.cmp(&b.mount)
});
Ok(SystemSnapshot {
cpu_percent: cpu_percent.clamp(0.0, 1.0),
mem_percent: ratio(mem_used, mem_total),
@@ -184,7 +221,10 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
swap_detail: format!("{}/{}", format_bytes(swap_used), format_bytes(swap_total)),
net_rx: format!("{}/s", format_bytes(rx_rate)),
net_tx: format!("{}/s", format_bytes(tx_rate)),
net_rx_rate: rx_rate,
net_tx_rate: tx_rate,
disks,
total_swap: swap_total,
})
}
+251
View File
@@ -0,0 +1,251 @@
use gpui::{Bounds, Hsla, Path, Pixels, Window, fill, point, px, size};
pub fn is_custom_block_supported(c: char) -> bool {
match c as u32 {
0x2580..=0x258F | 0x2590 | 0x2594..=0x259F => true, // Block Elements
0x2500 | 0x2502 | 0x250C | 0x2510 | 0x2514 | 0x2518 | 0x251C | 0x2524 | 0x252C | 0x2534 | 0x253C => true, // Light lines
0x2501 | 0x2503 | 0x250F | 0x2513 | 0x2517 | 0x251B | 0x2523 | 0x252B | 0x2533 | 0x253B | 0x254B => true, // Heavy lines
0xE0B0..=0xE0B6 => true, // Powerline
_ => false,
}
}
pub fn paint_custom_block(
window: &mut Window,
c: char,
raw_x: f32,
raw_y: f32,
raw_w: f32,
raw_h: f32,
color: Hsla,
) -> bool {
let scale = window.scale_factor();
let x = px((raw_x * scale).round() / scale);
let y = px((raw_y * scale).round() / scale);
let w = px(((raw_x + raw_w) * scale).round() / scale) - x;
let h = px(((raw_y + raw_h) * scale).round() / scale) - y;
let mut painted = false;
// Helper to paint a quad snapping its true physical boundaries to integer physical pixels
let mut paint_quad = |qx: Pixels, qy: Pixels, qw: Pixels, qh: Pixels| {
let l = px((qx.as_f32() * scale).round() / scale);
let t = px((qy.as_f32() * scale).round() / scale);
let r = px(((qx + qw).as_f32() * scale).round() / scale);
let b = px(((qy + qh).as_f32() * scale).round() / scale);
window.paint_quad(fill(Bounds::new(point(l, t), size(r - l, b - t)), color));
painted = true;
};
let px_1 = px(1.0);
let px_2 = px(2.0);
// Box drawing thickness
let light = (w * 0.1).max(px_1);
let heavy = (w * 0.2).max(px_2);
let cx = x + w / 2.0;
let cy = y + h / 2.0;
match c as u32 {
// --- Block Elements (U+2580..=U+259F) ---
0x2580 => paint_quad(x, y, w, h / 2.0),
0x2581 => paint_quad(x, y + h * 7.0 / 8.0, w, h / 8.0),
0x2582 => paint_quad(x, y + h * 3.0 / 4.0, w, h / 4.0),
0x2583 => paint_quad(x, y + h * 5.0 / 8.0, w, h * 3.0 / 8.0),
0x2584 => paint_quad(x, y + h / 2.0, w, h / 2.0),
0x2585 => paint_quad(x, y + h * 3.0 / 8.0, w, h * 5.0 / 8.0),
0x2586 => paint_quad(x, y + h / 4.0, w, h * 3.0 / 4.0),
0x2587 => paint_quad(x, y + h / 8.0, w, h * 7.0 / 8.0),
0x2588 => paint_quad(x, y, w, h),
0x2589 => paint_quad(x, y, w * 7.0 / 8.0, h),
0x258A => paint_quad(x, y, w * 3.0 / 4.0, h),
0x258B => paint_quad(x, y, w * 5.0 / 8.0, h),
0x258C => paint_quad(x, y, w / 2.0, h),
0x258D => paint_quad(x, y, w * 3.0 / 8.0, h),
0x258E => paint_quad(x, y, w / 4.0, h),
0x258F => paint_quad(x, y, w / 8.0, h),
0x2590 => paint_quad(x + w / 2.0, y, w / 2.0, h),
0x2594 => paint_quad(x, y, w, h / 8.0),
0x2595 => paint_quad(x + w * 7.0 / 8.0, y, w / 8.0, h),
0x2596 => paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0),
0x2597 => paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0),
0x2598 => paint_quad(x, y, w / 2.0, h / 2.0),
0x2599 => {
paint_quad(x, y, w / 2.0, h);
paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259A => {
paint_quad(x, y, w / 2.0, h / 2.0);
paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259B => {
paint_quad(x, y, w, h / 2.0);
paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259C => {
paint_quad(x, y, w, h / 2.0);
paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259D => paint_quad(x + w / 2.0, y, w / 2.0, h / 2.0),
0x259E => {
paint_quad(x + w / 2.0, y, w / 2.0, h / 2.0);
paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259F => {
paint_quad(x + w / 2.0, y, w / 2.0, h);
paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0);
}
// --- Basic Box Drawing (U+2500..=U+257F) ---
// Light lines
0x2500 => paint_quad(x, cy - light / 2.0, w, light), // Horizontal
0x2502 => paint_quad(cx - light / 2.0, y, light, h), // Vertical
0x250C => { // Down + Right
paint_quad(cx - light / 2.0, cy - light / 2.0, light, h / 2.0 + light / 2.0);
paint_quad(cx - light / 2.0, cy - light / 2.0, w / 2.0 + light / 2.0, light);
}
0x2510 => { // Down + Left
paint_quad(cx - light / 2.0, cy - light / 2.0, light, h / 2.0 + light / 2.0);
paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light);
}
0x2514 => { // Up + Right
paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0);
paint_quad(cx - light / 2.0, cy - light / 2.0, w / 2.0 + light / 2.0, light);
}
0x2518 => { // Up + Left
paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0);
paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light);
}
0x251C => { // Vertical + Right
paint_quad(cx - light / 2.0, y, light, h);
paint_quad(cx - light / 2.0, cy - light / 2.0, w / 2.0 + light / 2.0, light);
}
0x2524 => { // Vertical + Left
paint_quad(cx - light / 2.0, y, light, h);
paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light);
}
0x252C => { // Horizontal + Down
paint_quad(x, cy - light / 2.0, w, light);
paint_quad(cx - light / 2.0, cy - light / 2.0, light, h / 2.0 + light / 2.0);
}
0x2534 => { // Horizontal + Up
paint_quad(x, cy - light / 2.0, w, light);
paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0);
}
0x253C => { // Vertical + Horizontal (Cross)
paint_quad(x, cy - light / 2.0, w, light);
paint_quad(cx - light / 2.0, y, light, h);
}
// Heavy lines
0x2501 => paint_quad(x, cy - heavy / 2.0, w, heavy), // Heavy Horizontal
0x2503 => paint_quad(cx - heavy / 2.0, y, heavy, h), // Heavy Vertical
0x250F => { // Heavy Down + Right
paint_quad(cx - heavy / 2.0, cy - heavy / 2.0, heavy, h / 2.0 + heavy / 2.0);
paint_quad(cx - heavy / 2.0, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
}
0x2513 => { // Heavy Down + Left
paint_quad(cx - heavy / 2.0, cy - heavy / 2.0, heavy, h / 2.0 + heavy / 2.0);
paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
}
0x2517 => { // Heavy Up + Right
paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0);
paint_quad(cx - heavy / 2.0, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
}
0x251B => { // Heavy Up + Left
paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0);
paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
}
0x2523 => { // Heavy Vertical + Right
paint_quad(cx - heavy / 2.0, y, heavy, h);
paint_quad(cx - heavy / 2.0, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
}
0x252B => { // Heavy Vertical + Left
paint_quad(cx - heavy / 2.0, y, heavy, h);
paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
}
0x2533 => { // Heavy Horizontal + Down
paint_quad(x, cy - heavy / 2.0, w, heavy);
paint_quad(cx - heavy / 2.0, cy - heavy / 2.0, heavy, h / 2.0 + heavy / 2.0);
}
0x253B => { // Heavy Horizontal + Up
paint_quad(x, cy - heavy / 2.0, w, heavy);
paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0);
}
0x254B => { // Heavy Vertical + Horizontal (Cross)
paint_quad(x, cy - heavy / 2.0, w, heavy);
paint_quad(cx - heavy / 2.0, y, heavy, h);
}
// --- Powerline ---
0xE0B0 => { // Rightward Solid Arrow
let mut path = Path::new(point(x, y));
path.line_to(point(x + w, y + h / 2.0));
path.line_to(point(x, y + h));
path.line_to(point(x, y));
window.paint_path(path, color);
painted = true;
}
0xE0B2 => { // Leftward Solid Arrow
let mut path = Path::new(point(x + w, y));
path.line_to(point(x, y + h / 2.0));
path.line_to(point(x + w, y + h));
path.line_to(point(x + w, y));
window.paint_path(path, color);
painted = true;
}
0xE0B1 => { // Rightward Line Arrow
let t = px(1.0);
let mut p = Path::new(point(x, y));
p.line_to(point(x + w, y + h / 2.0));
p.line_to(point(x + w - t, y + h / 2.0));
p.line_to(point(x, y + t));
p.line_to(point(x, y));
window.paint_path(p, color);
let mut p2 = Path::new(point(x, y + h));
p2.line_to(point(x + w, y + h / 2.0));
p2.line_to(point(x + w - t, y + h / 2.0));
p2.line_to(point(x, y + h - t));
p2.line_to(point(x, y + h));
window.paint_path(p2, color);
painted = true;
}
0xE0B3 => { // Leftward Line Arrow
let t = px(1.0);
let mut p = Path::new(point(x + w, y));
p.line_to(point(x, y + h / 2.0));
p.line_to(point(x + t, y + h / 2.0));
p.line_to(point(x + w, y + t));
p.line_to(point(x + w, y));
window.paint_path(p, color);
let mut p2 = Path::new(point(x + w, y + h));
p2.line_to(point(x, y + h / 2.0));
p2.line_to(point(x + t, y + h / 2.0));
p2.line_to(point(x + w, y + h - t));
p2.line_to(point(x + w, y + h));
window.paint_path(p2, color);
painted = true;
}
// Half arches (Powerline rounded)
0xE0B4 => { // Rightward Solid Semicircle
let mut path = Path::new(point(x, y));
path.curve_to(point(x, y + h), point(x + w * 2.0, y + h / 2.0));
path.line_to(point(x, y));
window.paint_path(path, color);
painted = true;
}
0xE0B6 => { // Leftward Solid Semicircle
let mut path = Path::new(point(x + w, y));
path.curve_to(point(x + w, y + h), point(x - w, y + h / 2.0));
path.line_to(point(x + w, y));
window.paint_path(path, color);
painted = true;
}
_ => {}
}
painted
}
@@ -4,7 +4,7 @@ use alacritty_terminal::{
};
use gpui::{
App, Bounds, Element, ElementId, Entity, FocusHandle, Font, FontStyle, FontWeight,
GlobalElementId, Hsla, InputHandler, IntoElement, LayoutId, Pixels, Point, Rgba,
GlobalElementId, Hsla, InputHandler, IntoElement, LayoutId, Pixels, Point, Rgba, SharedString,
StrikethroughStyle, TextRun, TextStyle, UTF16Selection, UnderlineStyle, Window, fill, point,
px, relative, rgb,
};
@@ -12,6 +12,7 @@ use gpui_component::ActiveTheme as _;
use crate::Ashell;
use crate::terminal::{RenderSnapshot, ViewportSelection};
use crate::terminal::custom_blocks::{paint_custom_block, is_custom_block_supported};
#[derive(Clone, Copy)]
struct TerminalMetrics {
@@ -127,7 +128,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,
@@ -138,12 +139,24 @@ pub struct PrepaintState {
metrics: TerminalMetrics,
rects: Vec<LayoutRect>,
runs: Vec<BatchedTextRun>,
custom_blocks: Vec<LayoutCustomBlock>,
cursor: Option<CursorLayout>,
}
#[derive(Clone)]
struct LayoutCustomBlock {
c: char,
row: i32,
col: i32,
cells: usize,
color: Hsla,
}
struct TerminalInputHandler {
view: Entity<Ashell>,
element_bounds: Bounds<Pixels>,
cell_width: f32,
line_height: f32,
}
impl InputHandler for TerminalInputHandler {
@@ -219,7 +232,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(
@@ -250,7 +263,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,
@@ -270,7 +283,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()
@@ -319,7 +332,7 @@ impl TerminalElement {
color: fg,
background_color: None,
font: Font {
family: self.font_family.into(),
family: self.font_family.clone(),
weight,
style,
..Font::default()
@@ -329,9 +342,10 @@ impl TerminalElement {
}
}
fn layout_grid(&self, cx: &App) -> (Vec<LayoutRect>, Vec<BatchedTextRun>) {
fn layout_grid(&self, cx: &App) -> (Vec<LayoutRect>, Vec<BatchedTextRun>, Vec<LayoutCustomBlock>) {
let mut rects = Vec::new();
let mut runs = Vec::new();
let mut custom_blocks = Vec::new();
let mut current_run: Option<BatchedTextRun> = None;
for render_cell in &self.snapshot.cells {
@@ -350,7 +364,7 @@ impl TerminalElement {
rects.push(LayoutRect {
row: render_cell.row,
col: render_cell.col,
cells: 1,
cells: if cell.flags.contains(Flags::WIDE_CHAR) { 2 } else { 1 },
color: if selected {
cx.theme().selection
} else if cell.flags.contains(Flags::INVERSE) {
@@ -369,6 +383,24 @@ impl TerminalElement {
}
let style = self.cell_run_style(cell, cx);
// Box Drawing & Block Elements interception
let is_custom_block = is_custom_block_supported(cell.c);
if is_custom_block {
if let Some(run) = current_run.take() {
runs.push(run);
}
custom_blocks.push(LayoutCustomBlock {
c: cell.c,
row: render_cell.row,
col: render_cell.col,
cells: if cell.flags.contains(Flags::WIDE_CHAR) { 2 } else { 1 },
color: style.color,
});
continue;
}
if let Some(run) = current_run.as_mut() {
if run.can_append(&style, render_cell.row, render_cell.col) {
run.append(cell.c, cell.zerowidth());
@@ -400,7 +432,7 @@ impl TerminalElement {
runs.push(run);
}
(merge_rects(rects), runs)
(merge_rects(rects), runs, custom_blocks)
}
fn cursor_layout(&self, cx: &App) -> Option<CursorLayout> {
@@ -456,7 +488,7 @@ impl Element for TerminalElement {
cx: &mut App,
) -> Self::PrepaintState {
let _ = self.base_text_style(cx);
let (rects, runs) = self.layout_grid(cx);
let (rects, runs, custom_blocks) = self.layout_grid(cx);
PrepaintState {
bounds,
metrics: TerminalMetrics {
@@ -465,6 +497,7 @@ impl Element for TerminalElement {
},
rects,
runs,
custom_blocks,
cursor: self.cursor_layout(cx),
}
}
@@ -487,11 +520,27 @@ impl Element for TerminalElement {
run.paint(prepaint.bounds.origin, prepaint.metrics, window, cx);
}
for block in &prepaint.custom_blocks {
let x = prepaint.bounds.origin.x.as_f32() + block.col as f32 * prepaint.metrics.cell_width.as_f32();
let y = prepaint.bounds.origin.y.as_f32() + block.row as f32 * prepaint.metrics.line_height.as_f32();
paint_custom_block(
window,
block.c,
x,
y,
prepaint.metrics.cell_width.as_f32() * block.cells as f32,
prepaint.metrics.line_height.as_f32(),
block.color,
);
}
window.handle_input(
&self.focus_handle,
TerminalInputHandler {
view: self.view.clone(),
element_bounds: prepaint.bounds,
cell_width: prepaint.metrics.cell_width.as_f32(),
line_height: prepaint.metrics.line_height.as_f32(),
},
cx,
);
@@ -514,7 +563,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,
+482
View File
@@ -0,0 +1,482 @@
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>,
) {
// Pane navigation: Alt + h/j/k/l
if event.keystroke.modifiers.alt
&& !event.keystroke.modifiers.shift
&& !event.keystroke.modifiers.control
&& !event.keystroke.modifiers.platform
{
match event.keystroke.key.to_ascii_lowercase().as_str() {
"h" => self.focus_adjacent_pane("left"),
"j" => self.focus_adjacent_pane("down"),
"k" => self.focus_adjacent_pane("up"),
"l" => self.focus_adjacent_pane("right"),
"q" => {
if let Some(active_id) = self.active_tab.clone() {
self.close_tab(active_id, cx);
}
}
_ => return,
}
window.prevent_default();
cx.stop_propagation();
cx.notify();
return;
}
// Pane split: Shift+Alt + h/j/k/l
if event.keystroke.modifiers.shift
&& event.keystroke.modifiers.alt
&& !event.keystroke.modifiers.control
&& !event.keystroke.modifiers.platform
{
let direction = match event.keystroke.key.to_ascii_lowercase().as_str() {
"h" => Some("left"),
"j" => Some("down"),
"k" => Some("up"),
"l" => Some("right"),
_ => None,
};
if let Some(dir) = direction {
self.split_current_pane(dir, cx);
window.prevent_default();
cx.stop_propagation();
cx.notify();
return;
}
}
if event.keystroke.modifiers.secondary() && event.keystroke.key == "," {
self.show_settings_dialog(window, cx);
window.prevent_default();
cx.stop_propagation();
return;
}
if event.keystroke.modifiers.shift
&& event.keystroke.modifiers.secondary()
&& event.keystroke.key == "o"
{
self.show_selector_dialog(window, cx);
window.prevent_default();
cx.stop_propagation();
return;
}
if event.keystroke.modifiers.secondary() && event.keystroke.key.eq_ignore_ascii_case("c") {
if let Some(text) = self.active_terminal_selection_text() {
cx.write_to_clipboard(ClipboardItem::new_string(text));
window.prevent_default();
cx.stop_propagation();
return;
}
}
if event.keystroke.modifiers.secondary() && event.keystroke.key.eq_ignore_ascii_case("v") {
if let Some(clipboard) = cx.read_from_clipboard() {
if let Some(text) = clipboard.text() {
self.paste_into_terminal(&text, window, cx);
return;
}
}
}
if event.prefer_character_input {
if let Some(text) = event.keystroke.key_char.as_deref() {
if !text.is_empty()
&& !event.keystroke.modifiers.control
&& !event.keystroke.modifiers.function
&& !event.keystroke.modifiers.platform
{
self.send_terminal_input(text.as_bytes().to_vec(), window, cx);
}
}
return;
}
let Some(active_id) = self.active_tab.clone() else {
return;
};
let Some(tab) = self.tabs.iter_mut().find(|t| t.id == active_id) else {
return;
};
if tab.render_snapshot().display_offset > 0 {
tab.scroll_to_bottom();
}
tab.clear_selection();
if let Some(bytes) = encode_key(&event.keystroke, tab.app_cursor_mode(), false) {
tab.backend.send(BackendCommand::Input(bytes));
window.prevent_default();
cx.stop_propagation();
cx.notify();
}
}
pub(crate) fn on_terminal_tab_action(
&mut self,
_: &TerminalTabKey,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.send_terminal_input(vec![b'\t'], window, cx);
}
pub(crate) fn on_terminal_backtab_action(
&mut self,
_: &TerminalBacktabKey,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.send_terminal_input(b"\x1b[Z".to_vec(), window, cx);
}
fn send_terminal_input(&mut self, bytes: Vec<u8>, window: &mut Window, cx: &mut Context<Self>) {
let Some(active_id) = self.active_tab.clone() else {
return;
};
let Some(tab) = self.tabs.iter_mut().find(|t| t.id == active_id) else {
return;
};
if tab.render_snapshot().display_offset > 0 {
tab.scroll_to_bottom();
}
tab.clear_selection();
tab.backend.send(BackendCommand::Input(bytes));
window.prevent_default();
cx.stop_propagation();
cx.notify();
}
fn active_terminal_selection_text(&self) -> Option<String> {
let active_id = self.active_tab.as_ref()?;
self.tabs
.iter()
.find(|tab| &tab.id == active_id)
.and_then(|tab| tab.selection_text())
}
fn paste_into_terminal(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
let Some(active_id) = self.active_tab.clone() else {
return;
};
let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) else {
return;
};
if tab.render_snapshot().display_offset > 0 {
tab.scroll_to_bottom();
}
tab.clear_selection();
tab.paste_text(text);
window.prevent_default();
cx.stop_propagation();
cx.notify();
}
pub(crate) fn terminal_accepts_text_input(&self) -> bool {
self.active_tab.is_some()
}
pub(crate) fn terminal_marked_text_range(&self) -> Option<Range<usize>> {
self.terminal_marked_text
.as_ref()
.map(|text| 0..text.encode_utf16().count())
}
pub(crate) fn set_terminal_marked_text(
&mut self,
text: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.terminal_marked_text = if text.is_empty() { None } else { Some(text) };
window.invalidate_character_coordinates();
cx.notify();
}
pub(crate) fn clear_terminal_marked_text(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.terminal_marked_text.take().is_some() {
window.invalidate_character_coordinates();
cx.notify();
}
}
pub(crate) fn commit_terminal_ime_text(
&mut self,
text: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(active_id) = self.active_tab.clone() else {
return;
};
let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) else {
return;
};
if tab.render_snapshot().display_offset > 0 {
tab.scroll_to_bottom();
}
tab.clear_selection();
self.terminal_marked_text = None;
tab.backend
.send(BackendCommand::Input(text.as_bytes().to_vec()));
window.invalidate_character_coordinates();
cx.notify();
}
pub(crate) fn on_terminal_right_click(
&mut self,
_event: &MouseDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.config.right_click_copy_paste() {
return;
}
let mut handled = false;
if let Some(text) = self.active_terminal_selection_text() {
if !text.is_empty() {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
let active_id = self.active_tab.clone();
if let Some(active_id) = active_id {
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
tab.clear_selection();
}
}
cx.notify();
handled = true;
}
}
if !handled {
if let Some(clipboard_item) = cx.read_from_clipboard() {
if let Some(text) = clipboard_item.text() {
if !text.is_empty() {
self.paste_into_terminal(&text, window, cx);
}
}
}
}
}
pub(crate) fn begin_terminal_selection(&mut self, event: &MouseDownEvent, cx: &mut Context<Self>) {
let click_count = event.click_count.max(1);
let selection_type = match click_count {
1 => SelectionType::Simple,
2 => SelectionType::Semantic,
3 => SelectionType::Lines,
_ => SelectionType::Simple,
};
let Some((row, col, side)) = self.terminal_grid_point_and_side(event.position) else {
return;
};
let Some(active_id) = self.active_tab.clone() else {
return;
};
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
tab.begin_selection(row, col, side, selection_type);
self.terminal_selecting = true;
cx.notify();
}
}
pub(crate) fn on_terminal_mouse_move(
&mut self,
event: &MouseMoveEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
// Handle split drag
if self.dragging_splitter.is_some() {
if event.pressed_button == Some(MouseButton::Left) {
self.on_split_drag_move(event, window, cx);
cx.notify();
} else {
self.end_drag_split();
cx.notify();
}
return;
}
if !self.terminal_selecting || event.pressed_button != Some(MouseButton::Left) {
return;
}
let Some((row, col, side)) = self.terminal_grid_point_and_side(event.position) else {
return;
};
let Some(active_id) = self.active_tab.clone() else {
return;
};
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
tab.update_selection(row, col, side);
cx.notify();
}
}
pub(crate) fn on_terminal_mouse_up(
&mut self,
_event: &MouseUpEvent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if self.dragging_splitter.is_some() {
self.end_drag_split();
}
self.terminal_selecting = false;
cx.notify();
}
fn terminal_grid_point_and_side(
&self,
position: Point<Pixels>,
) -> Option<(usize, usize, Side)> {
let active_id = self.active_tab.as_ref()?;
let bounds = self.terminal_bounds.get(active_id)?;
if !bounds.contains(&position) {
// Try other pane bounds
for (_, b) in &self.terminal_bounds {
if b.contains(&position) {
// Found a different pane - focus it
// (this path is for click-to-focus; handled via focus_terminal)
return None;
}
}
return None;
}
let local_x = (position.x - bounds.origin.x).max(px(0.));
let local_y = (position.y - bounds.origin.y).max(px(0.));
let cell_width = px(self.terminal_cell_width());
let line_height = px(self.terminal_line_height());
let snapshot = self.active_snapshot()?;
let max_col = snapshot.cols.saturating_sub(1);
let max_row = snapshot.rows.saturating_sub(1);
let col = ((local_x / cell_width).floor() as usize).min(max_col);
let row = ((local_y / line_height).floor() as usize).min(max_row);
let cell_offset_x = px(local_x.as_f32() % cell_width.as_f32());
let side = if cell_offset_x >= (cell_width / 2.) {
Side::Right
} else {
Side::Left
};
Some((row, col, side))
}
pub(crate) fn on_terminal_scroll(
&mut self,
event: &ScrollWheelEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(active_id) = self.active_tab.clone() else {
return;
};
// Get coordinates before mutably borrowing tabs
let grid_point = self.terminal_grid_point_and_side(event.position);
let line_height = self.terminal_line_height();
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
let delta_lines = match event.delta {
ScrollDelta::Lines(point) => point.y.round() as i32,
ScrollDelta::Pixels(point) => {
tab.scroll_pixel_y += point.y.as_f32();
let lines = (tab.scroll_pixel_y / line_height).trunc() as i32;
tab.scroll_pixel_y -= (lines as f32) * line_height;
lines
}
};
if delta_lines == 0 {
return;
}
let mode = tab.term.mode();
let is_mouse_tracking = mode.intersects(
alacritty_terminal::term::TermMode::MOUSE_REPORT_CLICK
| alacritty_terminal::term::TermMode::MOUSE_MOTION
| alacritty_terminal::term::TermMode::MOUSE_DRAG,
);
let is_alternate_scroll = mode.contains(
alacritty_terminal::term::TermMode::ALT_SCREEN
| alacritty_terminal::term::TermMode::ALTERNATE_SCROLL,
);
if is_mouse_tracking {
if let Some((row, col, _)) = grid_point {
let sgr = mode.contains(alacritty_terminal::term::TermMode::SGR_MOUSE);
let button = if delta_lines > 0 { 64 } else { 65 };
let times = delta_lines.abs();
let mut bytes = Vec::new();
for _ in 0..times {
if sgr {
bytes.extend_from_slice(
format!("\x1b[<{};{};{}M", button, col + 1, row + 1).as_bytes(),
);
} else {
if col < 223 && row < 223 {
bytes.extend_from_slice(b"\x1b[M");
bytes.push(button as u8 + 32);
bytes.push(col as u8 + 33);
bytes.push(row as u8 + 33);
}
}
}
if !bytes.is_empty() {
tab.backend.send(crate::terminal::BackendCommand::Input(bytes));
}
}
window.prevent_default();
cx.stop_propagation();
return;
} else if is_alternate_scroll {
let times = delta_lines.abs();
let code = if delta_lines > 0 { b'A' } else { b'B' };
let mut bytes = Vec::with_capacity((times * 3) as usize);
for _ in 0..times {
bytes.extend_from_slice(&[b'\x1b', b'O', code]);
}
if !bytes.is_empty() {
tab.backend.send(crate::terminal::BackendCommand::Input(bytes));
}
window.prevent_default();
cx.stop_propagation();
return;
}
tab.scroll_history(delta_lines);
window.prevent_default();
cx.stop_propagation();
cx.notify();
}
}
}
+22 -16
View File
@@ -1,3 +1,7 @@
pub mod element;
pub mod input;
pub mod custom_blocks;
use std::sync::mpsc::Sender;
use alacritty_terminal::{
@@ -10,7 +14,7 @@ use alacritty_terminal::{
};
use gpui::Keystroke;
use crate::config::Session;
use crate::session::config::Session;
use crate::sftp::{PreviewData, RemoteEntry};
use crate::system::SystemSnapshot;
@@ -24,6 +28,7 @@ pub enum TabKind {
pub enum BackendCommand {
Input(Vec<u8>),
Resize { cols: u16, rows: u16 },
SampleMetrics,
Close,
}
@@ -61,6 +66,10 @@ pub enum BackendEvent {
tab_id: String,
reason: String,
},
SftpHome {
tab_id: String,
home: String,
},
TransferProgress {
#[allow(dead_code)]
tab_id: String,
@@ -109,12 +118,12 @@ pub struct TerminalTab {
pub status: String,
pub connected: bool,
pub session: Option<Session>,
pub sftp: Option<SftpUiState>,
processor: Processor,
term: Term<TerminalListener>,
cols: u16,
rows: u16,
pub backend: BackendTx,
pub scroll_pixel_y: f32,
}
#[derive(Clone, Copy)]
@@ -159,6 +168,7 @@ pub struct SftpUiState {
pub selected_path: Option<String>,
pub preview: Option<PreviewData>,
pub selected_entries: std::collections::HashSet<String>,
pub home_dir: String,
}
impl TerminalTab {
@@ -197,14 +207,6 @@ impl TerminalTab {
);
tab.session = Some(session.clone());
tab.connected = false;
tab.sftp = Some(SftpUiState {
current_path: "/".into(),
status: t!("sftp_connecting").to_string(),
entries: Vec::new(),
selected_path: None,
preview: None,
selected_entries: std::collections::HashSet::new(),
});
tab
}
@@ -223,12 +225,12 @@ impl TerminalTab {
status,
connected: matches!(kind, TabKind::Local),
session: None,
sftp: None,
processor: Processor::new(),
term: new_term(100, 30, backend.clone(), id, events),
cols: 100,
rows: 30,
backend,
scroll_pixel_y: 0.0,
}
}
@@ -237,10 +239,15 @@ impl TerminalTab {
}
pub fn resize(&mut self, cols: u16, rows: u16) {
self.cols = cols.max(1);
self.rows = rows.max(1);
self.term.resize(TerminalSize::new(self.cols, self.rows));
self.backend.send(BackendCommand::Resize { cols, rows });
let new_cols = cols.max(1);
let new_rows = rows.max(1);
if self.cols != new_cols || self.rows != new_rows {
self.cols = new_cols;
self.rows = new_rows;
tracing::info!("[ui] terminal resized to {}x{} (cols x rows)", self.cols, self.rows);
self.term.resize(TerminalSize::new(self.cols, self.rows));
self.backend.send(BackendCommand::Resize { cols, rows });
}
}
pub fn cursor_state(&self) -> Option<CursorState> {
@@ -437,7 +444,6 @@ impl EventListener for TerminalListener {
}
}
use rust_i18n::t;
fn new_term(
cols: u16,
rows: u16,