Compare commits

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

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

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

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

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

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

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

验证:

- cargo fmt

- cargo check

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

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

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

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

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

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

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

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

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

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

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

**Double-Frame Delay Update Reason**: When expanding the panel from 1 child back to 2 children, ResizableState needs one full render frame to recognize the child count change, append the new panel, and initialize its zero-size defaults. If we update the sizes synchronously on the first frame, ResizableState would overwrite our manual sizes during its layout initialization. Waiting for a double-frame delay guarantees that ResizableState has fully populated the second panel in its internal state, allowing us to accurately restore the user's previously dragged height without being overridden by the layout engine.
2026-06-13 02:42:04 +08:00
TomZz 6f887712e6 style: Simplify terminal title bar and remove redundant path display 2026-06-12 21:26:18 +08:00
TomZz 99071997d5 Merge PR 9, refactor codebase and manually resolve conflicts while retaining all recent main branch features 2026-06-12 20:38:11 +08:00
TomZz e35a2458bb Merge branch 'pr-9' into resolve-pr-9 2026-06-12 20:34:34 +08:00
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
57 changed files with 36787 additions and 7203 deletions
+64
View File
@@ -0,0 +1,64 @@
name: CI
on:
push:
branches: ["main", "master"]
pull_request:
branches: ["main", "master"]
jobs:
build:
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: windows-2022
target: x86_64-pc-windows-msvc
name: windows-x64
- os: windows-2022
target: i686-pc-windows-msvc
name: windows-x86
- os: windows-2022
target: aarch64-pc-windows-msvc
name: windows-arm64
- os: ubuntu-22.04 # build on the oldest supported runner for broader glibc compatibility
target: x86_64-unknown-linux-gnu
name: linux-x86_64
- os: macos-14 # Apple Silicon
target: aarch64-apple-darwin
name: macos-arm64
- os: macos-14 # Intel (cross-compiled from Apple Silicon to avoid queue delays)
target: x86_64-apple-darwin
name: macos-x64
steps:
- uses: actions/checkout@v4
# GPUI needs these system libs on Linux to compile.
- name: Install Linux GUI dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential pkg-config cmake \
libfontconfig1-dev libfreetype6-dev \
libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \
libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev \
libgl1-mesa-dev libegl1-mesa-dev libgtk-3-dev \
libudev-dev
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Build (release)
# We build in release mode so that the release workflow can reuse the exact artifacts
run: cargo build --release --target ${{ matrix.target }}
+440 -52
View File
@@ -1,42 +1,80 @@
name: Release
# Build native binaries for Windows / Linux / macOS.
# Build native installers and portable archives for Windows / Linux / macOS.
# - Push a tag like `v0.2.3` -> builds all platforms and attaches the archives
# to a GitHub Release.
# - Run manually (workflow_dispatch) -> builds all platforms and uploads them as
# downloadable workflow artifacts (no release created).
# - Push a `build/**` branch -> builds preview installers as workflow artifacts.
# - Run manually (workflow_dispatch) -> builds all platforms by default, or only
# the selected target, and uploads them as downloadable workflow artifacts.
on:
push:
branches: ["build/**"]
tags: ["v*"]
workflow_dispatch:
inputs:
platform:
description: "Build target"
required: true
type: choice
default: all
options:
- all
- windows-x64
- macos-arm64
permissions:
contents: write # needed to create / update the GitHub Release
contents: write # needed to create / update the GitHub Release
jobs:
build:
if: >-
github.ref != 'refs/heads/build/windows-x64' &&
github.ref != 'refs/heads/build/macos-arm64' &&
(github.event_name != 'workflow_dispatch' || inputs.platform == 'all')
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
- os: windows-2022
target: x86_64-pc-windows-msvc
name: windows-x86_64
name: windows-x64
kind: windows
arch: x64
bin: ashell.exe
- os: ubuntu-22.04 # build on the oldest supported runner for broader glibc compatibility
- os: windows-2022
target: i686-pc-windows-msvc
name: windows-x86
kind: windows
arch: x86
bin: ashell.exe
- os: windows-2022
target: aarch64-pc-windows-msvc
name: windows-arm64
kind: windows
arch: arm64
bin: ashell.exe
- os: ubuntu-22.04 # build on the oldest supported runner for broader glibc compatibility
target: x86_64-unknown-linux-gnu
name: linux-x86_64
name: linux-x64
kind: linux
arch: x64
bin: ashell
- os: macos-14 # Apple Silicon
- os: macos-14 # Apple Silicon
target: aarch64-apple-darwin
name: macos-aarch64
name: macos-arm64
kind: macos
arch: arm64
binary_arch: arm64
bin: ashell
- os: macos-14 # Intel (cross-compiled from Apple Silicon to avoid queue delays)
- os: macos-14 # Intel (cross-compiled from Apple Silicon to avoid queue delays)
target: x86_64-apple-darwin
name: macos-x86_64
name: macos-x64
kind: macos
arch: x64
binary_arch: x86_64
bin: ashell
steps:
@@ -68,28 +106,46 @@ jobs:
- name: Build (release)
run: cargo build --release --target ${{ matrix.target }}
- name: Package
- name: Set package metadata
shell: bash
run: |
set -eu
VERSION="${GITHUB_REF_NAME:-dev}"
STAGE="ashell-${VERSION}-${{ matrix.name }}"
set -euo pipefail
VERSION="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n 1)"
if [ -z "$VERSION" ]; then
echo "Unable to read the package version from Cargo.toml" >&2
exit 1
fi
if [ "${{ runner.os }}" = "macOS" ]; then
# ── Build a proper .app bundle ────────────────────────────────────
# Note: We build the bundle manually here rather than running
# scripts/package-macos-app.sh, because that script runs `cargo build`
# without respecting the `--target` flag from the matrix.
APP="ashell.app"
CONTENTS="$APP/Contents"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
if [ "$GITHUB_REF_TYPE" = "tag" ]; then
PACKAGE_VERSION="${GITHUB_REF_NAME#v}"
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
echo "Tag version $PACKAGE_VERSION does not match Cargo.toml version $VERSION" >&2
exit 1
fi
else
PACKAGE_VERSION="${VERSION}-dev.${GITHUB_SHA:0:7}"
fi
cp "target/${{ matrix.target }}/release/ashell" "$CONTENTS/MacOS/"
cp "assets/icons/ashell.icns" "$CONTENTS/Resources/ashell.icns"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> "$GITHUB_ENV"
echo "PACKAGE_BASENAME=ashell-v${PACKAGE_VERSION}-${{ matrix.name }}" >> "$GITHUB_ENV"
VERSION_NUM="${VERSION#v}"
cat > "$CONTENTS/Info.plist" <<EOF
- name: Package macOS app, DMG, and portable archive
if: matrix.kind == 'macos'
shell: bash
run: |
set -euo pipefail
APP_ROOT="$RUNNER_TEMP/${PACKAGE_BASENAME}-app"
DMG_ROOT="$RUNNER_TEMP/${PACKAGE_BASENAME}-dmg"
APP="$APP_ROOT/ashell.app"
CONTENTS="$APP/Contents"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources" "$DMG_ROOT" dist
cp "target/${{ matrix.target }}/release/ashell" "$CONTENTS/MacOS/ashell"
chmod 755 "$CONTENTS/MacOS/ashell"
cp "assets/icons/ashell.icns" "$CONTENTS/Resources/ashell.icns"
cat > "$CONTENTS/Info.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -110,9 +166,9 @@ jobs:
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${VERSION_NUM}</string>
<string>${VERSION}</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>${GITHUB_RUN_NUMBER}</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>NSHighResolutionCapable</key>
@@ -121,35 +177,284 @@ jobs:
</plist>
EOF
printf 'APPL????' > "$CONTENTS/PkgInfo"
printf 'APPL????' > "$CONTENTS/PkgInfo"
plutil -lint "$CONTENTS/Info.plist"
# Ad-hoc code-sign the assembled bundle.
codesign --force --deep --sign - "$APP"
codesign --verify --verbose "$APP"
# Release builds remain ad-hoc signed until signing credentials are configured.
codesign --force --deep --sign - "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"
# Package with ditto (Apple's tool) to preserve signature/metadata
ditto -c -k --keepParent "$APP" "${STAGE}.zip"
echo "ASSET=${STAGE}.zip" >> "$GITHUB_ENV"
ditto -c -k --sequesterRsrc --keepParent \
"$APP" "dist/${PACKAGE_BASENAME}-portable.zip"
ditto "$APP" "$DMG_ROOT/ashell.app"
ln -s /Applications "$DMG_ROOT/Applications"
hdiutil create \
-volname "ashell ${VERSION}" \
-srcfolder "$DMG_ROOT" \
-ov \
-format UDZO \
"dist/${PACKAGE_BASENAME}.dmg"
elif [ "${{ runner.os }}" = "Windows" ]; then
mkdir "$STAGE"
cp "target/${{ matrix.target }}/release/${{ matrix.bin }}" "$STAGE/"
7z a "${STAGE}.zip" "$STAGE" >/dev/null
echo "ASSET=${STAGE}.zip" >> "$GITHUB_ENV"
else
# Linux
mkdir "$STAGE"
cp "target/${{ matrix.target }}/release/${{ matrix.bin }}" "$STAGE/"
tar -czf "${STAGE}.tar.gz" "$STAGE"
echo "ASSET=${STAGE}.tar.gz" >> "$GITHUB_ENV"
EXPECTED_ARCH="${{ matrix.binary_arch }}"
ACTUAL_ARCHS="$(lipo -archs "$CONTENTS/MacOS/ashell")"
if [ "$ACTUAL_ARCHS" != "$EXPECTED_ARCH" ]; then
echo "Expected $EXPECTED_ARCH binary, found $ACTUAL_ARCHS" >&2
exit 1
fi
shasum -a 256 dist/*
- name: Package Windows installer and portable archive
if: matrix.kind == 'windows'
shell: pwsh
env:
ASHELL_ARCH: ${{ matrix.arch }}
ASHELL_BINARY_PATH: ${{ github.workspace }}\target\${{ matrix.target }}\release\ashell.exe
ASHELL_OUTPUT_DIR: ${{ github.workspace }}\dist
run: |
$ErrorActionPreference = "Stop"
New-Item -ItemType Directory -Path "dist" -Force | Out-Null
$isccCommand = Get-Command "ISCC.exe" -ErrorAction SilentlyContinue
if ($null -ne $isccCommand) {
$iscc = $isccCommand.Source
} else {
$iscc = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
}
if (-not (Test-Path $iscc)) {
throw "Inno Setup compiler was not found"
}
& $iscc "packaging\windows\ashell.iss"
if ($LASTEXITCODE -ne 0) {
throw "Inno Setup failed with exit code $LASTEXITCODE"
}
$installer = "dist\${env:PACKAGE_BASENAME}-setup.exe"
if (-not (Test-Path $installer)) {
throw "Expected installer was not created: $installer"
}
$stage = Join-Path $env:RUNNER_TEMP $env:PACKAGE_BASENAME
New-Item -ItemType Directory -Path $stage | Out-Null
Copy-Item $env:ASHELL_BINARY_PATH (Join-Path $stage "ashell.exe")
Compress-Archive -Path $stage -DestinationPath "dist\${env:PACKAGE_BASENAME}-portable.zip"
Get-FileHash -Algorithm SHA256 "dist\*"
- name: Package Linux portable archive
if: matrix.kind == 'linux'
shell: bash
run: |
set -euo pipefail
STAGE="$RUNNER_TEMP/$PACKAGE_BASENAME"
mkdir -p "$STAGE" dist
cp "target/${{ matrix.target }}/release/${{ matrix.bin }}" "$STAGE/"
tar -C "$RUNNER_TEMP" -czf "dist/${PACKAGE_BASENAME}.tar.gz" "$PACKAGE_BASENAME"
sha256sum dist/*
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.name }}
path: ${{ env.ASSET }}
path: dist/*
if-no-files-found: error
retention-days: 14
build-windows-x64:
if: >-
github.ref == 'refs/heads/build/windows-x64' ||
(github.event_name == 'workflow_dispatch' && inputs.platform == 'windows-x64')
name: windows-x64
runs-on: windows-2022
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
with:
key: x86_64-pc-windows-msvc
- name: Build (release)
run: cargo build --release --target x86_64-pc-windows-msvc
- name: Set package metadata
shell: bash
run: |
set -euo pipefail
VERSION="$(sed -n 's/^version = "\([^\"]*\)"/\1/p' Cargo.toml | head -n 1)"
if [ -z "$VERSION" ]; then
echo "Unable to read the package version from Cargo.toml" >&2
exit 1
fi
PACKAGE_VERSION="${VERSION}-dev.${GITHUB_SHA:0:7}"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> "$GITHUB_ENV"
echo "PACKAGE_BASENAME=ashell-v${PACKAGE_VERSION}-windows-x64" >> "$GITHUB_ENV"
- name: Package Windows installer and portable archive
shell: pwsh
env:
ASHELL_ARCH: x64
ASHELL_BINARY_PATH: ${{ github.workspace }}\target\x86_64-pc-windows-msvc\release\ashell.exe
ASHELL_OUTPUT_DIR: ${{ github.workspace }}\dist
run: |
$ErrorActionPreference = "Stop"
New-Item -ItemType Directory -Path "dist" -Force | Out-Null
$isccCommand = Get-Command "ISCC.exe" -ErrorAction SilentlyContinue
if ($null -ne $isccCommand) {
$iscc = $isccCommand.Source
} else {
$iscc = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
}
if (-not (Test-Path $iscc)) {
throw "Inno Setup compiler was not found"
}
& $iscc "packaging\windows\ashell.iss"
if ($LASTEXITCODE -ne 0) {
throw "Inno Setup failed with exit code $LASTEXITCODE"
}
$installer = "dist\${env:PACKAGE_BASENAME}-setup.exe"
if (-not (Test-Path $installer)) {
throw "Expected installer was not created: $installer"
}
$stage = Join-Path $env:RUNNER_TEMP $env:PACKAGE_BASENAME
New-Item -ItemType Directory -Path $stage | Out-Null
Copy-Item $env:ASHELL_BINARY_PATH (Join-Path $stage "ashell.exe")
Compress-Archive -Path $stage -DestinationPath "dist\${env:PACKAGE_BASENAME}-portable.zip"
Get-FileHash -Algorithm SHA256 "dist\*"
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: windows-x64
path: dist/*
if-no-files-found: error
retention-days: 14
build-macos-arm64:
if: >-
github.ref == 'refs/heads/build/macos-arm64' ||
(github.event_name == 'workflow_dispatch' && inputs.platform == 'macos-arm64')
name: macos-arm64
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
with:
key: aarch64-apple-darwin
- name: Build (release)
run: cargo build --release --target aarch64-apple-darwin
- name: Set package metadata
shell: bash
run: |
set -euo pipefail
VERSION="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n 1)"
if [ -z "$VERSION" ]; then
echo "Unable to read the package version from Cargo.toml" >&2
exit 1
fi
PACKAGE_VERSION="${VERSION}-dev.${GITHUB_SHA:0:7}"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> "$GITHUB_ENV"
echo "PACKAGE_BASENAME=ashell-v${PACKAGE_VERSION}-macos-arm64" >> "$GITHUB_ENV"
- name: Package macOS app, DMG, and portable archive
shell: bash
run: |
set -euo pipefail
APP_ROOT="$RUNNER_TEMP/${PACKAGE_BASENAME}-app"
DMG_ROOT="$RUNNER_TEMP/${PACKAGE_BASENAME}-dmg"
APP="$APP_ROOT/ashell.app"
CONTENTS="$APP/Contents"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources" "$DMG_ROOT" dist
cp "target/aarch64-apple-darwin/release/ashell" "$CONTENTS/MacOS/ashell"
chmod 755 "$CONTENTS/MacOS/ashell"
cp "assets/icons/ashell.icns" "$CONTENTS/Resources/ashell.icns"
cat > "$CONTENTS/Info.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>ashell</string>
<key>CFBundleIconFile</key>
<string>ashell.icns</string>
<key>CFBundleIdentifier</key>
<string>dev.ashell.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>ashell</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${VERSION}</string>
<key>CFBundleVersion</key>
<string>${GITHUB_RUN_NUMBER}</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
EOF
printf 'APPL????' > "$CONTENTS/PkgInfo"
plutil -lint "$CONTENTS/Info.plist"
codesign --force --deep --sign - "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"
ditto -c -k --sequesterRsrc --keepParent \
"$APP" "dist/${PACKAGE_BASENAME}-portable.zip"
ditto "$APP" "$DMG_ROOT/ashell.app"
ln -s /Applications "$DMG_ROOT/Applications"
hdiutil create \
-volname "ashell ${VERSION}" \
-srcfolder "$DMG_ROOT" \
-ov \
-format UDZO \
"dist/${PACKAGE_BASENAME}.dmg"
EXPECTED_ARCH="arm64"
ACTUAL_ARCHS="$(lipo -archs "$CONTENTS/MacOS/ashell")"
if [ "$ACTUAL_ARCHS" != "$EXPECTED_ARCH" ]; then
echo "Expected $EXPECTED_ARCH binary, found $ACTUAL_ARCHS" >&2
exit 1
fi
shasum -a 256 dist/*
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: macos-arm64
path: dist/*
if-no-files-found: error
retention-days: 14
publish:
name: Publish Release
@@ -161,10 +466,93 @@ jobs:
uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Attach to GitHub Release
uses: softprops/action-gh-release@v2
with:
files: dist/**/*
files: dist/*
generate_release_notes: true
fail_on_unmatched_files: true
cask:
name: Update Homebrew Cask
needs: publish
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- name: Checkout Homebrew Tap
uses: actions/checkout@v4
with:
repository: rust-kotlin/homebrew-taps
token: ${{ secrets.TAP_GITHUB_TOKEN }}
path: tap
- name: Download all macOS artifacts
uses: actions/download-artifact@v4
with:
pattern: macos-*
path: dist
merge-multiple: true
- name: Update Cask
run: |
VERSION=${GITHUB_REF#refs/tags/v}
SHA_ARM=$(sha256sum "dist/ashell-v${VERSION}-macos-arm64.dmg" | cut -d' ' -f1)
SHA_INTEL=$(sha256sum "dist/ashell-v${VERSION}-macos-x64.dmg" | cut -d' ' -f1)
mkdir -p tap/Casks
CASK_FILE="tap/Casks/ashell.rb"
cat > "$CASK_FILE" <<EOF
cask "ashell" do
version "${VERSION}"
on_arm do
sha256 "${SHA_ARM}"
url "https://github.com/rust-kotlin/ashell/releases/download/v#{version}/ashell-v#{version}-macos-arm64.dmg"
end
on_intel do
sha256 "${SHA_INTEL}"
url "https://github.com/rust-kotlin/ashell/releases/download/v#{version}/ashell-v#{version}-macos-x64.dmg"
end
name "ashell"
desc "ashell is a modern, fast, GPUI Component-based desktop terminal client written in Rust."
homepage "https://github.com/rust-kotlin/ashell"
app "ashell.app"
postflight do
puts "Removing quarantine flag from ashell.app..."
system_command "/usr/bin/xattr",
args: ["-rd", "com.apple.quarantine", "#{appdir}/ashell.app"],
sudo: true
end
caveats <<~EOS
ashell uses ad-hoc signing. The installation includes a postflight script
that removes the 'com.apple.quarantine' attribute from the application
to allow it to run. This will require your administrator password.
ashell 采用本地签名。安装程序包含一个后置脚本,用于移除应用程序的
'com.apple.quarantine' 属性以允许其运行。这需要您的管理员密码。
EOS
end
EOF
# Remove the 10 leading spaces added for YAML formatting
sed -i 's/^ //' "$CASK_FILE"
- name: Commit and Push
run: |
VERSION=${GITHUB_REF#refs/tags/v}
cd tap
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if [[ -n $(git status -s) ]]; then
git add Casks/ashell.rb
git commit -m "chore: bump ashell to v${VERSION}"
git push
else
echo "No changes to commit"
fi
+2 -1
View File
@@ -1,2 +1,3 @@
/target/
dist/
target/
.DS_Store
+39
View File
@@ -0,0 +1,39 @@
# 更新日志
本文档记录 ashell 的重要功能变更。
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循
[语义化版本](https://semver.org/lang/zh-CN/)。
## [v0.5.0] - 2026-08-27
### 新增
- 支持 ANSI 转义序列中的 F1–F12 功能键映射,解决在终端工具(如 `htop`、`mc`、`vim` 等)中按 F1–F12 无法响应的问题 (#91)。
- 连接管理新增分组功能,支持创建分组、重命名、删除分组、折叠展开与按组归类管理连接 (#108)。
- 扩展终端通知协议支持,兼容 OSC 9、OSC 99、OSC 777 和 BEL (响铃),支持 macOS Dock 与 Windows 任务栏未读徽标,后台标签页显示闪烁提醒,点击通知可自动唤醒窗口并切换至对应标签页 (#110)。
- 新增系统级应用菜单,提供包含关于、文件、编辑、视图、窗口和帮助等常用操作 (#111)。
### 变更
- 重新设计侧边栏连接管理交互:侧边栏头部增加显式的“编辑 / 完成”模式切换按钮,编辑模式下在搜索框下方提供全宽“+ 新增分组”按钮,并移除侧边栏右下角切换 Switch 开关。
- 优化侧边栏连接项点击体验:非编辑模式下点击整行即可直接发起连接并隐藏冗余外链图标;允许侧边栏宽度自定义拖拽调小至 200px。
- 将 CSV 导入与导出入口位置由侧边栏头部整合移至设置对话框的“配置文件与备份”页面中。
- 优化标签栏加号下拉菜单:将第二个菜单项更新为“打开连接”,点击后直接弹出连接选择器对话框。
- 支持侧边栏完全折叠隐藏,折叠后隐藏连接列表并最大化终端界面绘制区域。
### 修复
- 修复 SSH 历史命令输入时的残留字符与光标定位偏差问题 (#114)。
- 优化 Windows 平台下终端拖拽与窗口细节表现 (#112)。
### 兼容性与稳定性
- 代码架构模块化重构:将核心代码拆分为 `session`、`terminal`、`app`、`backend`、`sftp` 与 `sync` 模块,提升可维护性 (#105-#107)。
- 优化 GitHub Actions CI 构建工作流 (#109)。
### 注意事项
- CSV 导出的密码为明文格式,请妥善保管并在迁移后及时删除导出的 CSV 文件。
Generated
+767 -59
View File
File diff suppressed because it is too large Load Diff
+60 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "ashell"
version = "0.1.0"
version = "0.5.2-fix1"
edition = "2024"
authors = ["ashell contributors"]
description = "A GPUI Component based SSH and local terminal client"
@@ -9,13 +9,16 @@ rust-version = "1.85.0"
[dependencies]
anyhow = "1"
tokio-socks = "0.5"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "fcf32feacb367b75ec84dd40f041e4fd411d3cc1" }
async-trait = "0.1"
chrono = { version = "0.4", features = ["serde"] }
csv = "1"
directories = "5"
encoding_rs = "0.8"
flate2 = "1"
gpui = { git = "https://github.com/zed-industries/zed" }
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "runtime_shaders"] }
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" }
@@ -31,10 +34,11 @@ serde_json = "1"
ssh-key = "0.6"
sysinfo = "0.33"
tar = "0.4"
tempfile = "3"
thiserror = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "net", "time", "fs"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-subscriber = { version = "0.3", features = ["env-filter", "local-time", "time"] }
uuid = { version = "1", features = ["v4", "serde"] }
walkdir = "2"
zip = { version = "2", default-features = false, features = ["deflate"] }
@@ -42,6 +46,54 @@ image = { version = "0.25.9", default-features = false, features = ["png"] }
futures = "0.3.32"
notify = "6.1.1"
open = "5.1"
time = { version = "0.3.45", features = ["formatting", "local-offset"] }
tracing-appender = "0.2.5"
argon2 = "0.5"
base64 = "0.22"
chacha20poly1305 = "0.10"
hmac = "0.12"
hex = "0.4"
serialport = "4"
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
sha2 = "0.10"
[dev-dependencies]
libc = "0.2"
[target.'cfg(target_os = "windows")'.dependencies]
raw-window-handle = "0.6"
winrt-notification = { package = "tauri-winrt-notification", version = "=0.2.1" }
windows = { version = "0.57", features = [
"Win32_Foundation",
"Win32_System_Com",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
"UI_Notifications",
] }
winreg = "0.52"
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
desktop-notify = { package = "notify-rust", version = "=4.11.5", default-features = false }
[target.'cfg(target_os = "macos")'.dependencies]
mac-notification-sys = "=0.6.15"
objc2 = "0.6.4"
objc2-app-kit = { version = "0.3.2", features = ["NSApplication", "NSDockTile"] }
objc2-foundation = { version = "0.3.2", features = ["NSString", "NSUserNotification"] }
[package.metadata.deb]
maintainer = "ashell contributors"
license-file = ["LICENSE"]
extended-description = "A GPUI Component based SSH and local terminal client"
section = "terminal"
priority = "optional"
assets = [
["target/release/ashell", "usr/bin/", "755"],
["assets/ashell.desktop", "usr/share/applications/", "644"],
["assets/icons/ashell.png", "usr/share/icons/hicolor/256x256/apps/", "644"],
]
[profile.release]
opt-level = 3
@@ -51,3 +103,8 @@ strip = true
[build-dependencies]
winres = "0.1.12"
# Remove these overrides after GPUI moves off block 0.1 and stacksafe 0.1.
[patch.crates-io]
block = { git = "https://github.com/Dicklesworthstone/rust-block", rev = "b39ae859d1ee8e8cb5eef6a516471f1578d26b96" }
stacksafe-macro = { path = "patches/stacksafe-macro" }
+109 -11
View File
@@ -8,29 +8,66 @@
This project focuses on providing a high-performance and visually appealing shell workspace by combining local and remote environments with a rich set of built-in features.
## 🚀 v0.3 Massive Update!
## 🚀 v0.4 Major Upgrades
The brand-new v0.3 introduces comprehensive upgrades to both architecture and user experience:
- ✨ **Global Real-Time Font Settings** (Thanks to [@ancion](https://github.com/ancion) for [PR #6](https://github.com/rust-kotlin/ashell/pull/6)): Customize your global UI fonts and terminal rendering fonts independently. Adjust text size in real-time with automatic persistence.
- ✨ **Concurrent SFTP Transfers**: A dedicated Transfers dialog now supports highly optimized, concurrent file uploads and downloads with precise progress display.
- ✨ **Persistent Workspace State**: Your sidebar width adjustments, terminal themes, font settings, and file visibility preferences are completely restored upon your next launch!
- ✨ **Intelligent SSH Disconnect/Reconnect**: Greatly enhanced SSH connection resilience. Provides a beautiful and localized overlay for one-click re-connection on unexpected disconnections.
- ✨ **Hot-Swappable i18n System**: Deeply optimized internationalization. Switch instantly between English and Simplified Chinese without restarting the application.
- ✨ **Smart Right-Click Actions**: Added intelligent right-click copy and paste for the terminal, featuring a dedicated settings toggle and OS-native keyboard shortcut hints.
v0.4 builds on the v0.3 foundation and focuses on more capable workspace operations and a smoother daily workflow:
- ✨ **Keybinding Management**: View and edit common shortcuts from the settings UI with conflict hints.
- ✨ **Settings Page Polish**: A more compact and clearer settings experience with improved layout and interaction flow.
- ✨ **Multi-Pane Tabs with a tmux-like Workflow**: A single tab can now host multiple panes, with split, focus, and switching actions for a tmux-inspired experience.
- ✨ **Transfer History Improvements**: The transfer history panel now presents richer task details, making upload and download activity easier to review.
- ✨ **SSH Passphrase Support**: Private keys can now store a passphrase, and SSH connections will use it automatically.
- ✨ **Terminal Rendering Enhancements**: Terminal rendering now handles Block Elements and similar custom glyphs more completely.
## Download
You can download the latest pre-compiled releases for macOS, Windows, and Linux from the [GitHub Releases page](https://github.com/rust-kotlin/ashell/releases/latest).
You can download the latest installers from the [GitHub Releases page](https://github.com/rust-kotlin/ashell/releases/latest):
- macOS: `.dmg` installers for x64 and arm64, plus portable `.zip` archives.
- Windows: `Setup.exe` installers for x64, x86, and arm64, plus portable `.zip` archives.
- Linux: a portable x64 `.tar.gz` archive.
## Mac Installation Guide
After downloading, please unzip the file and move `ashell.app` to your **Applications** folder.
Since the app uses ad-hoc signing, macOS may warn that the app is "damaged" upon first launch. If this happens, open Terminal and run the following command:
### Method 1: Homebrew (Recommended)
If you use [Homebrew](https://brew.sh/), you can install it quickly with:
```bash
brew install rust-kotlin/taps/ashell --cask
```
To update the app:
```bash
brew update
brew upgrade ashell --cask
```
> **Note**: Since the app uses ad-hoc signing, the Homebrew installation or update includes a postflight script to automatically handle the quarantine flag. This will require you to enter your administrator password for authorization.
### Method 2: Manual Download
1. Download the `.dmg` matching your processor from the [Releases page](https://github.com/rust-kotlin/ashell/releases/latest).
2. Open the DMG and drag `ashell.app` into **Applications**.
3. Since the app uses ad-hoc signing, macOS may warn that the app is "damaged" upon first launch. If this happens, open Terminal and run:
```bash
sudo xattr -cr /Applications/ashell.app
```
## Windows Installation Guide
Download the appropriate installer from the [Releases page](https://github.com/rust-kotlin/ashell/releases/latest):
| System architecture | Installer suffix |
| --- | --- |
| Intel/AMD 64-bit | `windows-x64-setup.exe` |
| Intel/AMD 32-bit | `windows-x86-setup.exe` |
| Windows on ARM | `windows-arm64-setup.exe` |
The installer can optionally create a desktop shortcut and registers ashell in the Start menu and the Windows uninstall list.
The installer is not yet signed with a commercial code-signing certificate, so Windows SmartScreen may display a security warning.
## Features
The current version provides a fully-featured GPUI-native workspace:
@@ -43,6 +80,7 @@ The current version provides a fully-featured GPUI-native workspace:
- **System Telemetry:** Real-time visualization of CPU, memory, swap, network, and disk metrics in the left cockpit sidebar.
- **Theming System:** Switch between multiple GPUI Component themes directly from the top toolbar.
- **Embedded Fonts:** Uses embedded Maple Mono NF CN fonts out-of-the-box for excellent CJK character and Nerd Font icon support.
- **v0.3 Core Enhancements:** Global font and font-size controls, concurrent SFTP transfers, persistent layout state, disconnect awareness, hot-swappable i18n, and smart terminal right-click copy/paste.
## Run
@@ -52,6 +90,33 @@ To run the application locally:
cargo run --release
```
## Codex and Terminal Completion Notifications
ashell supports generic OSC 9 completion notifications. For Codex CLI, add the following to
`~/.codex/config.toml`:
```toml
[tui]
notifications = ["agent-turn-complete", "approval-requested"]
notification_method = "osc9"
notification_condition = "unfocused"
```
When an eligible notification originates in the background or a non-visible tab, ashell shows a
native notification and a flashing bell on the corresponding tab. Unread notifications also add a
red badge to the macOS Dock icon or a red overlay to the Windows taskbar icon. Clicking the native
notification activates ashell and switches to the originating terminal tab; viewing that tab clears
its unread state. No reminder is shown when ashell is active and the notification originates in the
currently visible tab. An unselected tab shows a loading indicator beside its title for two seconds
after receiving terminal output. Continued output refreshes the indicator, while the selected tab
does not show it.
You can test a generic OSC 9 notification with:
```bash
printf '\033]9;Task completed\007'
```
## Package macOS App
```bash
@@ -61,6 +126,39 @@ open target/release/ashell.app
The packaging script creates a standard `.app` bundle. It does not attach an entitlements file, and after signing, it verifies that `com.apple.security.app-sandbox` is not present (meaning it runs non-sandboxed).
## Package Linux (Debian/Ubuntu)
### Prerequisites
```bash
sudo apt install pkg-config libfontconfig1-dev
cargo install cargo-deb
```
### Build .deb
```bash
cargo build --release
cargo deb
```
The `.deb` file will be generated at:
```
target/debian/ashell_0.4.8-1_amd64.deb
```
### Install
```bash
sudo dpkg -i target/debian/ashell_0.4.8-1_amd64.deb
```
After installation, you can launch from the application menu or by running `ashell` in the terminal. The `.deb` package includes:
- `/usr/bin/ashell` — Main binary
- `/usr/share/applications/ashell.desktop` — Desktop entry
- `/usr/share/icons/hicolor/256x256/apps/ashell.png` — Application icon
## License
This project is licensed under the [GPL-3.0-or-later License](LICENSE).
+113 -11
View File
@@ -8,29 +8,66 @@
该项目旨在通过结合本地和远程环境,并内置丰富的核心功能,提供一个高性能且美观的 Shell 工作区。
## 🚀 v0.3 版本重磅更新!
## 🚀 v0.4 版本重要升级
全新发布的 v0.3 迎来了架构和体验的全面升级:
- ✨ **全局实时字体设置与字号调节**(感谢 [@ancion](https://github.com/ancion) 提供的 [PR #6](https://github.com/rust-kotlin/ashell/pull/6)):支持自定义全局 UI 字体和终端渲染字体,支持实时字号调节并自动记忆。
- ✨ **SFTP 多任务并发传输队列**:引入全新的独立传输对话框,支持多文件并发上传下载,提供精准的进度展示。
- ✨ **持久化工作区记忆**:您对侧边栏的尺寸拖拽、终端主题、字体、隐藏文件开关等一切设置都会在下次启动时原样恢复!
- ✨ **智能断线重连与状态感知**:全面增强的 SSH 网络异常处理机制,断线后直接提供美观的覆盖层用于一键重连或关闭。
- ✨ **多语言热切换增强**:深度优化的 i18n 系统,包含中文与英文在内的所有界面文案现在均支持热切换。
- ✨ **智能右键快捷操作**:新增终端右键智能复制与粘贴功能,提供专属设置项自由控制开关,并支持根据不同操作系统自适应显示原生快捷键提示。
v0.4 在 v0.3 打下的基础上,重点带来了更完整的工作区操作能力和更顺手的日常体验:
- ✨ **键位管理功能**:支持在设置中可视化查看和修改常用快捷键,并提供冲突提示。
- ✨ **设置页优化**:设置页的布局和交互更加紧凑顺手,整体信息层级更清晰。
- ✨ **Tab 内多 Pane 与类 tmux 体验**:一个 tab 内现在可以管理多个 pane,支持分屏、聚焦和切换,提供更接近 tmux 的工作流。
- ✨ **传输历史增强**:传输历史面板展示更完整的任务信息,便于回溯上传和下载过程。
- ✨ **SSH passphrase 支持**:现在可以为私钥填写 passphrase,并在连接时自动使用保存的口令。
- ✨ **终端显示优化**:终端渲染进一步增强,支持 Block Elements 等自定义图形字符的更完整显示。
## 下载
您可以从 [GitHub Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载 macOS、Windows 和 Linux 版本的最新预编译程序。
您可以从 [GitHub Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载最新安装包:
- macOS:x64 和 arm64 的 `.dmg`,同时提供便携 `.zip`。
- Windows:x64、x86 和 arm64 的 `Setup.exe`,同时提供便携 `.zip`。
- Linux:x64 便携 `.tar.gz`。
## Mac 安装指南
下载并解压后,请先将 `ashell.app` 拖入或移动到 **应用程序 (Applications)** 目录。
由于应用采用本地签名,初次启动时如果系统提示“App 已损坏,无法打开”,请打开终端(Terminal)并执行以下命令:
### 方法 1: Homebrew 安装 (推荐)
如果您使用 [Homebrew](https://brew.sh/),可以通过以下命令快速安装:
```bash
brew install rust-kotlin/taps/ashell --cask
```
更新应用:
```bash
brew update
brew upgrade ashell --cask
```
> **注意**: 由于应用采用本地签名,Homebrew 在安装或更新过程中会执行一个后置脚本来自动处理隔离属性(quarantine flag),这需要您输入管理员密码以授权。
### 方法 2: 手动下载
1. 从 [Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载与处理器匹配的 `.dmg`。
2. 打开 DMG,将 `ashell.app` 拖入 **Applications** 目录。
3. 由于应用采用本地签名,初次启动时如果系统提示“App 已损坏,无法打开”,请打开终端(Terminal)并执行以下命令:
```bash
sudo xattr -cr /Applications/ashell.app
```
## Windows 安装指南
从 [Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载对应安装程序:
| 系统架构 | 安装包名称后缀 |
| --- | --- |
| Intel/AMD 64 位 | `windows-x64-setup.exe` |
| Intel/AMD 32 位 | `windows-x86-setup.exe` |
| Windows on ARM | `windows-arm64-setup.exe` |
运行安装程序后,可选择是否创建桌面快捷方式;应用也会出现在开始菜单和系统卸载列表中。
当前安装包尚未使用商业代码签名证书,Windows SmartScreen 可能显示安全提醒。
## 功能特性
当前版本提供了一个功能完备的 GPUI 原生工作区:
@@ -43,6 +80,7 @@ sudo xattr -cr /Applications/ashell.app
- **系统遥测**:在左侧边栏实时可视化显示 CPU、内存、Swap、网络和磁盘的使用指标。
- **主题系统**:支持直接从顶部工具栏切换多种 GPUI Component 颜色主题。
- **内置字体**:开箱即用,内置 Maple Mono NF CN 字体,提供卓越的中日韩(CJK)字符与 Nerd Font 图标支持。
- **v0.3 核心增强**:全局字体与字号可调,支持 SFTP 并发传输、布局记忆、断线感知、多语言热切换,以及终端右键复制粘贴等增强能力。
## 运行
@@ -52,6 +90,37 @@ sudo xattr -cr /Applications/ashell.app
cargo run --release
```
## 终端通知与任务提醒
ashell 支持 [OSC 9](https://iterm2.com/documentation-escape-codes.html)、
[OSC 99](https://sw.kovidgoyal.net/kitty/desktop-notifications/)、
[OSC 777](https://wezterm.org/escape-sequences.html) 和终端响铃(BEL),通知能力不绑定
Codex 或其它特定工具。只要 CLI、脚本或远程程序发送受支持的终端控制序列,本地终端、
SSH 和串口标签页都会将其转换为 macOS 或 Windows 系统通知,无需修改工具配置或伪装
其它终端。
OSC 9 携带通知正文;OSC 777 和 OSC 99 可以同时携带标题与正文,OSC 99 还支持
`always`、`unfocused` 和 `invisible` 显示时机。BEL 本身不含正文,因此仅显示通用提醒,
不会读取或推断当前终端画面中的内容。
符合显示时机的通知来自后台或非当前可见标签时,ashell 会显示系统通知,标签按钮以
闪动铃铛标记未读状态,并在 macOS Dock 或 Windows 任务栏图标上显示红色徽标。点击
系统通知会激活 ashell 并切换到对应的终端标签,查看对应标签后自动清除未读状态。
如果应用已激活且通知来自当前可见标签,则不重复提醒。未选中的标签在最近 2 秒内收到
终端输出时,会在标题左侧显示 Loading 动画;持续输出会刷新显示时间,当前标签不会显示。
可以分别使用下面的命令测试四种提醒:
```bash
sleep 3; printf '\033]9;Task completed\007'
sleep 3; printf '\033]777;notify;Deploy;Production is ready\033\\'
sleep 3; printf '\033]99;i=ashell-test:d=0;Build completed\033\\'; printf '\033]99;i=ashell-test:p=body;Artifacts are ready\033\\'
sleep 3; printf '\007'
```
执行后在 3 秒内切换到其他应用。macOS 第一次收到通知时可能会询问权限;如果仍未显示,
请在“系统设置 > 通知 > ashell”中确认已允许通知。
## 打包 macOS 应用
```bash
@@ -61,6 +130,39 @@ open target/release/ashell.app
该打包脚本会创建一个标准的 `.app` 应用程序包。它没有附加 entitlements 文件,并且在签名后会验证是否不存在 `com.apple.security.app-sandbox`(这意味着它在非沙盒模式下运行)。
## 打包 Linux (Debian/Ubuntu)
### 前置条件
```bash
sudo apt install pkg-config libfontconfig1-dev
cargo install cargo-deb
```
### 构建 .deb 包
```bash
cargo build --release
cargo deb
```
生成的 `.deb` 文件位于:
```
target/debian/ashell_0.4.9-1_amd64.deb
```
### 安装
```bash
sudo dpkg -i target/debian/ashell_0.4.9-1_amd64.deb
```
安装后可通过应用菜单或命令行 `ashell` 启动。`.deb` 包包含以下内容:
- `/usr/bin/ashell` — 主程序
- `/usr/share/applications/ashell.desktop` — 桌面入口
- `/usr/share/icons/hicolor/256x256/apps/ashell.png` — 应用图标
## 协议
本项目采用 [GPL-3.0-or-later 协议](LICENSE) 开源。
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Name=Ashell
Comment=A GPUI Component based SSH and local terminal client
Exec=ashell
Icon=ashell
Terminal=false
Type=Application
Categories=System;TerminalEmulator;Utility;
StartupWMClass=ashell
+308
View File
@@ -0,0 +1,308 @@
{
"$schema": "https://github.com/longbridge/gpui-component/raw/refs/heads/main/.theme-schema.json",
"name": "Phyger",
"author": "phygerr",
"url": "https://github.com/phygerr",
"themes": [
{
"name": "Phyger Light",
"mode": "light",
"colors": {
"accent.background": "#D6E4F0",
"accent.foreground": "#1B3A5C",
"background": "#F8F9FC",
"border": "#D0D5DD",
"danger.background": "#D94848",
"danger.active.background": "#B53535",
"danger.foreground": "#FFFFFF",
"foreground": "#2B2D30",
"input.border": "#C0C7D0",
"link.active.foreground": "#2563EB",
"link.foreground": "#2563EB",
"link.hover.foreground": "#1D4ED8",
"list.active.background": "#2563EB18",
"list.hover.background": "#E8EDF4",
"list.active.border": "#2563EB",
"list.even.background": "#F0F2F7",
"muted.background": "#E2E6EE88",
"muted.foreground": "#7A7F88",
"panel.background": "#ECEEF3",
"popover.background": "#F8F9FC",
"popover.foreground": "#2B2D30",
"primary.active.background": "#1D4ED8",
"primary.background": "#2563EB",
"primary.foreground": "#FFFFFF",
"primary.hover.background": "#3B72F0",
"ring": "#2563EB",
"scrollbar.background": "#F8F9FC00",
"scrollbar.thumb.background": "#C0C7D0",
"secondary.background": "#E8EDF4",
"secondary.active.background": "#D0D5DD",
"secondary.foreground": "#2B2D30",
"secondary.hover.background": "#DCE2EB",
"tab.active.background": "#F8F9FC",
"tab.active.foreground": "#2B2D30",
"tab.background": "#ECEEF300",
"tab.foreground": "#7A7F88",
"tab_bar.background": "#ECEEF3",
"title_bar.background": "#ECEEF3",
"title_bar.border": "#D0D5DD",
"base.magenta.light": "#C2185B44",
"base.red": "#D32F2F",
"base.green": "#2E7D32",
"base.yellow": "#BF8C00",
"base.blue": "#1565C0",
"base.magenta": "#8E24AA",
"base.cyan": "#00838F"
},
"highlight": {
"editor.foreground": "#2B2D30",
"editor.background": "#F8F9FC",
"editor.active_line.background": "#E8EDF4",
"editor.line_number": "#A0A5AE",
"editor.active_line_number": "#2B2D30",
"editor.invisible": "#A0A5AE55",
"conflict": "#D32F2F",
"created": "#2E7D32",
"deleted": "#C62828",
"error": "#D32F2F",
"hidden": "#A0A5AE",
"hint": "#1565C0",
"ignored": "#A0A5AE",
"info": "#00838F",
"modified": "#BF8C00",
"predictive": "#A0A5AE",
"renamed": "#BF8C00",
"success": "#2E7D32",
"unreachable": "#A0A5AE",
"warning": "#E65100",
"syntax": {
"attribute": {
"color": "#7B1FA2"
},
"boolean": {
"color": "#00838F"
},
"comment": {
"color": "#6A737D",
"font_style": "italic"
},
"comment.doc": {
"color": "#6A737D",
"font_style": "italic"
},
"constant": {
"color": "#00838F"
},
"constructor": {
"color": "#1565C0"
},
"embedded": {
"color": "#2B2D30"
},
"function": {
"color": "#6A1B9A"
},
"keyword": {
"color": "#1565C0"
},
"link_text": {
"color": "#1565C0",
"font_style": "normal"
},
"link_uri": {
"color": "#00838F",
"font_style": "italic"
},
"number": {
"color": "#1B5E20"
},
"string": {
"color": "#BF360C"
},
"string.escape": {
"color": "#BF360C"
},
"string.regex": {
"color": "#BF360C"
},
"string.special": {
"color": "#00838F"
},
"string.special.symbol": {
"color": "#00838F"
},
"tag": {
"color": "#1565C0"
},
"text.literal": {
"color": "#00838F"
},
"title": {
"color": "#6A1B9A",
"font_weight": 600
},
"type": {
"color": "#2E7D32"
},
"property": {
"color": "#2B2D30"
},
"variable.special": {
"color": "#D32F2F"
}
}
}
},
{
"name": "Phyger Dark",
"mode": "dark",
"colors": {
"accent.background": "#2A3040",
"accent.foreground": "#C8D3E0",
"background": "#1A1D23",
"border": "#2E3340",
"danger.background": "#E05555",
"danger.active.background": "#CC3D3D",
"danger.foreground": "#FFFFFF",
"foreground": "#C8CED8",
"input.border": "#3A4050",
"link.active.foreground": "#6CB4EE",
"link.foreground": "#6CB4EE",
"link.hover.foreground": "#8FC8F6",
"list.active.background": "#6CB4EE15",
"list.hover.background": "#252A34",
"list.active.border": "#6CB4EE",
"list.even.background": "#1E2229",
"muted.background": "#252A3466",
"muted.foreground": "#6B7280",
"panel.background": "#1E2229",
"popover.background": "#1A1D23",
"popover.foreground": "#C8CED8",
"primary.active.background": "#4A90D9",
"primary.background": "#5BA0E0",
"primary.foreground": "#1A1D23",
"primary.hover.background": "#6CB4EE",
"ring": "#5BA0E0",
"scrollbar.background": "#1A1D2300",
"scrollbar.thumb.background": "#3A4050",
"secondary.active.background": "#333A48",
"secondary.background": "#252A34",
"secondary.foreground": "#C8CED8",
"secondary.hover.background": "#2E3542",
"tab.active.background": "#1A1D23",
"tab.active.foreground": "#C8CED8",
"tab.background": "#252A3400",
"tab.foreground": "#6B7280",
"tab_bar.background": "#1E2229DD",
"title_bar.background": "#1E2229",
"title_bar.border": "#2E3340",
"base.magenta.light": "#C792EA44",
"base.red": "#E06060",
"base.green": "#7EC699",
"base.yellow": "#E8C97A",
"base.blue": "#6CB4EE",
"base.magenta": "#C792EA",
"base.cyan": "#56D4C7"
},
"highlight": {
"editor.foreground": "#C8CED8",
"editor.background": "#1A1D23",
"editor.active_line.background": "#222730",
"editor.line_number": "#5A6170",
"editor.active_line_number": "#C8CED8",
"editor.invisible": "#5A617055",
"conflict": "#E06060",
"created": "#7EC699",
"deleted": "#CC4444",
"error": "#E06060",
"hidden": "#6B7280",
"hint": "#C792EA",
"ignored": "#5A6170",
"info": "#56D4C7",
"modified": "#E8C97A",
"predictive": "#4A5568",
"renamed": "#E8C97A",
"success": "#7EC699",
"unreachable": "#5A6170",
"warning": "#E8C97A",
"syntax": {
"attribute": {
"color": "#C792EA"
},
"boolean": {
"color": "#56D4C7"
},
"comment": {
"color": "#5A6A7A",
"font_style": "italic"
},
"comment.doc": {
"color": "#5A6A7A",
"font_style": "italic"
},
"constant": {
"color": "#56D4C7"
},
"constructor": {
"color": "#6CB4EE"
},
"embedded": {
"color": "#C8CED8"
},
"function": {
"color": "#E2C08D"
},
"keyword": {
"color": "#6CB4EE"
},
"link_text": {
"color": "#6CB4EE",
"font_style": "normal"
},
"link_uri": {
"color": "#56D4C7",
"font_style": "italic"
},
"number": {
"color": "#7EC699"
},
"string": {
"color": "#E8A87C"
},
"string.escape": {
"color": "#E8A87C"
},
"string.regex": {
"color": "#E8A87C"
},
"string.special": {
"color": "#56D4C7"
},
"string.special.symbol": {
"color": "#56D4C7"
},
"tag": {
"color": "#6CB4EE"
},
"text.literal": {
"color": "#56D4C7"
},
"title": {
"color": "#E2C08D",
"font_weight": 600
},
"type": {
"color": "#7EC699"
},
"property": {
"color": "#C8CED8"
},
"variable.special": {
"color": "#E06060"
}
}
}
}
]
}
+282 -19
View File
@@ -1,7 +1,21 @@
remote_files: "REMOTE FILES"
open_ssh_tab_sftp: "Open an SSH tab to browse SFTP files."
sftp_connecting: "sftp connecting..."
refresh: "Refresh"
sftp_more_actions: "More remote file actions"
sftp_more_actions_short: "More"
reveal_current_directory: "Reveal current directory"
collapse_all_directories: "Collapse all directories"
parent_directory: "Parent directory"
create_or_upload: "Create or upload"
add: "Add"
directories: "DIRECTORIES"
expand_directory: "Expand directory"
collapse_directory: "Collapse directory"
retry_directory: "Retry loading directory"
loading_directory: "Loading directory..."
directory_load_failed: "Failed to load directory: %{reason}"
empty_directory: "This directory is empty"
selected_items: "%{count} selected"
upload_file: "Upload File"
upload_folder: "Upload Folder"
hidden: "Hidden"
@@ -11,9 +25,35 @@ download_count: "Download (%{count})"
name: "NAME"
size: "SIZE"
modified: "MODIFIED"
new_ssh_connection: "New SSH Connection"
new_connection: "New Connection"
edit_connection: "Edit Connection"
create_or_edit_ssh_session: "Create or edit an SSH session"
new_serial_connection: "New Serial Connection"
edit_serial_connection: "Edit Serial Connection"
serial_port: "Serial Port Path"
baud_rate: "Baud Rate"
session_name: "Session Name"
connection_group: "Group"
connection_group_name: "Group name"
new_connection_group: "New Group"
rename_connection_group: "Rename Group"
delete_connection_group: "Delete Group"
ungrouped: "Ungrouped"
move_to_group: "Move"
group_name_required: "Group name is required."
group_name_exists: "A group with this name already exists."
connection_group_created: "Created group %{name}"
connection_group_renamed: "Renamed group to %{name}"
connection_group_deleted: "Deleted group and moved %{count} connections to Ungrouped"
connections_moved_to_group: "Moved %{count} connections to %{name}"
new_folder: "New Folder"
rename: "Rename"
new_name: "New name"
name_required: "Name is required."
invalid_remote_name: "Name cannot be '.', '..', or contain '/'."
rename_success: "Renamed to %{name}"
rename_failed: "Rename failed: %{err}"
sftp_connection_unavailable: "The SFTP connection is unavailable."
create_folder_success: "Created folder: %{name}"
create_folder_failed: "Failed to create folder: %{err}"
delete_selected: "Delete"
@@ -23,21 +63,23 @@ system_path_warning: "WARNING: You are attempting to delete critical system path
delete_success: "Successfully deleted %{count} items"
delete_failed: "Failed to delete: %{err}"
deleting_paths: "Deleting %{count} items..."
confirm: "Confirm"
local_terminal: "Local Terminal"
open_local_shell_tab: "Open a new local shell tab"
connect: "Connect"
edit: "Edit"
done: "Done"
delete: "Delete"
host: "Host"
port: "Port"
user: "User"
password: "Password"
key: "Key"
private_key_path: "Private Key Path"
private_key_data: "Private Key Data"
save_and_connect: "Save & Connect"
ssh_config: "Config(~/.ssh/config)"
ssh_config_empty: "No entries found in ~/.ssh/config"
save: "Save"
cancel: "Cancel"
more: "More"
host_and_user_required: "Host and user are required"
language: "Language"
follow_system: "Follow System"
@@ -47,32 +89,105 @@ use_light_mode: "Use Light Mode"
use_dark_mode: "Use Dark Mode"
english: "English"
chinese: "简体中文"
idle_no_session: "idle / No session"
system: "SYSTEM"
cpu: "CPU"
mem: "MEM"
swap: "SWAP"
net: "NET"
disk: "DISK"
global_load: "global load"
live: "Live"
idle: "idle"
saved: "SAVED"
add_ssh: "+ ssh"
system_info: "SYSTEM"
memory: "Memory"
cpu_percent: "CPU"
memory_usage_short: "Memory"
process: "Process"
system_processes: "System Processes"
filter_processes: "Filter processes"
refresh_processes: "Refresh process list"
no_processes: "No process data"
no_matching_processes: "No matching processes"
process_details: "Process details"
copy_process: "Copy process information"
process_pid: "PID"
process_command: "Command"
process_summary: "%{name} · PID %{pid}"
network_ports: "Network Ports"
refresh_ports: "Refresh port list"
no_ports: "No network ports"
filter_ports: "Filter ports"
no_matching_ports: "No matching ports"
loading_ports: "Loading port list..."
protocol: "Protocol"
address: "Address"
state: "State"
loading_processes: "Loading process list..."
terminate_process: "Terminate Process"
confirm_terminate_process: "Terminate Process?"
confirm_terminate_process_desc: "Send a termination signal to PID %{pid}? Unsaved data may be lost."
terminating_process: "Sending termination signal to process %{pid}..."
process_terminated: "Termination signal sent to process %{pid}"
process_terminate_failed: "Failed to terminate process %{pid}: %{reason}"
connection_management: "CONNECTIONS"
manage_connections: "Manage connections"
import_connections: "Import"
export_connections: "Export"
import_export_connections: "Import & Export Connections"
new_connection_short: "New"
open_connection: "Open Connection"
filter_connections: "Filter connections"
no_connections: "No connections"
no_matching_connections: "No matching connections"
connections_exported: "Exported %{count} connections"
connections_import_summary: "Import complete: %{added} added, %{updated} updated, %{skipped} skipped"
connections_export_failed: "Export failed: %{reason}"
connections_import_failed: "Import failed: %{reason}"
connections_export_no_ssh: "No SSH connections are available to export"
connections_export_plaintext_title: "Export plaintext passwords?"
connections_export_plaintext_warning: "The CSV will contain passwords for %{count} SSH connections in plaintext. Private keys and passphrases are not included. Store it securely and remove it after migration."
connections_export_plaintext_warning_with_skipped: "The CSV will contain passwords for %{count} SSH connections in plaintext. Private keys and passphrases are not included, and %{skipped} non-SSH connections will not be exported. Store it securely and remove it after migration."
delete_selected_connections: "Delete"
connections_deleted: "Deleted %{count} connections"
delete_selected_commands: "Delete selected commands"
commands_deleted: "Deleted %{count} commands"
command_history: "Command History"
command_history_short: "History"
command_history_empty: "No command history"
filter_command_history: "Filter command history"
no_matching_commands: "No matching commands"
copy_command: "Copy command"
execute_command: "Execute command"
close_command_history: "Close command history"
open_local_or_ssh: "Open a local terminal or pick an SSH session."
open_session: "Open Session"
settings: "Settings"
proxy: "Proxy"
proxy_none: "Follow Global"
proxy_host: "Proxy Host"
proxy_port: "Proxy Port"
proxy_user: "Proxy Username (optional)"
proxy_password: "Proxy Password (optional)"
ui_font_size: "UI Font Size"
terminal_font_size: "Terminal Font Size"
system_default: "System Default"
ui_font_family: "UI Font"
terminal_font_family: "Terminal Font"
theme_management_hint: "Theme mode and light/dark theme families are managed from the top-bar theme button."
local_host: "local host"
local_terminal_opened: "local terminal opened"
cursor_style: "Cursor Style"
cursor_style_default: "Default"
cursor_style_blink: "Blink"
cursor_style_beam: "Line"
cursor_style_beam_blink: "Line Blink"
monitoring_position: "Monitoring Position"
position_bottom: "Bottom"
position_sidebar: "Sidebar"
position_hidden: "Hidden"
connecting: "Connecting"
starting_connection: "starting connection..."
connection_failed: "Connection Failed"
session_disconnected: "Session disconnected: %{reason}"
press_enter_to_reconnect: "Press Enter to reconnect"
ssh_reconnect_pending: "Waiting for reconnect confirmation"
reconnect_ssh_title: "Reconnect"
reconnect_ssh_desc: "%{name} is not connected. Reconnect now?"
reconnect_ssh: "Reconnect"
preview_failed: "Preview failed: %{err}"
downloading_file: "Downloading %{base}..."
@@ -91,21 +206,169 @@ 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"
downloading: "Downloading"
paused: "Paused"
completed: "Completed"
terminal_attention_required: "Terminal requires attention"
failed: "Failed"
cancelled: "Cancelled"
interrupted: "Interrupted"
session: "Session"
zombie: "Zombie"
zombie_reason: "This transfer was interrupted because the application exited. The transfer task no longer exists."
software_builtin: "Built-in"
retry: "Retry"
right_click_copy_paste: "Right-click Copy/Paste"
copy_paste_hint: "Tip: Regardless of this switch, you can always use %{key} + C/V to copy/paste."
keyword_highlight: "Terminal Keyword Highlighting"
remember_tabs: "Remember Tabs"
lock_layout: "Lock Window Layout"
lock_layout_hint: "Lock the position of the sidebar and the SFTP panel."
copy_paste_hint: "Selected text can also be copied by left-clicking."
settings_copy: "Copy Selection"
settings_paste: "Paste Clipboard"
edit_file: "Edit File"
edit_file_tooltip: "Download to temporary directory, open in default editor, and auto upload on save."
auto_saved_and_uploaded: "Auto-saved & uploaded: %{base}"
edit_file_tooltip: "Edit directly in ashell and save changes to the remote server."
terminal_encoding: "Terminal Encoding"
local_terminal_encoding: "Local Terminal Encoding"
local_terminal_encoding_changed: "Local terminal encoding changed to %{encoding}."
default_local_terminal: "Default Local Terminal"
local_terminal_windows_powershell: "Windows PowerShell"
local_terminal_powershell_7: "PowerShell 7"
local_terminal_cmd: "Command Prompt"
local_terminal_git_bash: "Git Bash"
local_terminal_not_installed: "Not installed"
local_terminal_shell_fallback: "The selected local terminal is unavailable; switched back to %{terminal}."
local_terminal_shell_changed: "Default local terminal changed to %{terminal}; new terminals will use it."
file_encoding: "File Encoding"
terminal_encoding_changed: "Terminal encoding changed to %{encoding}; new input and output will use it."
file_encoding_changed: "File encoding changed to %{encoding}."
encoding_decode_warning: "Some bytes are invalid in %{encoding}. Try another encoding."
encoding_encode_failed: "Some characters cannot be represented in %{encoding}. Choose another encoding before saving."
loading_file_content: "Loading file content..."
saving_file_content: "Saving file..."
editor_content_too_large: "Content exceeds the %{max} in-app editing limit."
unsaved_changes: "Unsaved changes"
file_content_loaded: "File loaded"
open_remote_file_failed: "Failed to open remote file: %{err}"
remote_file_saved: "Remote file saved"
save_remote_file_failed: "Failed to save remote file: %{err}"
sync_cwd_tooltip: "Sync path from active terminal"
sync_cwd: "Sync Path"
scroll: "Scroll ↓"
clone: "Clone"
about_feedback_hint: "If you encounter a problem or have a suggestion, please report it on our GitHub project."
about_description: "A GPUI Component based SSH and local terminal client"
about_tips_title: "Shortcuts & Usage Tips:"
about_tip_backspace: "Unbinding Shortcuts: Press Backspace while recording a shortcut in Settings -> Keybindings to clear/unbind it."
about_tip_close_shortcuts: "Default Close Shortcuts: macOS uses ⌘+Q (Quit App) / ⌘+W (Close Tab); Windows/Linux uses Alt+F4 or Ctrl+Q (Quit App) / Ctrl+W (Close Tab)."
about_tip_conflict_shortcuts: "Potential Shortcut Conflicts: On Linux/Windows, default Ctrl shortcuts (e.g. Ctrl+W to close tab, Ctrl+L to focus pane) will intercept terminal Shell bindings (e.g. Ctrl+W to backward-kill-word, Ctrl+L to clear screen). Rebind to Ctrl+Shift+W or press Backspace to unbind in Settings -> Keybindings."
version: "Version"
close: "Close"
menu_about_ashell: "About Ashell"
menu_services: "Services"
menu_hide_ashell: "Hide Ashell"
menu_hide_others: "Hide Others"
menu_show_all: "Show All"
menu_quit_ashell: "Quit Ashell"
menu_file: "File"
menu_edit: "Edit"
menu_view: "View"
menu_help: "Help"
menu_close_window: "Close Window"
menu_undo: "Undo"
menu_redo: "Redo"
menu_cut: "Cut"
menu_copy: "Copy"
menu_paste: "Paste"
menu_select_all: "Select All"
menu_toggle_sidebar: "Toggle Sidebar"
menu_toggle_full_screen: "Enter Full Screen"
menu_minimize: "Minimize"
menu_zoom: "Zoom"
menu_project_website: "Ashell Project Website"
settings_general: "General"
settings_key_bindings: "Key Bindings"
settings_help: "Help"
settings_about: "About"
settings_group_appearance: "Appearance"
settings_group_font: "Font"
settings_group_terminal: "Terminal"
settings_group_other: "Other"
settings_group_keybind_general: "General"
settings_group_keybind_zoom: "Zoom"
settings_group_keybind_focus: "Focus"
settings_group_keybind_panel: "Panel"
theme_mode: "Theme Mode"
clear_transfers: "Clear History"
transfers_limit: " (Max 100 records)"
press_new_key: "Press new key..."
none: "None"
settings_open_settings: "Settings"
settings_open_session: "Open Session"
settings_tab_list: "Tab List"
settings_open_transfers: "Open Transfer History"
panel_minimize: "Minimize Panel"
panel_minimize_short: "Collapse"
panel_expand: "Expand Panel"
panel_expand_short: "Expand"
settings_new_connection: "New Connection"
settings_open_search: "Search"
settings_quit_application: "Quit Application"
search: "Search"
no_results: "No results"
settings_toggle_sidebar: "Toggle Sidebar"
settings_toggle_sftp_zoom: "Toggle SFTP Panel Size"
settings_focus_pane_left: "Focus Pane Left"
settings_focus_pane_right: "Focus Pane Right"
settings_focus_pane_up: "Focus Pane Up"
settings_focus_pane_down: "Focus Pane Down"
settings_split_pane_left: "Split Pane Left"
settings_split_pane_right: "Split Pane Right"
settings_split_pane_up: "Split Pane Up"
settings_split_pane_down: "Split Pane Down"
settings_close_pane: "Close Pane"
keybind_conflict: "\"%{key}\" is already used by \"%{action}\", please choose another key"
title_bar_style: "Title Bar Style"
title_bar_native: "Native"
title_bar_integrated: "Integrated"
restart_hint: " (Restart required)"
settings_sync: "Configuration Sync"
sync_endpoint: "WebDAV Endpoint"
sync_username: "WebDAV Username"
sync_webdav_password: "WebDAV Password"
sync_encryption_password: "Encryption Password (8+ characters)"
sync_security_hint: "Only connection parameters are stored locally. WebDAV passwords, S3 secrets, and encryption passwords remain in memory for this process. Uploaded data is end-to-end encrypted. Downloading replaces locally saved SSH sessions."
sync_s3_endpoint: "S3 Endpoint (optional for AWS S3)"
sync_s3_region: "S3 Region"
sync_s3_bucket: "S3 Bucket"
sync_s3_object_key: "Object Key"
sync_s3_access_key: "Access Key ID"
sync_s3_secret_key: "Secret Access Key"
sync_s3_session_token: "Session Token (optional)"
sync_download: "Download Remote"
sync_upload: "Upload Remote"
sync_not_run: "Not synchronized yet"
sync_uploading: "Encrypting and uploading configuration..."
sync_downloading: "Downloading and decrypting configuration..."
sync_upload_complete: "Configuration uploaded"
sync_download_complete: "Configuration downloaded"
sync_failed: "Synchronization failed"
settings_proxy: "Proxy"
enable_proxy: "Enable Proxy"
read_env_proxy: "Read environment variables on startup"
read_env_proxy_desc: "If enabled, the global proxy settings below will be overridden by environment variables on startup."
global_proxy_settings: "Global Proxy Configuration"
global_proxy_host: "Proxy Host"
global_proxy_port: "Proxy Port"
global_proxy_user: "Proxy Username"
global_proxy_password: "Proxy Password"
save_proxy: "Save Proxy"
settings_config_file: "Config File"
settings_backup_local_desc: "Export or import your unencrypted configuration file locally."
backup_export: "Export Config"
backup_import: "Import Config"
+283 -22
View File
@@ -1,7 +1,21 @@
remote_files: "远程文件"
open_ssh_tab_sftp: "打开一个 SSH 会话面板以浏览 SFTP 文件。"
open_ssh_tab_sftp: "打开 SSH 标签页以浏览 SFTP 文件。"
sftp_connecting: "正在连接 SFTP..."
refresh: "刷新"
sftp_more_actions: "更多远程文件操作"
sftp_more_actions_short: "更多"
reveal_current_directory: "定位当前目录"
collapse_all_directories: "折叠全部目录"
parent_directory: "返回上级目录"
create_or_upload: "新建或上传"
add: "添加"
directories: "目录"
expand_directory: "展开目录"
collapse_directory: "折叠目录"
retry_directory: "重新加载目录"
loading_directory: "正在加载目录..."
directory_load_failed: "目录加载失败:%{reason}"
empty_directory: "当前目录为空"
selected_items: "已选择 %{count} 项"
upload_file: "上传文件"
upload_folder: "上传文件夹"
hidden: "显示隐藏文件"
@@ -11,9 +25,35 @@ download_count: "下载 (%{count})"
name: "名称"
size: "大小"
modified: "修改时间"
new_ssh_connection: "新建 SSH 连接"
new_connection: "新建连接"
edit_connection: "编辑连接"
create_or_edit_ssh_session: "创建或编辑一个 SSH 会话"
new_serial_connection: "新建串口连接"
edit_serial_connection: "编辑串口连接"
serial_port: "串口设备路径"
baud_rate: "波特率"
session_name: "会话名称"
connection_group: "分组"
connection_group_name: "分组名称"
new_connection_group: "新建分组"
rename_connection_group: "重命名分组"
delete_connection_group: "删除分组"
ungrouped: "未分组"
move_to_group: "移动"
group_name_required: "分组名称不能为空。"
group_name_exists: "已存在同名分组。"
connection_group_created: "已创建分组 %{name}"
connection_group_renamed: "已重命名分组为 %{name}"
connection_group_deleted: "已删除分组,并将 %{count} 个连接移至未分组"
connections_moved_to_group: "已将 %{count} 个连接移动到 %{name}"
new_folder: "新建文件夹"
rename: "重命名"
new_name: "新名称"
name_required: "名称不能为空。"
invalid_remote_name: "名称不能是“.”、“..”或包含“/”。"
rename_success: "已重命名为 %{name}"
rename_failed: "重命名失败:%{err}"
sftp_connection_unavailable: "SFTP 连接不可用。"
create_folder_success: "成功创建文件夹: %{name}"
create_folder_failed: "创建文件夹失败: %{err}"
delete_selected: "删除"
@@ -23,23 +63,23 @@ system_path_warning: "警告:您即将删除系统关键路径!这可能会
delete_success: "成功删除 %{count} 个项"
delete_failed: "删除失败: %{err}"
deleting_paths: "正在删除 %{count} 个项..."
cancel: "取消"
confirm: "确认"
local_terminal: "本地终端"
open_local_shell_tab: "打开一个新的本地终端页面"
open_local_shell_tab: "打开一个新的本地终端标签页"
connect: "连接"
edit: "编辑"
done: "完成"
delete: "删除"
host: "主机 (Host)"
port: "端口 (Port)"
user: "用户 (User)"
password: "密码 (Password)"
key: "密钥 (Key)"
private_key_path: "私钥路径"
private_key_data: "私钥内容"
save_and_connect: "保存并连接"
ssh_config: "配置(~/.ssh/config)"
ssh_config_empty: "~/.ssh/config 中未找到条目"
save: "保存"
cancel: "取消"
more: "更多"
host_and_user_required: "主机名和用户名不能为空"
language: "显示语言"
follow_system: "跟随系统"
@@ -49,32 +89,105 @@ use_light_mode: "使用浅色模式"
use_dark_mode: "使用深色模式"
english: "English"
chinese: "简体中文"
idle_no_session: "空闲 / 未连接会话"
system: "系统"
cpu: "CPU"
mem: "内存"
swap: "虚拟内存"
net: "网络"
disk: "磁盘"
global_load: "全局负载"
live: "实时"
idle: "空闲"
saved: "已保存会话"
add_ssh: "+ SSH"
system_info: "系统信息"
memory: "内存"
cpu_percent: "CPU"
memory_usage_short: "内存"
process: "进程"
system_processes: "系统进程"
filter_processes: "筛选进程"
refresh_processes: "刷新进程列表"
no_processes: "暂无进程数据"
no_matching_processes: "没有匹配的进程"
process_details: "进程详细信息"
copy_process: "复制进程信息"
process_pid: "PID"
process_command: "命令"
process_summary: "%{name} · PID %{pid}"
network_ports: "网络端口"
refresh_ports: "刷新端口列表"
no_ports: "暂无网络端口"
filter_ports: "筛选端口"
no_matching_ports: "没有匹配的端口"
loading_ports: "正在加载端口列表..."
protocol: "协议"
address: "地址"
state: "状态"
loading_processes: "正在加载进程列表..."
terminate_process: "终止进程"
confirm_terminate_process: "确认终止进程?"
confirm_terminate_process_desc: "确定要向 PID %{pid} 发送终止信号吗?未保存的数据可能会丢失。"
terminating_process: "正在向进程 %{pid} 发送终止信号..."
process_terminated: "已向进程 %{pid} 发送终止信号"
process_terminate_failed: "终止进程 %{pid} 失败:%{reason}"
connection_management: "连接"
manage_connections: "管理连接"
import_connections: "导入连接"
export_connections: "导出连接"
import_export_connections: "导入与导出连接"
new_connection_short: "新建"
open_connection: "打开连接"
filter_connections: "筛选连接"
no_connections: "暂无连接"
no_matching_connections: "没有匹配的连接"
connections_exported: "已导出 %{count} 个连接"
connections_import_summary: "导入完成:新增 %{added} 个,更新 %{updated} 个,跳过 %{skipped} 个"
connections_export_failed: "导出失败:%{reason}"
connections_import_failed: "导入失败:%{reason}"
connections_export_no_ssh: "没有可导出的 SSH 连接"
connections_export_plaintext_title: "导出明文密码?"
connections_export_plaintext_warning: "CSV 将以明文保存 %{count} 个 SSH 连接的密码,不包含私钥和私钥口令。请妥善保管,并在迁移后及时移除。"
connections_export_plaintext_warning_with_skipped: "CSV 将以明文保存 %{count} 个 SSH 连接的密码,不包含私钥和私钥口令;另有 %{skipped} 个非 SSH 连接不会导出。请妥善保管,并在迁移后及时移除。"
delete_selected_connections: "删除"
connections_deleted: "已删除 %{count} 个连接"
delete_selected_commands: "删除选中命令"
commands_deleted: "已删除 %{count} 条命令"
command_history: "历史命令"
command_history_short: "历史"
command_history_empty: "暂无历史命令"
filter_command_history: "筛选历史命令"
no_matching_commands: "没有匹配的命令"
copy_command: "复制命令"
execute_command: "执行命令"
close_command_history: "关闭历史命令"
open_local_or_ssh: "打开本地终端或选择一个 SSH 会话。"
open_session: "打开会话"
settings: "设置"
proxy: "代理"
proxy_none: "跟随全局"
proxy_host: "代理服务器地址"
proxy_port: "端口"
proxy_user: "用户名 (可选)"
proxy_password: "密码 (可选)"
ui_font_size: "界面字体大小"
terminal_font_size: "终端字体大小"
system_default: "系统默认"
ui_font_family: "界面字体"
terminal_font_family: "终端字体"
theme_management_hint: "主题模式和亮/暗主题系列可通过顶栏的主题按钮管理。"
local_host: "本地主机"
local_terminal_opened: "已打开本地终端"
cursor_style: "光标样式"
cursor_style_default: "默认"
cursor_style_blink: "闪烁"
cursor_style_beam: "细线"
cursor_style_beam_blink: "细线闪烁"
monitoring_position: "监控显示位置"
position_bottom: "底部"
position_sidebar: "侧边栏"
position_hidden: "不显示"
connecting: "正在连接"
starting_connection: "开始连接..."
connection_failed: "连接失败"
session_disconnected: "会话已断开: %{reason}"
press_enter_to_reconnect: "按 Enter 重新连接"
ssh_reconnect_pending: "等待确认后重新连接"
reconnect_ssh_title: "重新连接"
reconnect_ssh_desc: "%{name} 当前未连接,是否现在重新连接?"
reconnect_ssh: "重新连接"
preview_failed: "预览失败: %{err}"
downloading_file: "正在下载 %{base}..."
@@ -93,21 +206,169 @@ n_folders: "%{folders} 个文件夹"
n_files_and_folders: "%{files} 个文件及 %{folders} 个文件夹"
reset: "重置"
reset_layout: "恢复默认布局"
reset_layout_success: "布局占比已恢复默认。窗口大小将在下次启动时生效。"
reset_layout_hint: "重置面板布局占比,窗口大小将在下次启动时生效"
transfers: "传输记录"
no_transfers_yet: "暂无传输记录"
downloading: "正在下载"
paused: "已暂停"
completed: "已完成"
terminal_attention_required: "终端中有需要关注的新状态"
failed: "失败"
cancelled: "已取消"
interrupted: "已中断"
session: "会话"
zombie: "僵尸任务"
zombie_reason: "此传输任务因程序退出而中断,传输进程已不存在。"
software_builtin: "软件内置"
retry: "重试"
right_click_copy_paste: "右键复制/粘贴"
copy_paste_hint: "提示:不管是否开启此开关,都可以使用 %{key} + C/V 快捷键进行复制和粘贴。"
keyword_highlight: "终端关键词高亮"
remember_tabs: "记忆标签页"
lock_layout: "锁定窗口布局"
lock_layout_hint: "锁定侧边栏和 SFTP 面板的位置。"
copy_paste_hint: "选中后左键点击也可复制。"
settings_copy: "复制选区"
settings_paste: "粘贴剪贴板"
edit_file: "编辑文件"
edit_file_tooltip: "下载到临时目录,使用默认编辑器打开,并在保存后自动上传。"
auto_saved_and_uploaded: "已自动保存并上传: %{base}"
edit_file_tooltip: "直接在 ashell 内编辑,并将修改保存到远程服务器。"
terminal_encoding: "终端编码"
local_terminal_encoding: "本地终端编码"
local_terminal_encoding_changed: "本地终端编码已切换为 %{encoding}。"
default_local_terminal: "默认本地终端"
local_terminal_windows_powershell: "Windows PowerShell"
local_terminal_powershell_7: "PowerShell 7"
local_terminal_cmd: "命令提示符"
local_terminal_git_bash: "Git Bash"
local_terminal_not_installed: "未安装"
local_terminal_shell_fallback: "所选本地终端不可用,已改回 %{terminal}。"
local_terminal_shell_changed: "默认本地终端已切换为 %{terminal},新终端生效。"
file_encoding: "文件编码"
terminal_encoding_changed: "终端编码已切换为 %{encoding},新的输入和输出将使用此编码。"
file_encoding_changed: "文件编码已切换为 %{encoding}。"
encoding_decode_warning: "部分字节无法使用 %{encoding} 正确解码,请尝试其它编码。"
encoding_encode_failed: "部分字符无法使用 %{encoding} 表示,请更换编码后再保存。"
loading_file_content: "正在加载文件内容..."
saving_file_content: "正在保存文件..."
editor_content_too_large: "内容超过应用内编辑器的 %{max} 限制。"
unsaved_changes: "有未保存的修改"
file_content_loaded: "文件已加载"
open_remote_file_failed: "打开远程文件失败:%{err}"
remote_file_saved: "远程文件已保存"
save_remote_file_failed: "保存远程文件失败:%{err}"
sync_cwd_tooltip: "从当前终端同步路径"
sync_cwd: "同步路径"
scroll: "滑动查看 ↓"
clone: "克隆"
about_feedback_hint: "如果遇到问题或者有什么建议,请前往我们的 GitHub 项目中反馈。"
about_description: "基于 GPUI Component 的 SSH 与本地终端客户端"
about_tips_title: "快捷键与使用提示:"
about_tip_backspace: "快捷键解绑:在“设置 -> 快捷键”中录制按键时按 Backspace (退格键) 即可置空/解绑该快捷键。"
about_tip_close_shortcuts: "默认关闭快捷键:macOS 为 ⌘+Q (退出应用) / ⌘+W (关闭标签);Windows / Linux 为 Alt+F4 或 Ctrl+Q (退出应用) / Ctrl+W (关闭标签)。"
about_tip_conflict_shortcuts: "潜在快捷键冲突:Linux / Windows 默认修饰键为 Ctrl,应用默认快捷键(如 Ctrl+W 关闭标签、Ctrl+L 切换窗格等)会挡住终端 Shell 的原生快捷键(如 Bash/Zsh 中 Ctrl+W 清除单词、Ctrl+L 清屏)。建议在“设置 -> 快捷键”中改为 Ctrl+Shift+W 或按 Backspace 置空解绑。"
version: "版本"
close: "关闭"
menu_about_ashell: "关于 Ashell"
menu_services: "服务"
menu_hide_ashell: "隐藏 Ashell"
menu_hide_others: "隐藏其它应用"
menu_show_all: "全部显示"
menu_quit_ashell: "退出 Ashell"
menu_file: "文件"
menu_edit: "编辑"
menu_view: "视图"
menu_help: "帮助"
menu_close_window: "关闭窗口"
menu_undo: "撤销"
menu_redo: "重做"
menu_cut: "剪切"
menu_copy: "复制"
menu_paste: "粘贴"
menu_select_all: "全选"
menu_toggle_sidebar: "切换侧边栏"
menu_toggle_full_screen: "进入全屏"
menu_minimize: "最小化"
menu_zoom: "缩放"
menu_project_website: "Ashell 项目主页"
settings_general: "常规"
settings_key_bindings: "快捷键"
settings_help: "帮助"
settings_about: "关于"
settings_group_appearance: "外观"
settings_group_font: "字体"
settings_group_terminal: "终端"
settings_group_other: "其它"
settings_group_keybind_general: "常规"
settings_group_keybind_zoom: "缩放"
settings_group_keybind_focus: "焦点"
settings_group_keybind_panel: "面板"
theme_mode: "主题模式"
clear_transfers: "清空记录"
transfers_limit: " (最多保留 100 条)"
press_new_key: "请按下新的快捷键..."
none: "无"
settings_open_settings: "设置"
settings_open_session: "打开会话"
settings_tab_list: "标签列表"
settings_open_transfers: "打开传输历史"
panel_minimize: "折叠面板"
panel_minimize_short: "折叠"
panel_expand: "展开面板"
panel_expand_short: "展开"
settings_new_connection: "新建连接"
settings_open_search: "搜索"
settings_quit_application: "退出应用"
search: "搜索"
no_results: "无结果"
settings_toggle_sidebar: "切换侧边栏"
settings_toggle_sftp_zoom: "切换 SFTP 面板大小"
settings_focus_pane_left: "向左切换焦点"
settings_focus_pane_right: "向右切换焦点"
settings_focus_pane_up: "向上切换焦点"
settings_focus_pane_down: "向下切换焦点"
settings_split_pane_left: "向左拆分面板"
settings_split_pane_right: "向右拆分面板"
settings_split_pane_up: "向上拆分面板"
settings_split_pane_down: "向下拆分面板"
settings_close_pane: "关闭当前面板"
keybind_conflict: "\"%{key}\" 已被「%{action}」使用,请选择其他快捷键"
title_bar_style: "标题栏样式"
title_bar_native: "原生"
title_bar_integrated: "融合"
restart_hint: " (修改后重启生效)"
settings_sync: "配置同步"
sync_endpoint: "WebDAV 地址"
sync_username: "WebDAV 用户名"
sync_webdav_password: "WebDAV 密码"
sync_encryption_password: "配置加密口令(至少 8 位)"
sync_security_hint: "仅连接参数会保存到本机。WebDAV 密码、S3 密钥和加密口令只保留在当前进程内;上传内容使用端到端加密。下载会替换本机保存的 SSH 会话。"
sync_s3_endpoint: "S3 Endpoint(AWS S3 可留空)"
sync_s3_region: "S3 Region"
sync_s3_bucket: "S3 Bucket"
sync_s3_object_key: "对象路径"
sync_s3_access_key: "Access Key ID"
sync_s3_secret_key: "Secret Access Key"
sync_s3_session_token: "Session Token(可选)"
sync_download: "从远程下载"
sync_upload: "上传到远程"
sync_not_run: "尚未同步"
sync_uploading: "正在加密并上传配置..."
sync_downloading: "正在下载并解密配置..."
sync_upload_complete: "配置上传完成"
sync_download_complete: "配置下载完成"
sync_failed: "同步失败"
settings_proxy: "代理设置"
enable_proxy: "启用代理功能"
read_env_proxy: "启动时读取环境变量"
read_env_proxy_desc: "若开启此项,启动时将读取环境变量,下方配置的全局代理设置将失效。"
global_proxy_settings: "全局代理配置"
global_proxy_host: "代理地址"
global_proxy_port: "代理端口"
global_proxy_user: "代理用户名"
global_proxy_password: "代理密码"
save_proxy: "保存代理设置"
settings_config_file: "配置文件"
settings_backup_local_desc: "在本地导出或导入你未加密的配置文件。"
backup_export: "导出配置"
backup_import: "导入配置"
+417
View File
@@ -0,0 +1,417 @@
; *** Inno Setup version 6.5.0+ Chinese Simplified messages ***
;
; To download user-contributed translations of this file, go to:
; https://jrsoftware.org/files/istrans/
;
; Note: When translating this text, do not add periods (.) to the end of
; messages that didn't have them already, because on those messages Inno
; Setup adds the periods automatically (appending a period would result in
; two periods being displayed).
;
; Maintainer: Zhenghan Yang (Kira)
; Email: 847320916@QQ.com
; Github: https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
; Encoding: UTF-8
; Translation based on network resource
;
[LangOptions]
; The following three entries are very important. Be sure to read and
; understand the '[LangOptions] section' topic in the help file.
LanguageName=简体中文
; About LanguageID, to reference link:
; https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c
LanguageID=$0804
; LanguageCodePage should always be set if possible, even if this file is Unicode
; For English it's set to zero anyway because English only uses ASCII characters
LanguageCodePage=936
; If the language you are translating to requires special font faces or
; sizes, uncomment any of the following entries and change them accordingly.
;DialogFontName=
;DialogFontSize=9
;DialogFontBaseScaleWidth=7
;DialogFontBaseScaleHeight=15
;WelcomeFontName=Segoe UI
;WelcomeFontSize=14
[Messages]
; *** Application titles
SetupAppTitle=安装
SetupWindowTitle=安装 - %1
UninstallAppTitle=卸载
UninstallAppFullTitle=%1 卸载
; *** Misc. common
InformationTitle=信息
ConfirmTitle=确认
ErrorTitle=错误
; *** SetupLdr messages
SetupLdrStartupMessage=现在将安装 %1。您想要继续吗?
LdrCannotCreateTemp=无法创建临时文件。安装程序已中止
LdrCannotExecTemp=无法执行临时目录中的文件。安装程序已中止
HelpTextNote=
; *** Startup error messages
LastErrorMessage=%1。%n%n错误 %2: %3
SetupFileMissing=安装目录中缺少文件 %1。请修正这个问题或者获取程序的新副本。
SetupFileCorrupt=安装文件已损坏。请获取程序的新副本。
SetupFileCorruptOrWrongVer=安装文件已损坏,或是与这个安装程序的版本不兼容。请修正这个问题或获取新的程序副本。
InvalidParameter=无效的命令行参数:%n%n%1
SetupAlreadyRunning=安装程序已在运行。
WindowsVersionNotSupported=此程序不支持当前计算机运行的 Windows 版本。
WindowsServicePackRequired=此程序需要 %1 服务包 %2 或更高版本。
NotOnThisPlatform=此程序不能在 %1 上运行。
OnlyOnThisPlatform=此程序只能在 %1 上运行。
OnlyOnTheseArchitectures=此程序只能安装到为下列处理器架构设计的 Windows 版本中:%n%n%1
WinVersionTooLowError=此程序需要 %1 版本 %2 或更高。
WinVersionTooHighError=此程序不能安装于 %1 版本 %2 或更高。
AdminPrivilegesRequired=在安装此程序时您必须以管理员身份登录。
PowerUserPrivilegesRequired=在安装此程序时您必须以管理员身份或高级用户组身份登录。
SetupAppRunningError=安装程序检测到 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
UninstallAppRunningError=卸载程序检测到 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
; *** Startup questions
PrivilegesRequiredOverrideTitle=选择安装程序安装模式
PrivilegesRequiredOverrideInstruction=选择安装模式
PrivilegesRequiredOverrideText1=%1 可以为所有用户安装(需要管理员权限),或仅为您安装。
PrivilegesRequiredOverrideText2=%1 可以仅为您安装,或为所有用户安装(需要管理员权限)。
PrivilegesRequiredOverrideAllUsers=为所有用户安装(&A)
PrivilegesRequiredOverrideAllUsersRecommended=为所有用户安装(&A)(推荐)
PrivilegesRequiredOverrideCurrentUser=仅为我安装(&M)
PrivilegesRequiredOverrideCurrentUserRecommended=仅为我安装(&M)(推荐)
; *** Misc. errors
ErrorCreatingDir=安装程序无法创建目录“%1”
ErrorTooManyFilesInDir=无法在目录“%1”中创建文件,因为里面包含太多文件。
; *** Setup common messages
ExitSetupTitle=退出安装程序
ExitSetupMessage=安装程序尚未完成。如果现在退出,将不会安装该程序。%n%n您之后可以再次运行安装程序完成安装。%n%n现在退出安装程序吗?
AboutSetupMenuItem=关于安装程序(&A)...
AboutSetupTitle=关于安装程序
AboutSetupMessage=%1 版本 %2%n%3%n%n%1 主页:%n%4
AboutSetupNote=
TranslatorNote=简体中文翻译由 Kira(847320916@qq.com)维护。项目地址:https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
; *** Buttons
ButtonBack=< 上一步(&B)
ButtonNext=下一步(&N) >
ButtonInstall=安装(&I)
ButtonOK=确定
ButtonCancel=取消
ButtonYes=是(&Y)
ButtonYesToAll=全是(&A)
ButtonNo=否(&N)
ButtonNoToAll=全否(&O)
ButtonFinish=完成(&F)
ButtonBrowse=浏览(&B)...
ButtonWizardBrowse=浏览(&R)...
ButtonNewFolder=新建文件夹(&M)
; *** "Select Language" dialog messages
SelectLanguageTitle=选择安装语言
SelectLanguageLabel=选择安装时使用的语言。
; *** Common wizard text
ClickNext=点击“下一步”继续,或点击“取消”退出安装程序。
BeveledLabel=
BrowseDialogTitle=浏览文件夹
BrowseDialogLabel=在下面的列表中选择一个文件夹,然后点击“确定”。
NewFolderName=新建文件夹
; *** "Welcome" wizard page
WelcomeLabel1=欢迎使用 [name] 安装向导
WelcomeLabel2=即将在您的计算机上安装 [name/ver]。%n%n建议您在继续安装前关闭所有其他应用程序。
; *** "Password" wizard page
WizardPassword=密码
PasswordLabel1=此安装程序需要密码验证。
PasswordLabel3=请输入密码,然后点击“下一步”继续。密码区分大小写。
PasswordEditLabel=密码(&P):
IncorrectPassword=您输入的密码不正确,请重新输入。
; *** "License Agreement" wizard page
WizardLicense=许可协议
LicenseLabel=请在继续安装前阅读以下重要信息。
LicenseLabel3=请阅读下列许可协议。在继续安装前您必须同意这些协议条款。
LicenseAccepted=我同意此协议(&A)
LicenseNotAccepted=我不同意此协议(&D)
; *** "Information" wizard pages
WizardInfoBefore=信息
InfoBeforeLabel=请在继续安装前阅读以下重要信息。
InfoBeforeClickLabel=准备好继续安装后,点击“下一步”。
WizardInfoAfter=信息
InfoAfterLabel=请在继续安装前阅读以下重要信息。
InfoAfterClickLabel=准备好继续安装后,点击“下一步”。
; *** "User Information" wizard page
WizardUserInfo=用户信息
UserInfoDesc=请输入您的信息。
UserInfoName=用户名(&U):
UserInfoOrg=组织(&O):
UserInfoSerial=序列号(&S):
UserInfoNameRequired=请输入用户名。
; *** "Select Destination Location" wizard page
WizardSelectDir=选择目标位置
SelectDirDesc=您想将 [name] 安装在哪里?
SelectDirLabel3=安装程序将安装 [name] 到下面的文件夹中。
SelectDirBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
DiskSpaceGBLabel=至少需要有 [gb] GB 的可用磁盘空间。
DiskSpaceMBLabel=至少需要有 [mb] MB 的可用磁盘空间。
CannotInstallToNetworkDrive=安装程序无法安装到一个网络驱动器。
CannotInstallToUNCPath=安装程序无法安装到一个 UNC 路径。
InvalidPath=您必须输入一个带驱动器盘符的完整路径,例如:%n%nC:\App%n%n或UNC路径:%n%n\\server\share
InvalidDrive=您选定的驱动器或 UNC 共享不存在或不能访问。请选择其他位置。
DiskSpaceWarningTitle=磁盘空间不足
DiskSpaceWarning=安装程序至少需要 %1 KB 的可用空间才能安装,但选定驱动器只有 %2 KB 的可用空间。%n%n您确定要继续吗?
DirNameTooLong=文件夹名称或路径太长。
InvalidDirName=文件夹名称无效。
BadDirName32=文件夹名称不能包含下列任何字符:%n%n%1
DirExistsTitle=文件夹已存在
DirExists=文件夹:%n%n%1%n%n已经存在。您确定安装到这个文件夹中吗?
DirDoesntExistTitle=文件夹不存在
DirDoesntExist=文件夹:%n%n%1%n%n不存在。您想要创建此文件夹吗?
; *** "Select Components" wizard page
WizardSelectComponents=选择组件
SelectComponentsDesc=您想安装哪些程序组件?
SelectComponentsLabel2=选中您想安装的组件;取消您不想安装的组件。然后点击“下一步”继续。
FullInstallation=完全安装
; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language)
CompactInstallation=简洁安装
CustomInstallation=自定义安装
NoUninstallWarningTitle=组件已存在
NoUninstallWarning=安装程序检测到下列组件已安装在您的计算机中:%n%n%1%n%n取消选中这些组件不会卸载它们。%n%n您确定要继续吗?
ComponentSize1=%1 KB
ComponentSize2=%1 MB
ComponentsDiskSpaceGBLabel=当前选择的组件需要至少 [gb] GB 的磁盘空间。
ComponentsDiskSpaceMBLabel=当前选择的组件需要至少 [mb] MB 的磁盘空间。
; *** "Select Additional Tasks" wizard page
WizardSelectTasks=选择附加任务
SelectTasksDesc=您想要安装程序执行哪些附加任务?
SelectTasksLabel2=选择您想要安装程序在安装 [name] 时执行的附加任务,然后点击“下一步”。
; *** "Select Start Menu Folder" wizard page
WizardSelectProgramGroup=选择开始菜单文件夹
SelectStartMenuFolderDesc=安装程序应该在哪里放置程序的快捷方式?
SelectStartMenuFolderLabel3=安装程序将在下列“开始”菜单文件夹中创建程序的快捷方式。
SelectStartMenuFolderBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
MustEnterGroupName=您必须输入一个文件夹名称。
GroupNameTooLong=文件夹名称或路径太长。
InvalidGroupName=文件夹名称无效。
BadGroupName=文件夹名称不能包含下列任何字符:%n%n%1
NoProgramGroupCheck2=不创建开始菜单文件夹(&D)
; *** "Ready to Install" wizard page
WizardReady=准备安装
ReadyLabel1=安装程序准备就绪,现在可以开始安装 [name] 到您的计算机。
ReadyLabel2a=点击“安装”继续此安装程序。如果您想重新查看或修改任何设置,点击“上一步”。
ReadyLabel2b=点击“安装”继续此安装程序。
ReadyMemoUserInfo=用户信息:
ReadyMemoDir=目标位置:
ReadyMemoType=安装类型:
ReadyMemoComponents=已选择组件:
ReadyMemoGroup=开始菜单文件夹:
ReadyMemoTasks=附加任务:
; *** TDownloadWizardPage wizard page and DownloadTemporaryFile
DownloadingLabel2=正在下载文件...
ButtonStopDownload=停止下载(&S)
StopDownload=您确定要停止下载吗?
ErrorDownloadAborted=下载已中止。
ErrorDownloadFailed=下载失败:%1 %2。
ErrorDownloadSizeFailed=获取大小失败:%1 %2。
ErrorProgress=无效的进度:%1 / %2。
ErrorFileSize=文件大小错误:预期 %1,实际 %2。
; *** TExtractionWizardPage wizard page and ExtractArchive
ExtractingLabel=正在提取文件...
ButtonStopExtraction=停止提取(&S)
StopExtraction=您确定要停止提取吗?
ErrorExtractionAborted=提取已中止。
ErrorExtractionFailed=提取失败:%1
; *** Archive extraction failure details
ArchiveIncorrectPassword=密码不正确。
ArchiveIsCorrupted=压缩包已损坏。
ArchiveUnsupportedFormat=不支持的压缩包格式。
; *** "Preparing to Install" wizard page
WizardPreparing=正在准备安装
PreparingDesc=安装程序正在准备安装 [name] 到您的计算机。
PreviousInstallNotCompleted=先前的程序安装或卸载未完成,需要您重启计算机以完成该安装。%n%n在重启计算机后,再次运行安装程序以完成 [name] 的安装。
CannotContinue=安装程序不能继续。请点击“取消”退出。
ApplicationsFound=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。
ApplicationsFound2=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。安装完成后,安装程序将尝试重新启动这些应用程序。
CloseApplications=自动关闭应用程序(&A)
DontCloseApplications=不要关闭应用程序(&D)
ErrorCloseApplications=安装程序无法自动关闭所有应用程序。建议您在继续之前,关闭所有在使用需要由安装程序更新的文件的应用程序。
PrepareToInstallNeedsRestart=安装程序必须重启您的计算机。计算机重启后,请再次运行安装程序以完成 [name] 的安装。%n%n要立即重启吗?
; *** "Installing" wizard page
WizardInstalling=正在安装
InstallingLabel=安装程序正在安装 [name] 到您的计算机,请稍候。
; *** "Setup Completed" wizard page
FinishedHeadingLabel=完成 [name] 安装向导
FinishedLabelNoIcons=安装程序已在您的计算机中安装了 [name]。
FinishedLabel=安装程序已在您的计算机中安装了 [name]。您可以通过已安装的快捷方式运行此应用程序。
ClickFinish=点击“完成”退出安装程序。
FinishedRestartLabel=为完成 [name] 的安装,安装程序必须重新启动您的计算机。要立即重启吗?
FinishedRestartMessage=为完成 [name] 的安装,安装程序必须重新启动您的计算机。%n%n要立即重启吗?
ShowReadmeCheck=是,我想查阅自述文件
YesRadio=是,立即重启计算机(&Y)
NoRadio=否,稍后重启计算机(&N)
; used for example as 'Run MyProg.exe'
RunEntryExec=运行 %1
; used for example as 'View Readme.txt'
RunEntryShellExec=查阅 %1
; *** "Setup Needs the Next Disk" stuff
ChangeDiskTitle=安装程序需要下一张磁盘
SelectDiskLabel2=请插入磁盘 %1 并点击“确定”。%n%n如果这个磁盘中的文件可以在下列文件夹之外的文件夹中找到,请输入正确的路径或点击“浏览”。
PathLabel=路径(&P):
FileNotInDir2=“%2”中找不到文件“%1”。请插入正确的磁盘或选择其他文件夹。
SelectDirectoryLabel=请指定下一张磁盘的位置。
; *** Installation phase messages
SetupAborted=安装程序未完成安装。%n%n请修正这个问题并重新运行安装程序。
AbortRetryIgnoreSelectAction=选择操作
AbortRetryIgnoreRetry=重试(&T)
AbortRetryIgnoreIgnore=忽略错误并继续(&I)
AbortRetryIgnoreCancel=取消安装
RetryCancelSelectAction=选择操作
RetryCancelRetry=重试(&T)
RetryCancelCancel=取消
; *** Installation status messages
StatusClosingApplications=正在关闭应用程序...
StatusCreateDirs=正在创建目录...
StatusExtractFiles=正在提取文件...
StatusDownloadFiles=正在下载文件...
StatusCreateIcons=正在创建快捷方式...
StatusCreateIniEntries=正在创建 INI 条目...
StatusCreateRegistryEntries=正在创建注册表条目...
StatusRegisterFiles=正在注册文件...
StatusSavingUninstall=正在保存卸载信息...
StatusRunProgram=正在完成安装...
StatusRestartingApplications=正在重启应用程序...
StatusRollback=正在撤销更改...
; *** Misc. errors
ErrorInternal2=内部错误:%1。
ErrorFunctionFailedNoCode=%1 失败。
ErrorFunctionFailed=%1 失败;错误代码 %2。
ErrorFunctionFailedWithMessage=%1 失败;错误代码 %2。%n%3
ErrorExecutingProgram=无法执行文件:%n%1
; *** Registry errors
ErrorRegOpenKey=打开注册表项时出错:%n%1\%2
ErrorRegCreateKey=创建注册表项时出错:%n%1\%2
ErrorRegWriteKey=写入注册表项时出错:%n%1\%2
; *** INI errors
ErrorIniEntry=在文件“%1”中创建 INI 条目时出错。
; *** File copying errors
FileAbortRetryIgnoreSkipNotRecommended=跳过此文件(&S)(不推荐)
FileAbortRetryIgnoreIgnoreNotRecommended=忽略错误并继续(&I)(不推荐)
SourceIsCorrupted=源文件已损坏。
SourceDoesntExist=源文件“%1”不存在。
SourceVerificationFailed=源文件验证失败:%1
VerificationSignatureDoesntExist=签名文件“%1”不存在。
VerificationSignatureInvalid=签名文件“%1”无效。
VerificationKeyNotFound=签名文件“%1”使用了未知的密钥。
VerificationFileNameIncorrect=文件名不正确。
VerificationFileTagIncorrect=文件标签不正确。
VerificationFileSizeIncorrect=文件大小不正确。
VerificationFileHashIncorrect=文件哈希值不正确。
ExistingFileReadOnly2=无法替换已存在的文件,它是只读的。
ExistingFileReadOnlyRetry=移除只读属性并重试(&R)
ExistingFileReadOnlyKeepExisting=保留已存在的文件(&K)
ErrorReadingExistingDest=尝试读取已存在的文件时出错:
FileExistsSelectAction=选择操作
FileExists2=文件已经存在。
FileExistsOverwriteExisting=覆盖已存在的文件(&O)
FileExistsKeepExisting=保留已存在的文件(&K)
FileExistsOverwriteOrKeepAll=为接下来的冲突文件执行此操作(&D)
ExistingFileNewerSelectAction=选择操作
ExistingFileNewer2=已存在的文件比安装程序将要安装的文件还要新。
ExistingFileNewerOverwriteExisting=覆盖已存在的文件(&O)
ExistingFileNewerKeepExisting=保留已存在的文件(&K)(推荐)
ExistingFileNewerOverwriteOrKeepAll=为接下来的冲突文件执行此操作(&D)
ErrorChangingAttr=尝试更改下列已存在的文件属性时出错:
ErrorCreatingTemp=尝试在目标目录创建文件时出错:
ErrorReadingSource=尝试读取下列源文件时出错:
ErrorCopying=尝试复制下列文件时出错:
ErrorDownloading=尝试下载文件时出错:
ErrorExtracting=尝试提取压缩包时出错:
ErrorReplacingExistingFile=尝试替换已存在的文件时出错:
ErrorRestartReplace=重启并替换失败:
ErrorRenamingTemp=尝试重命名下列目标目录中的一个文件时出错:
ErrorRegisterServer=无法注册 DLL/OCX:%1
ErrorRegSvr32Failed=RegSvr32 失败;退出代码 %1。
ErrorRegisterTypeLib=无法注册类型库:%1
; *** Uninstall display name markings
; used for example as 'My Program (32-bit)'
UninstallDisplayNameMark=%1 (%2)
; used for example as 'My Program (32-bit, All users)'
UninstallDisplayNameMarks=%1 (%2, %3)
UninstallDisplayNameMark32Bit=32 位
UninstallDisplayNameMark64Bit=64 位
UninstallDisplayNameMarkAllUsers=所有用户
UninstallDisplayNameMarkCurrentUser=当前用户
; *** Post-installation errors
ErrorOpeningReadme=尝试打开自述文件时出错。
ErrorRestartingComputer=安装程序无法重启计算机,请手动重启。
; *** Uninstaller messages
UninstallNotFound=文件“%1”不存在。无法卸载。
UninstallOpenError=文件“%1”不能被打开。无法卸载
UninstallUnsupportedVer=此版本的卸载程序无法识别卸载日志文件“%1”的格式。无法卸载。
UninstallUnknownEntry=卸载日志中遇到一个未知条目(%1)。
ConfirmUninstall=您确认要完全移除 %1 及其所有组件吗?
UninstallOnlyOnWin64=仅允许在 64 位 Windows 中卸载此程序。
OnlyAdminCanUninstall=仅使用管理员权限的用户能完成此卸载。
UninstallStatusLabel=正在从您的计算机中移除 %1,请稍候。
UninstalledAll=已顺利从您的计算机中移除 %1。
UninstalledMost=%1 卸载完成。%n%n有部分内容未能被删除,但您可以手动删除它们。
UninstalledAndNeedsRestart=为完成 %1 的卸载,需要重启您的计算机。%n%n要立即重启吗?
UninstallDataCorrupted=文件“%1”已损坏。无法卸载。
; *** Uninstallation phase messages
ConfirmDeleteSharedFileTitle=删除共享文件?
ConfirmDeleteSharedFile2=系统表示下列共享文件已不再有任何程序使用。您希望卸载程序删除此共享文件吗?%n%n如果仍有程序正在使用此文件,删除后这些程序可能无法正常运行。如果您不能确定,请选择“否”,保留此文件在系统中不会造成任何损害。
SharedFileNameLabel=文件名:
SharedFileLocationLabel=位置:
WizardUninstalling=卸载状态
StatusUninstalling=正在卸载 %1...
; *** Shutdown block reasons
ShutdownBlockReasonInstallingApp=正在安装 %1。
ShutdownBlockReasonUninstallingApp=正在卸载 %1。
; The custom messages below aren't used by Setup itself, but if you make
; use of them in your scripts, you'll want to translate them.
[CustomMessages]
NameAndVersion=%1 版本 %2
AdditionalIcons=附加快捷方式:
CreateDesktopIcon=创建桌面快捷方式(&D)
CreateQuickLaunchIcon=创建快速启动栏快捷方式(&Q)
ProgramOnTheWeb=%1 网站
UninstallProgram=卸载 %1
LaunchProgram=运行 %1
AssocFileExtension=将 %2 文件扩展名与 %1 建立关联(&A)
AssocingFileExtension=正在将 %2 文件扩展名与 %1 建立关联...
AutoStartProgramGroupDescription=启动:
AutoStartProgram=自动启动 %1
AddonHostProgramNotFound=您选择的文件夹中无法找到 %1。%n%n您确定要继续吗?
+82
View File
@@ -0,0 +1,82 @@
#define MyAppName "ashell"
#define MyAppUserModelId "dev.ashell.app"
#define MyAppVersion GetEnv("PACKAGE_VERSION")
#define MyAppArch GetEnv("ASHELL_ARCH")
#define MyAppExeSource GetEnv("ASHELL_BINARY_PATH")
#define MyOutputDir GetEnv("ASHELL_OUTPUT_DIR")
#define MyOutputBaseName GetEnv("PACKAGE_BASENAME")
#if MyAppVersion == ""
#error PACKAGE_VERSION is required
#endif
#if MyAppExeSource == ""
#error ASHELL_BINARY_PATH is required
#endif
#if MyOutputDir == ""
#error ASHELL_OUTPUT_DIR is required
#endif
#if MyOutputBaseName == ""
#error PACKAGE_BASENAME is required
#endif
#if MyAppArch == "x64"
#define MyAppId "dev.ashell.app.x64"
#elif MyAppArch == "x86"
#define MyAppId "dev.ashell.app.x86"
#elif MyAppArch == "arm64"
#define MyAppId "dev.ashell.app.arm64"
#else
#error Unsupported ASHELL_ARCH value
#endif
[Setup]
AppId={#MyAppId}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher=ashell contributors
AppPublisherURL=https://github.com/rust-kotlin/ashell
AppSupportURL=https://github.com/rust-kotlin/ashell/issues
AppUpdatesURL=https://github.com/rust-kotlin/ashell/releases
DefaultDirName={localappdata}\Programs\ashell
DefaultGroupName=ashell
DisableProgramGroupPage=yes
PrivilegesRequired=lowest
OutputDir={#MyOutputDir}
OutputBaseFilename={#MyOutputBaseName}-setup
SetupIconFile=..\..\assets\icons\ashell.ico
UninstallDisplayIcon={app}\ashell.exe
LicenseFile=..\..\LICENSE
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
MinVersion=10.0
CloseApplications=yes
RestartApplications=no
#if MyAppArch == "x64"
ArchitecturesAllowed=x64compatible and not arm64
ArchitecturesInstallIn64BitMode=x64compatible
#elif MyAppArch == "x86"
ArchitecturesAllowed=x86compatible and not x64compatible and not arm64
ArchitecturesInstallIn64BitMode=
#elif MyAppArch == "arm64"
ArchitecturesAllowed=arm64
ArchitecturesInstallIn64BitMode=arm64
#endif
[Languages]
Name: "en"; MessagesFile: "compiler:Default.isl"
Name: "zhcn"; MessagesFile: "ChineseSimplified.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "{#MyAppExeSource}"; DestDir: "{app}"; DestName: "ashell.exe"; Flags: ignoreversion
[Icons]
Name: "{autoprograms}\ashell"; Filename: "{app}\ashell.exe"; AppUserModelID: "{#MyAppUserModelId}"
Name: "{autodesktop}\ashell"; Filename: "{app}\ashell.exe"; AppUserModelID: "{#MyAppUserModelId}"; Tasks: desktopicon
[Run]
Filename: "{app}\ashell.exe"; Description: "{cm:LaunchProgram,ashell}"; Flags: nowait postinstall skipifsilent
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "stacksafe-macro"
version = "0.1.4"
edition = "2021"
rust-version = "1.80.0"
description = "Procedural macro implementation for the stacksafe crate."
license = "Apache-2.0"
repository = "https://github.com/fast/stacksafe"
publish = false
[lib]
proc-macro = true
[dependencies]
quote = "1"
syn = { version = "2", features = ["full"] }
+73
View File
@@ -0,0 +1,73 @@
// Copyright 2025 FastLabs Developers
// Licensed under the Apache License, Version 2.0.
//! Procedural macro implementation for the `stacksafe` crate.
//!
//! This local compatibility patch preserves the 0.1.4 API while replacing
//! the unmaintained `proc-macro-error2` dependency with native `syn` errors.
use proc_macro::TokenStream;
use quote::{quote, ToTokens};
use syn::{parse_macro_input, parse_quote, ItemFn, Path, ReturnType, Type};
#[proc_macro_attribute]
pub fn stacksafe(args: TokenStream, item: TokenStream) -> TokenStream {
let mut crate_path: Option<Path> = None;
let arg_parser = syn::meta::parser(|meta| {
if meta.path.is_ident("crate") {
crate_path = Some(meta.value()?.parse()?);
Ok(())
} else {
Err(meta.error(format!(
"unknown attribute parameter `{}`",
meta.path
.get_ident()
.map_or("unknown".to_string(), |ident| ident.to_string())
)))
}
});
parse_macro_input!(args with arg_parser);
let mut item_fn: ItemFn = match syn::parse(item) {
Ok(item_fn) => item_fn,
Err(error) => {
return syn::Error::new(
error.span(),
"#[stacksafe] can only be applied to functions",
)
.into_compile_error()
.into();
}
};
if let Some(asyncness) = &item_fn.sig.asyncness {
return syn::Error::new_spanned(asyncness, "#[stacksafe] does not support async functions")
.into_compile_error()
.into();
}
let block = item_fn.block;
let ret = match &item_fn.sig.output {
ReturnType::Type(_, ty) if matches!(**ty, Type::ImplTrait(_)) => ReturnType::Default,
_ => item_fn.sig.output.clone(),
};
let stacksafe_crate = crate_path.unwrap_or_else(|| parse_quote!(::stacksafe));
let wrapped_block = quote! {
{
#stacksafe_crate::internal::stacker::maybe_grow(
#stacksafe_crate::get_minimum_stack_size(),
#stacksafe_crate::get_stack_allocation_size(),
#stacksafe_crate::internal::with_protected(move || #ret { #block })
)
}
};
let wrapped_block = match syn::parse2(wrapped_block) {
Ok(block) => block,
Err(error) => return error.into_compile_error().into(),
};
item_fn.block = Box::new(wrapped_block);
item_fn.into_token_stream().into()
}
+9 -2
View File
@@ -9,6 +9,13 @@ CONTENTS_DIR="$APP_DIR/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS"
RESOURCES_DIR="$CONTENTS_DIR/Resources"
SIGN_IDENTITY="${SIGN_IDENTITY:--}"
APP_VERSION="${APP_VERSION:-$(sed -n 's/^version = "\([^"]*\)"/\1/p' "$ROOT_DIR/Cargo.toml" | head -n 1)}"
APP_BUILD_VERSION="${APP_BUILD_VERSION:-$APP_VERSION}"
if [[ -z "$APP_VERSION" ]]; then
echo "error: unable to read the package version from Cargo.toml" >&2
exit 1
fi
cd "$ROOT_DIR"
cargo build --release
@@ -40,9 +47,9 @@ cat > "$CONTENTS_DIR/Info.plist" <<EOF
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<string>$APP_VERSION</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>$APP_BUILD_VERSION</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>NSHighResolutionCapable</key>
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
pub(crate) const DEFAULT_COLS: u16 = 100;
pub(crate) const DEFAULT_ROWS: u16 = 30;
pub(crate) const SIDEBAR_WIDTH: f32 = 310.0;
pub(crate) const TERMINAL_SCROLLBAR_GUTTER: f32 = 16.0;
pub(crate) const TERMINAL_KEY_CONTEXT: &str = "AshellTerminal";
+232
View File
@@ -0,0 +1,232 @@
use std::{rc::Rc, time::Duration};
use gpui::{
App, ClipboardItem, ElementId, InteractiveElement as _, IntoElement, MouseButton,
ParentElement as _, Rems, RenderOnce, SharedString, StatefulInteractiveElement as _,
Styled as _, Window, div, prelude::FluentBuilder as _, px, rems,
};
use gpui_component::{
ActiveTheme as _, Icon, IconName, Sizable as _, Size,
button::{Button, ButtonVariants as _},
checkbox::Checkbox,
switch::Switch,
};
/// Shared application button size, matching the SSH file panel header controls.
pub(crate) const APP_BUTTON_SIZE: Size = Size::Small;
pub(crate) fn pointer_button(id: impl Into<ElementId>) -> Button {
Button::new(id).with_size(APP_BUTTON_SIZE).cursor_pointer()
}
/// Keep intentionally compact text readable when a view uses relative sizes.
pub(crate) fn ui_rems(size: f32) -> Rems {
rems(size.max(0.85))
}
pub(crate) fn pointer_checkbox(id: impl Into<ElementId>) -> Checkbox {
Checkbox::new(id).cursor_pointer()
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum SelectionState {
Unchecked,
Indeterminate,
Checked,
}
impl SelectionState {
pub(crate) fn from_counts(selected: usize, total: usize) -> Self {
if total == 0 || selected == 0 {
Self::Unchecked
} else if selected >= total {
Self::Checked
} else {
Self::Indeterminate
}
}
fn next_checked(self) -> bool {
self != Self::Checked
}
}
type SelectionClickHandler = Rc<dyn Fn(&bool, &mut Window, &mut App) + 'static>;
#[derive(IntoElement)]
pub(crate) struct PointerSelectionCheckbox {
id: ElementId,
state: SelectionState,
disabled: bool,
on_click: Option<SelectionClickHandler>,
}
impl PointerSelectionCheckbox {
pub(crate) fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
state: SelectionState::Unchecked,
disabled: false,
on_click: None,
}
}
pub(crate) fn state(mut self, state: SelectionState) -> Self {
self.state = state;
self
}
pub(crate) fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub(crate) fn on_click(
mut self,
handler: impl Fn(&bool, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
}
impl RenderOnce for PointerSelectionCheckbox {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let selected = self.state != SelectionState::Unchecked;
let border_color = if selected {
cx.theme().primary
} else {
cx.theme().input
};
let color = if self.disabled {
border_color.opacity(0.5)
} else {
border_color
};
let icon_color = if self.disabled {
cx.theme().primary_foreground.opacity(0.5)
} else {
cx.theme().primary_foreground
};
let radius = cx.theme().radius.min(px(4.));
let next_checked = self.state.next_checked();
let on_click = self.on_click.clone();
div()
.id(self.id)
.size(px(16.))
.flex_none()
.flex()
.items_center()
.justify_center()
.border_1()
.border_color(color)
.rounded(radius)
.bg(if selected {
color
} else {
cx.theme().input_background()
})
.when(cx.theme().shadow && !self.disabled, |this| this.shadow_xs())
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.on_mouse_down(MouseButton::Right, |_, _, cx| cx.stop_propagation())
.when(!self.disabled, |this| {
this.cursor_pointer().on_click(move |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
if let Some(on_click) = &on_click {
on_click(&next_checked, window, cx);
}
})
})
.when_some(
match self.state {
SelectionState::Unchecked => None,
SelectionState::Indeterminate => Some(IconName::Minus),
SelectionState::Checked => Some(IconName::Check),
},
|this, icon| {
this.child(
Icon::new(icon)
.with_size(Size::Small)
.text_color(icon_color),
)
},
)
}
}
pub(crate) fn pointer_switch(id: impl Into<ElementId>) -> Switch {
Switch::new(id).cursor_pointer()
}
#[derive(IntoElement)]
pub(crate) struct PointerClipboard {
id: ElementId,
value: SharedString,
tooltip: Option<SharedString>,
}
impl PointerClipboard {
pub(crate) fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
value: SharedString::default(),
tooltip: None,
}
}
pub(crate) fn value(mut self, value: impl Into<SharedString>) -> Self {
self.value = value.into();
self
}
pub(crate) fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
self.tooltip = Some(tooltip.into());
self
}
}
impl RenderOnce for PointerClipboard {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| ClipboardState::default());
let copied = state.read(cx).copied;
pointer_button(self.id)
.icon(if copied {
IconName::Check
} else {
IconName::Copy
})
.ghost()
.small()
.when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip))
.when(!copied, |this| {
let state = state.clone();
let value = self.value.clone();
this.on_click(move |_, _, cx| {
cx.stop_propagation();
cx.write_to_clipboard(ClipboardItem::new_string(value.to_string()));
state.update(cx, |state, cx| {
state.copied = true;
cx.notify();
});
let state = state.clone();
cx.spawn(async move |cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
state.update(cx, |state, cx| {
state.copied = false;
cx.notify();
});
})
.detach();
})
})
}
}
#[derive(Default)]
struct ClipboardState {
copied: bool,
}
+4487
View File
File diff suppressed because it is too large Load Diff
+480
View File
@@ -0,0 +1,480 @@
use gpui::{
Action as _, App, Entity, IntoElement, KeyBinding, KeyDownEvent, Keystroke, Unbind, prelude::*,
};
use gpui_component::{
Sizable,
button::ButtonVariants,
kbd::Kbd,
setting::{SettingField, SettingGroup, SettingItem},
};
use rust_i18n::t;
use crate::{Ashell, app::controls::pointer_button, session::config::ConfigStore};
gpui::actions!(
ashell_workspace,
[
OpenSettings,
OpenSession,
OpenTransfers,
NewSsh,
OpenSearch,
QuitApplication,
ToggleSidebar,
ToggleSftpZoom,
FocusPaneLeft,
FocusPaneRight,
FocusPaneUp,
FocusPaneDown,
SplitPaneLeft,
SplitPaneRight,
SplitPaneUp,
SplitPaneDown,
ClosePane,
Copy,
Paste
]
);
pub struct KeybindingsPage;
#[derive(Clone, Copy)]
pub(crate) struct WorkspaceAction {
id: &'static str,
label_key: &'static str,
default_suffix: &'static str,
}
pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[
WorkspaceAction {
id: "OpenSettings",
label_key: "settings_open_settings",
default_suffix: ",",
},
WorkspaceAction {
id: "OpenSession",
label_key: "settings_open_session",
default_suffix: "o",
},
WorkspaceAction {
id: "OpenTransfers",
label_key: "settings_open_transfers",
default_suffix: "t",
},
WorkspaceAction {
id: "NewSsh",
label_key: "settings_new_connection",
default_suffix: "n",
},
WorkspaceAction {
id: "OpenSearch",
label_key: "settings_open_search",
default_suffix: "f",
},
WorkspaceAction {
id: "QuitApplication",
label_key: "settings_quit_application",
default_suffix: "q",
},
WorkspaceAction {
id: "ToggleSidebar",
label_key: "settings_toggle_sidebar",
default_suffix: "s",
},
WorkspaceAction {
id: "ToggleSftpZoom",
label_key: "settings_toggle_sftp_zoom",
default_suffix: "m",
},
WorkspaceAction {
id: "FocusPaneLeft",
label_key: "settings_focus_pane_left",
default_suffix: "h",
},
WorkspaceAction {
id: "FocusPaneRight",
label_key: "settings_focus_pane_right",
default_suffix: "l",
},
WorkspaceAction {
id: "FocusPaneUp",
label_key: "settings_focus_pane_up",
default_suffix: "k",
},
WorkspaceAction {
id: "FocusPaneDown",
label_key: "settings_focus_pane_down",
default_suffix: "j",
},
WorkspaceAction {
id: "SplitPaneLeft",
label_key: "settings_split_pane_left",
default_suffix: "shift-h",
},
WorkspaceAction {
id: "SplitPaneRight",
label_key: "settings_split_pane_right",
default_suffix: "shift-l",
},
WorkspaceAction {
id: "SplitPaneUp",
label_key: "settings_split_pane_up",
default_suffix: "shift-k",
},
WorkspaceAction {
id: "SplitPaneDown",
label_key: "settings_split_pane_down",
default_suffix: "shift-j",
},
WorkspaceAction {
id: "ClosePane",
label_key: "settings_close_pane",
default_suffix: "w",
},
WorkspaceAction {
id: "Copy",
label_key: "settings_copy",
default_suffix: if cfg!(target_os = "macos") {
"c"
} else {
"shift-c"
},
},
WorkspaceAction {
id: "Paste",
label_key: "settings_paste",
default_suffix: if cfg!(target_os = "macos") {
"v"
} else {
"shift-v"
},
},
];
pub(crate) fn default_modifier() -> &'static str {
if cfg!(target_os = "macos") {
"cmd"
} else {
"ctrl"
}
}
pub(crate) fn default_keystroke(action_id: &str) -> Option<String> {
if action_id == "QuitApplication" && cfg!(target_os = "windows") {
return Some("alt-f4".to_string());
}
WORKSPACE_ACTIONS
.iter()
.find(|action| action.id == action_id)
.map(|action| format!("{}-{}", default_modifier(), action.default_suffix))
}
pub(crate) fn configured_keystroke(config: &ConfigStore, action_id: &str) -> Option<String> {
config
.key_bindings()
.get(action_id)
.cloned()
.or_else(|| default_keystroke(action_id))
}
pub(crate) fn normalize_recorded_keystroke(event: &KeyDownEvent) -> Option<String> {
let key = event.keystroke.key.trim();
if key.is_empty() {
return None;
}
let mut parts = Vec::new();
if event.keystroke.modifiers.control {
parts.push("ctrl".to_string());
}
if event.keystroke.modifiers.alt {
parts.push("alt".to_string());
}
if event.keystroke.modifiers.shift {
parts.push("shift".to_string());
}
if event.keystroke.modifiers.platform {
parts.push("cmd".to_string());
}
if event.keystroke.modifiers.function {
parts.push("fn".to_string());
}
parts.push(key.to_ascii_lowercase());
let keystroke = parts.join("-");
Keystroke::parse(&keystroke).ok().map(|_| keystroke)
}
pub(crate) fn format_keystroke(keystroke: &str) -> String {
Keystroke::parse(keystroke)
.map(|stroke| Kbd::format(&stroke))
.unwrap_or_else(|_| keystroke.to_string())
}
pub(crate) fn bind_workspace_keys_from_config(cx: &mut App, config: &ConfigStore) {
bind_workspace_actions(cx, config);
}
pub(crate) fn suspend_quit_keybinding(cx: &mut App, config: &ConfigStore) {
let default = default_keystroke("QuitApplication").expect("quit action has a default key");
let configured =
configured_keystroke(config, "QuitApplication").unwrap_or_else(|| default.clone());
let action_name = crate::QuitApplication.name();
let mut bindings = vec![KeyBinding::new(&default, Unbind(action_name.into()), None)];
if configured != default && configured != "none" && !configured.is_empty() {
bindings.push(KeyBinding::new(
&configured,
Unbind(action_name.into()),
None,
));
}
cx.bind_keys(bindings);
}
pub(crate) fn restore_quit_keybinding(cx: &mut App, config: &ConfigStore) {
let default = default_keystroke("QuitApplication").expect("quit action has a default key");
let configured =
configured_keystroke(config, "QuitApplication").unwrap_or_else(|| default.clone());
let action_name = crate::QuitApplication.name();
let mut bindings = Vec::new();
if configured != default {
bindings.push(KeyBinding::new(&default, Unbind(action_name.into()), None));
}
if configured != "none" && !configured.is_empty() {
bindings.push(KeyBinding::new(&configured, crate::QuitApplication, None));
}
cx.bind_keys(bindings);
}
/// Unbind workspace keybindings that could interfere with key recording.
///
/// The quit binding remains active so the application can always be closed while
/// the settings dialog is open.
pub(crate) fn unbind_all_workspace_keys(cx: &mut App, config: &ConfigStore) {
let mut bindings = Vec::new();
macro_rules! unbind_action {
($id:literal, $action:expr) => {
let default = default_keystroke($id).expect("workspace action has default key");
let configured = configured_keystroke(config, $id).unwrap_or_else(|| default.clone());
let action_name = $action.name();
// Unbind both the default and configured keystroke
bindings.push(KeyBinding::new(&default, Unbind(action_name.into()), None));
if configured != default && configured != "none" && !configured.is_empty() {
bindings.push(KeyBinding::new(
&configured,
Unbind(action_name.into()),
None,
));
}
};
}
unbind_action!("OpenSettings", crate::OpenSettings);
unbind_action!("OpenSession", crate::OpenSession);
unbind_action!("OpenTransfers", crate::OpenTransfers);
unbind_action!("NewSsh", crate::NewSsh);
unbind_action!("OpenSearch", crate::OpenSearch);
unbind_action!("ToggleSidebar", crate::ToggleSidebar);
unbind_action!("ToggleSftpZoom", crate::ToggleSftpZoom);
unbind_action!("FocusPaneLeft", crate::FocusPaneLeft);
unbind_action!("FocusPaneRight", crate::FocusPaneRight);
unbind_action!("FocusPaneUp", crate::FocusPaneUp);
unbind_action!("FocusPaneDown", crate::FocusPaneDown);
unbind_action!("SplitPaneLeft", crate::SplitPaneLeft);
unbind_action!("SplitPaneRight", crate::SplitPaneRight);
unbind_action!("SplitPaneUp", crate::SplitPaneUp);
unbind_action!("SplitPaneDown", crate::SplitPaneDown);
unbind_action!("ClosePane", crate::ClosePane);
unbind_action!("Copy", crate::Copy);
unbind_action!("Paste", crate::Paste);
cx.bind_keys(bindings);
}
/// Check if a keystroke conflicts with any other action's binding.
/// Returns Some((conflicting_action_id, label)) if there is a conflict.
pub(crate) fn find_conflict(
config: &ConfigStore,
current_action_id: &str,
new_keystroke: &str,
) -> Option<(String, String)> {
for action in WORKSPACE_ACTIONS {
if action.id == current_action_id {
continue;
}
let existing = configured_keystroke(config, action.id).unwrap_or_default();
if !existing.is_empty() && existing != "none" && existing == new_keystroke {
return Some((action.id.to_string(), t!(action.label_key).to_string()));
}
}
None
}
fn bind_workspace_actions(cx: &mut App, config: &ConfigStore) {
let mut bindings = Vec::new();
macro_rules! bind_action {
($id:literal, $action:expr) => {
let default = default_keystroke($id).expect("workspace action has default key");
let configured = configured_keystroke(config, $id).unwrap_or_else(|| default.clone());
let action_name = $action.name();
if configured != default {
bindings.push(KeyBinding::new(&default, Unbind(action_name.into()), None));
}
if configured != "none" && !configured.is_empty() {
bindings.push(KeyBinding::new(&configured, $action, None));
}
};
}
bind_action!("OpenSettings", crate::OpenSettings);
bind_action!("OpenSession", crate::OpenSession);
bind_action!("OpenTransfers", crate::OpenTransfers);
bind_action!("NewSsh", crate::NewSsh);
bind_action!("OpenSearch", crate::OpenSearch);
bind_action!("QuitApplication", crate::QuitApplication);
bind_action!("ToggleSidebar", crate::ToggleSidebar);
bind_action!("ToggleSftpZoom", crate::ToggleSftpZoom);
bind_action!("FocusPaneLeft", crate::FocusPaneLeft);
bind_action!("FocusPaneRight", crate::FocusPaneRight);
bind_action!("FocusPaneUp", crate::FocusPaneUp);
bind_action!("FocusPaneDown", crate::FocusPaneDown);
bind_action!("SplitPaneLeft", crate::SplitPaneLeft);
bind_action!("SplitPaneRight", crate::SplitPaneRight);
bind_action!("SplitPaneUp", crate::SplitPaneUp);
bind_action!("SplitPaneDown", crate::SplitPaneDown);
bind_action!("ClosePane", crate::ClosePane);
bind_action!("Copy", crate::Copy);
bind_action!("Paste", crate::Paste);
cx.bind_keys(bindings);
}
impl KeybindingsPage {
pub fn render_groups(view: &Entity<Ashell>, cx: &mut App) -> Vec<SettingGroup> {
let groups = [
(
"settings_group_keybind_general",
vec![
"OpenSettings",
"OpenSession",
"OpenTransfers",
"NewSsh",
"OpenSearch",
"QuitApplication",
"Copy",
"Paste",
],
),
(
"settings_group_keybind_zoom",
vec!["ToggleSidebar", "ToggleSftpZoom"],
),
(
"settings_group_keybind_focus",
vec![
"FocusPaneLeft",
"FocusPaneRight",
"FocusPaneUp",
"FocusPaneDown",
],
),
(
"settings_group_keybind_panel",
vec![
"SplitPaneLeft",
"SplitPaneRight",
"SplitPaneUp",
"SplitPaneDown",
"ClosePane",
],
),
];
let mut result = Vec::new();
for (group_label, action_ids) in groups {
let mut group = SettingGroup::new().title(t!(group_label).to_string());
for action_id in action_ids {
let action = WORKSPACE_ACTIONS
.iter()
.find(|a| a.id == action_id)
.expect("action exists");
let recording = view.read(cx).recording_action.as_deref() == Some(action.id);
let has_error = view
.read(cx)
.keybind_error
.as_ref()
.is_some_and(|(id, _)| id == action.id);
let error_msg = if has_error {
view.read(cx)
.keybind_error
.as_ref()
.map(|(_, msg)| msg.clone())
} else {
None
};
let keystroke = {
let config = &view.read(cx).config;
configured_keystroke(config, action.id).unwrap_or_default()
};
let btn_label = if recording {
t!("press_new_key").to_string()
} else if keystroke.is_empty() {
t!("none").to_string()
} else {
format_keystroke(&keystroke)
};
let mut item = SettingItem::new(
t!(action.label_key).to_string(),
SettingField::render({
let view = view.clone();
let action_id = action.id.to_string();
move |_, _window, _cx| {
pointer_button(gpui::SharedString::from(format!("keybind-{action_id}")))
.label(btn_label.clone())
.small()
.when(recording, |this| this.primary())
.when(has_error, |this| this.danger())
.on_click({
let view = view.clone();
let action_id = action_id.clone();
move |_event, window, cx| {
view.update(cx, |this, cx| {
// Clear any previous error when starting new recording
this.keybind_error = None;
suspend_quit_keybinding(cx, &this.config);
this.recording_action = Some(action_id.clone());
this.focus_handle.focus(window, cx);
cx.notify();
});
}
})
.into_any_element()
}
}),
);
if let Some(msg) = error_msg {
item = item.description(msg);
}
group = group.item(item);
}
result.push(group);
}
result
}
}
+2373
View File
File diff suppressed because it is too large Load Diff
+353
View File
@@ -0,0 +1,353 @@
#![allow(dead_code)]
use std::ops::Range;
use gpui::{
Along, App, Axis, Bounds, Context, ElementId, EventEmitter, IsZero, Pixels, Window, px,
};
mod panel;
mod resize_handle;
pub use panel::*;
pub(crate) use resize_handle::*;
pub(crate) const PANEL_MIN_SIZE: Pixels = px(100.);
pub enum ResizablePanelEvent {
Resized,
}
/// Create a [`ResizablePanelGroup`] with horizontal resizing
pub fn h_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
ResizablePanelGroup::new(id).axis(Axis::Horizontal)
}
/// Create a [`ResizablePanelGroup`] with vertical resizing
pub fn v_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
ResizablePanelGroup::new(id).axis(Axis::Vertical)
}
/// Create a [`ResizablePanel`].
pub fn resizable_panel() -> ResizablePanel {
ResizablePanel::new()
}
/// State for a [`ResizablePanel`]
#[derive(Debug, Clone)]
pub struct ResizableState {
/// The `axis` will sync to actual axis of the ResizablePanelGroup in use.
axis: Axis,
pub(crate) panels: Vec<ResizablePanelState>,
pub(crate) sizes: Vec<Pixels>,
pub(crate) resizing_panel_ix: Option<usize>,
pub(crate) bounds: Bounds<Pixels>,
}
impl Default for ResizableState {
fn default() -> Self {
Self {
axis: Axis::Horizontal,
panels: vec![],
sizes: vec![],
resizing_panel_ix: None,
bounds: Bounds::default(),
}
}
}
impl ResizableState {
/// Get the size of the panels.
pub fn sizes(&self) -> &Vec<Pixels> {
&self.sizes
}
/// Programmatically resize the panel at `ix` to `size`, redistributing
/// space among siblings using the same logic as a drag.
///
/// Sizes are clamped to the panel's `size_range` and to the container.
/// Emits `ResizablePanelEvent::Resized` so subscribers (e.g. preference
/// persistence) see the change just as if the user had dragged a handle.
///
/// Out-of-range indices are a no-op. For the last panel, space is taken
/// from the previous sibling (the last panel has no handle of its own).
pub fn resize_panel(
&mut self,
ix: usize,
size: Pixels,
window: &mut Window,
cx: &mut Context<Self>,
) {
if ix >= self.sizes.len() {
return;
}
if ix + 1 < self.sizes.len() {
self.resize_panel_at_handle(ix, size, window, cx);
} else if ix > 0 {
// Last panel: drive its size by resizing the previous sibling so
// the freed space lands here.
let delta = self.sizes[ix] - size;
let prev = self.sizes[ix - 1];
self.resize_panel_at_handle(ix - 1, prev + delta, window, cx);
}
self.done_resizing(cx);
}
pub(crate) fn insert_panel(
&mut self,
size: Option<Pixels>,
ix: Option<usize>,
cx: &mut Context<Self>,
) {
let panel_state = ResizablePanelState {
size,
..Default::default()
};
let size = size.unwrap_or(PANEL_MIN_SIZE);
// We make sure that the size always sums up to the container size
// by reducing the size of all other panels first.
let container_size = self.container_size().max(px(1.));
let total_leftover_size = (container_size - size).max(px(1.));
for (i, panel) in self.panels.iter_mut().enumerate() {
let ratio = self.sizes[i] / container_size;
self.sizes[i] = total_leftover_size * ratio;
panel.size = Some(self.sizes[i]);
}
if let Some(ix) = ix {
self.panels.insert(ix, panel_state);
self.sizes.insert(ix, size);
} else {
self.panels.push(panel_state);
self.sizes.push(size);
};
cx.notify();
}
pub(crate) fn sync_panels_count(
&mut self,
axis: Axis,
panels_count: usize,
fit_to_container: bool,
cx: &mut Context<Self>,
) {
let mut changed = self.axis != axis;
self.axis = axis;
if panels_count > self.panels.len() {
let diff = panels_count - self.panels.len();
self.panels
.extend(vec![ResizablePanelState::default(); diff]);
self.sizes.extend(vec![PANEL_MIN_SIZE; diff]);
changed = true;
}
if panels_count < self.panels.len() {
self.panels.truncate(panels_count);
self.sizes.truncate(panels_count);
changed = true;
}
if changed && fit_to_container {
// We need to make sure the total size is in line with the container size.
self.adjust_to_container_size(cx);
}
}
pub(crate) fn update_panel_size(
&mut self,
panel_ix: usize,
bounds: Bounds<Pixels>,
size_range: Range<Pixels>,
cx: &mut Context<Self>,
) {
let size = bounds.size.along(self.axis);
// This check is only necessary to stop the very first panel from resizing on its own
// it needs to be passed when the panel is freshly created so we get the initial size,
// but its also fine when it sometimes passes later.
if self.sizes[panel_ix].as_f32() == PANEL_MIN_SIZE.as_f32() {
self.sizes[panel_ix] = size;
self.panels[panel_ix].size = Some(size);
}
self.panels[panel_ix].bounds = bounds;
self.panels[panel_ix].size_range = size_range;
cx.notify();
}
pub(crate) fn remove_panel(&mut self, panel_ix: usize, cx: &mut Context<Self>) {
self.panels.remove(panel_ix);
self.sizes.remove(panel_ix);
if let Some(resizing_panel_ix) = self.resizing_panel_ix {
if resizing_panel_ix > panel_ix {
self.resizing_panel_ix = Some(resizing_panel_ix - 1);
}
}
self.adjust_to_container_size(cx);
}
pub(crate) fn replace_panel(
&mut self,
panel_ix: usize,
panel: ResizablePanelState,
cx: &mut Context<Self>,
) {
let old_size = self.sizes[panel_ix];
self.panels[panel_ix] = panel;
self.sizes[panel_ix] = old_size;
self.adjust_to_container_size(cx);
}
pub(crate) fn clear(&mut self) {
self.panels.clear();
self.sizes.clear();
}
#[inline]
pub(crate) fn container_size(&self) -> Pixels {
self.bounds.size.along(self.axis)
}
pub(crate) fn done_resizing(&mut self, cx: &mut Context<Self>) {
self.resizing_panel_ix = None;
cx.emit(ResizablePanelEvent::Resized);
}
fn panel_size_range(&self, ix: usize) -> Range<Pixels> {
let Some(panel) = self.panels.get(ix) else {
return PANEL_MIN_SIZE..Pixels::MAX;
};
panel.size_range.clone()
}
fn sync_real_panel_sizes(&mut self, _: &App) {
for (i, panel) in self.panels.iter().enumerate() {
self.sizes[i] = panel.bounds.size.along(self.axis);
}
}
pub(crate) fn resize_panel_at_handle(
&mut self,
ix: usize,
size: Pixels,
_: &mut Window,
cx: &mut Context<Self>,
) {
let old_sizes = self.sizes.clone();
let mut ix = ix;
if ix >= old_sizes.len() - 1 {
return;
}
let container_size = self.container_size();
self.sync_real_panel_sizes(cx);
let move_changed = size - old_sizes[ix];
if move_changed == px(0.) {
return;
}
let size_range = self.panel_size_range(ix);
let new_size = size.clamp(size_range.start, size_range.end);
let is_expand = move_changed > px(0.);
let main_ix = ix;
let mut new_sizes = old_sizes.clone();
if is_expand {
let mut changed = new_size - old_sizes[ix];
new_sizes[ix] = new_size;
while changed > px(0.) && ix < old_sizes.len() - 1 {
ix += 1;
let size_range = self.panel_size_range(ix);
let available_size = (new_sizes[ix] - size_range.start).max(px(0.));
let to_reduce = changed.min(available_size);
new_sizes[ix] -= to_reduce;
changed -= to_reduce;
}
} else {
let mut changed = new_size - size;
new_sizes[ix] = new_size;
while changed > px(0.) && ix > 0 {
ix -= 1;
let size_range = self.panel_size_range(ix);
let available_size = (new_sizes[ix] - size_range.start).max(px(0.));
let to_reduce = changed.min(available_size);
changed -= to_reduce;
new_sizes[ix] -= to_reduce;
}
new_sizes[main_ix + 1] += old_sizes[main_ix] - size - changed;
}
let total_size: Pixels = new_sizes.iter().map(|s| s.as_f32()).sum::<f32>().into();
if total_size > container_size {
let overflow = total_size - container_size;
new_sizes[main_ix] = (new_sizes[main_ix] - overflow).max(size_range.start);
}
for (i, _) in old_sizes.iter().enumerate() {
let size = new_sizes[i];
self.panels[i].size = Some(size);
}
self.sizes = new_sizes;
cx.notify();
}
pub(crate) fn resize_panel_independently(
&mut self,
ix: usize,
size: Pixels,
_: &mut Window,
cx: &mut Context<Self>,
) {
if ix >= self.sizes.len() {
return;
}
self.sync_real_panel_sizes(cx);
let size_range = self.panel_size_range(ix);
let new_size = size.clamp(size_range.start, size_range.end);
if new_size == self.sizes[ix] {
return;
}
self.sizes[ix] = new_size;
self.panels[ix].size = Some(new_size);
cx.notify();
}
pub(crate) fn adjust_to_container_size(&mut self, cx: &mut Context<Self>) {
if self.container_size().is_zero() {
return;
}
let container_size = self.container_size();
let total_size = px(self.sizes.iter().map(|s| s.as_f32()).sum::<f32>());
for i in 0..self.panels.len() {
let size = self.sizes[i];
let ratio = size / total_size;
let new_size = container_size * ratio;
self.sizes[i] = new_size;
self.panels[i].size = Some(new_size);
}
cx.notify();
}
}
impl EventEmitter<ResizablePanelEvent> for ResizableState {}
#[derive(Debug, Clone, Default)]
pub(crate) struct ResizablePanelState {
pub size: Option<Pixels>,
pub size_range: Range<Pixels>,
pub(crate) bounds: Bounds<Pixels>,
}
+458
View File
@@ -0,0 +1,458 @@
use std::{
ops::{Deref, Range},
rc::Rc,
};
use gpui::{
Along, AnyElement, App, AppContext as _, Axis, Bounds, Context, Element, ElementId, Empty,
Entity, EventEmitter, InteractiveElement as _, IntoElement, IsZero as _, MouseMoveEvent,
MouseUpEvent, ParentElement, Pixels, Render, RenderOnce, Style, StyleRefinement, Styled,
Window, div, prelude::FluentBuilder,
};
use gpui_component::{AxisExt, ElementExt, StyledExt, h_flex, v_flex};
use super::{PANEL_MIN_SIZE, ResizablePanelEvent, ResizableState, resizable_panel, resize_handle};
type ResizeCallback = Rc<dyn Fn(&Entity<ResizableState>, &mut Window, &mut App)>;
#[derive(Clone)]
pub(crate) struct DragPanel;
impl Render for DragPanel {
fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement {
Empty
}
}
/// A group of resizable panels.
#[derive(IntoElement)]
pub struct ResizablePanelGroup {
id: ElementId,
state: Option<Entity<ResizableState>>,
axis: Axis,
size: Option<Pixels>,
children: Vec<ResizablePanel>,
on_resize: ResizeCallback,
locked: bool,
independent_resize: bool,
}
impl ResizablePanelGroup {
/// Create a new resizable panel group.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
axis: Axis::Horizontal,
children: vec![],
state: None,
size: None,
on_resize: Rc::new(|_, _, _| {}),
locked: false,
independent_resize: false,
}
}
/// Bind yourself to a resizable state entity.
///
/// If not provided, it will handle its own state internally.
pub fn with_state(mut self, state: &Entity<ResizableState>) -> Self {
self.state = Some(state.clone());
self
}
/// Set the axis of the resizable panel group, default is horizontal.
pub fn axis(mut self, axis: Axis) -> Self {
self.axis = axis;
self
}
/// Set lock status of the resizable panel group.
pub fn lock(mut self, locked: bool) -> Self {
self.locked = locked;
self
}
/// Resize each panel without redistributing space among its siblings.
/// This lets the total group size grow and adds a trailing handle to the last panel.
pub fn independent_resize(mut self) -> Self {
self.independent_resize = true;
self
}
/// Add a panel to the group.
///
/// - The `axis` will be set to the same axis as the group.
/// - The `initial_size` will be set to the average size of all panels if not provided.
/// - The `group` will be set to the group entity.
pub fn child(mut self, panel: impl Into<ResizablePanel>) -> Self {
self.children.push(panel.into());
self
}
/// Add multiple panels to the group.
pub fn children<I>(mut self, panels: impl IntoIterator<Item = I>) -> Self
where
I: Into<ResizablePanel>,
{
self.children = panels.into_iter().map(|panel| panel.into()).collect();
self
}
/// Set size of the resizable panel group
///
/// - When the axis is horizontal, the size is the height of the group.
/// - When the axis is vertical, the size is the width of the group.
pub fn size(mut self, size: Pixels) -> Self {
self.size = Some(size);
self
}
/// Set the callback to be called when the panels are resized.
///
/// ## Callback arguments
///
/// - Entity<ResizableState>: The state of the ResizablePanelGroup.
pub fn on_resize(
mut self,
on_resize: impl Fn(&Entity<ResizableState>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_resize = Rc::new(on_resize);
self
}
}
impl<T> From<T> for ResizablePanel
where
T: Into<AnyElement>,
{
fn from(value: T) -> Self {
resizable_panel().child(value.into())
}
}
impl From<ResizablePanelGroup> for ResizablePanel {
fn from(value: ResizablePanelGroup) -> Self {
resizable_panel().child(value)
}
}
impl EventEmitter<ResizablePanelEvent> for ResizablePanelGroup {}
impl RenderOnce for ResizablePanelGroup {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = self.state.unwrap_or_else(|| {
window.use_keyed_state(self.id.clone(), cx, |_, _| ResizableState::default())
});
let container = if self.axis.is_horizontal() {
h_flex()
} else {
v_flex()
};
// Sync panels to the state
let panels_count = self.children.len();
state.update(cx, |state, cx| {
state.sync_panels_count(self.axis, panels_count, !self.independent_resize, cx);
});
container
.id(self.id)
.size_full()
.children(
self.children
.into_iter()
.enumerate()
.map(|(ix, mut panel)| {
panel.panel_ix = ix;
panel.axis = self.axis;
panel.state = Some(state.clone());
panel.locked = self.locked;
panel.trailing_resize_handle =
self.independent_resize && ix + 1 == panels_count;
panel
}),
)
.on_prepaint({
let state = state.clone();
move |bounds, _, cx| {
state.update(cx, |state, cx| {
let size_changed =
state.bounds.size.along(self.axis) != bounds.size.along(self.axis);
state.bounds = bounds;
if size_changed && !self.independent_resize {
state.adjust_to_container_size(cx);
}
})
}
})
.child(ResizePanelGroupElement {
state: state.clone(),
axis: self.axis,
on_resize: self.on_resize.clone(),
independent_resize: self.independent_resize,
})
}
}
/// A resizable panel inside a [`ResizablePanelGroup`].
#[derive(IntoElement)]
pub struct ResizablePanel {
axis: Axis,
panel_ix: usize,
state: Option<Entity<ResizableState>>,
/// Initial size is the size that the panel has when it is created.
initial_size: Option<Pixels>,
/// size range limit of this panel.
size_range: Range<Pixels>,
children: Vec<AnyElement>,
visible: bool,
style: StyleRefinement,
locked: bool,
trailing_resize_handle: bool,
}
impl ResizablePanel {
/// Create a new resizable panel.
pub(super) fn new() -> Self {
Self {
panel_ix: 0,
initial_size: None,
state: None,
size_range: (PANEL_MIN_SIZE..Pixels::MAX),
axis: Axis::Horizontal,
children: vec![],
visible: true,
style: StyleRefinement::default(),
locked: false,
trailing_resize_handle: false,
}
}
/// Set the visibility of the panel, default is true.
pub fn visible(mut self, visible: bool) -> Self {
self.visible = visible;
self
}
/// Set the initial size of the panel.
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
self.initial_size = Some(size.into());
self
}
/// Set the size range to limit panel resize.
///
/// Default is [`PANEL_MIN_SIZE`] to [`Pixels::MAX`].
pub fn size_range(mut self, range: impl Into<Range<Pixels>>) -> Self {
self.size_range = range.into();
self
}
}
impl Styled for ResizablePanel {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl ParentElement for ResizablePanel {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for ResizablePanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
if !self.visible {
return div().id(("resizable-panel", self.panel_ix));
}
let state = self
.state
.expect("BUG: The `state` in ResizablePanel should be present.");
let panel_state = state
.read(cx)
.panels
.get(self.panel_ix)
.expect("BUG: The `index` of ResizablePanel should be one of in `state`.");
let size_range = self.size_range.clone();
let leading_handle_state = state.clone();
let trailing_handle_state = state.clone();
div()
.id(("resizable-panel", self.panel_ix))
.flex()
.flex_grow_1()
.size_full()
.relative()
.refine_style(&self.style)
.when(self.axis.is_vertical(), |this| {
this.min_h(size_range.start).max_h(size_range.end)
})
.when(self.axis.is_horizontal(), |this| {
this.min_w(size_range.start).max_w(size_range.end)
})
.when(self.initial_size.is_none(), |this| this.flex_shrink_1())
.when_some(self.initial_size, |this, initial_size| {
this.when(
panel_state.size.is_none() && !initial_size.is_zero(),
|this| this.flex_none(),
)
.flex_basis(initial_size)
})
.map(|this| match panel_state.size {
Some(size) => this.flex_basis(size.min(size_range.end).max(size_range.start)),
None => this,
})
.on_prepaint({
let state = state.clone();
move |bounds, _, cx| {
state.update(cx, |state, cx| {
state.update_panel_size(self.panel_ix, bounds, self.size_range, cx)
})
}
})
.children(self.children)
.when(self.panel_ix > 0, |this| {
let ix = self.panel_ix - 1;
let handle = resize_handle(("resizable-handle", ix), self.axis);
let handle = if self.locked {
handle
} else {
handle.on_drag(DragPanel, move |drag_panel, _, _, cx| {
cx.stop_propagation();
leading_handle_state.update(cx, |state, cx| {
state.resizing_panel_ix = Some(ix);
cx.notify();
});
cx.new(|_| drag_panel.deref().clone())
})
};
this.child(handle)
})
.when(self.trailing_resize_handle, |this| {
let ix = self.panel_ix;
let handle = resize_handle(("resizable-handle", ix), self.axis)
.placement(gpui_component::dock::DockPlacement::Left);
let handle = if self.locked {
handle
} else {
handle.on_drag(DragPanel, move |drag_panel, _, _, cx| {
cx.stop_propagation();
trailing_handle_state.update(cx, |state, cx| {
state.resizing_panel_ix = Some(ix);
cx.notify();
});
cx.new(|_| drag_panel.deref().clone())
})
};
this.child(handle)
})
}
}
struct ResizePanelGroupElement {
state: Entity<ResizableState>,
on_resize: ResizeCallback,
axis: Axis,
independent_resize: bool,
}
impl IntoElement for ResizePanelGroupElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for ResizePanelGroupElement {
type RequestLayoutState = ();
type PrepaintState = ();
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
(window.request_layout(Style::default(), None, cx), ())
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_window: &mut Window,
_cx: &mut App,
) -> Self::PrepaintState {
}
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
_cx: &mut App,
) {
window.on_mouse_event({
let state = self.state.clone();
let axis = self.axis;
let independent_resize = self.independent_resize;
move |e: &MouseMoveEvent, phase, window, cx| {
if !phase.bubble() {
return;
}
let Some(ix) = state.read(cx).resizing_panel_ix else {
return;
};
state.update(cx, |state, cx| {
let panel = state.panels.get(ix).expect("BUG: invalid panel index");
let size = match axis {
Axis::Horizontal => e.position.x - panel.bounds.left(),
Axis::Vertical => e.position.y - panel.bounds.top(),
};
if independent_resize {
state.resize_panel_independently(ix, size, window, cx);
} else {
state.resize_panel_at_handle(ix, size, window, cx);
}
cx.notify();
})
}
});
// When any mouse up, stop dragging
window.on_mouse_event({
let state = self.state.clone();
let on_resize = self.on_resize.clone();
move |_: &MouseUpEvent, phase, window, cx| {
if state.read(cx).resizing_panel_ix.is_none() {
return;
}
if phase.bubble() {
state.update(cx, |state, cx| state.done_resizing(cx));
on_resize(&state, window, cx);
}
}
})
}
}
+223
View File
@@ -0,0 +1,223 @@
use std::{cell::Cell, rc::Rc};
use gpui::{
AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId, InteractiveElement,
IntoElement, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render,
StatefulInteractiveElement, Styled as _, Window, div, prelude::FluentBuilder as _, px,
};
use gpui_component::{ActiveTheme as _, AxisExt as _, dock::DockPlacement};
pub(crate) const HANDLE_PADDING: Pixels = px(4.);
pub(crate) const HANDLE_SIZE: Pixels = px(1.);
type DragCallback<E> = Rc<dyn Fn(&Point<Pixels>, &mut Window, &mut App) -> Entity<E>>;
/// Create a resize handle for a resizable panel.
pub(crate) fn resize_handle<T: 'static, E: 'static + Render>(
id: impl Into<ElementId>,
axis: Axis,
) -> ResizeHandle<T, E> {
ResizeHandle::new(id, axis)
}
pub(crate) struct ResizeHandle<T: 'static, E: 'static + Render> {
id: ElementId,
axis: Axis,
drag_value: Option<Rc<T>>,
placement: Option<DockPlacement>,
on_drag: Option<DragCallback<E>>,
}
impl<T: 'static, E: 'static + Render> ResizeHandle<T, E> {
fn new(id: impl Into<ElementId>, axis: Axis) -> Self {
let id = id.into();
Self {
id: id.clone(),
on_drag: None,
drag_value: None,
placement: None,
axis,
}
}
pub(crate) fn on_drag(
mut self,
value: T,
f: impl Fn(Rc<T>, &Point<Pixels>, &mut Window, &mut App) -> Entity<E> + 'static,
) -> Self {
let value = Rc::new(value);
self.drag_value = Some(value.clone());
self.on_drag = Some(Rc::new(move |p, window, cx| {
f(value.clone(), p, window, cx)
}));
self
}
pub(crate) fn placement(mut self, placement: DockPlacement) -> Self {
self.placement = Some(placement);
self
}
}
#[derive(Default, Debug, Clone)]
struct ResizeHandleState {
active: Cell<bool>,
}
impl ResizeHandleState {
fn set_active(&self, active: bool) {
self.active.set(active);
}
fn is_active(&self) -> bool {
self.active.get()
}
}
impl<T: 'static, E: 'static + Render> IntoElement for ResizeHandle<T, E> {
type Element = ResizeHandle<T, E>;
fn into_element(self) -> Self::Element {
self
}
}
impl<T: 'static, E: 'static + Render> Element for ResizeHandle<T, E> {
type RequestLayoutState = AnyElement;
type PrepaintState = ();
fn id(&self) -> Option<ElementId> {
Some(self.id.clone())
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
id: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let neg_offset = -HANDLE_PADDING;
let axis = self.axis;
window.with_element_state(id.unwrap(), |state, window| {
let state: ResizeHandleState = state.unwrap_or_default();
let bg_color = if state.is_active() {
cx.theme().drag_border
} else {
cx.theme().border
};
let has_drag = self.on_drag.is_some();
let mut el = div()
.id(self.id.clone())
.occlude()
.absolute()
.flex_shrink_0()
.group("handle")
.when_some(self.on_drag.clone(), |this, on_drag| {
this.on_drag(
self.drag_value.clone().unwrap(),
move |_, position, window, cx| on_drag(&position, window, cx),
)
})
.map(|this| match self.placement {
Some(DockPlacement::Left) => this
.when(has_drag, |this| this.cursor_col_resize())
.top_0()
.right(px(1.))
.h_full()
.w(HANDLE_SIZE)
.pl(HANDLE_PADDING),
_ => this
.when(axis.is_horizontal(), |this| {
this.when(has_drag, |this| this.cursor_col_resize())
.top_0()
.left(neg_offset)
.h_full()
.w(HANDLE_SIZE)
.px(HANDLE_PADDING)
})
.when(axis.is_vertical(), |this| {
this.when(has_drag, |this| this.cursor_row_resize())
.top(neg_offset)
.left_0()
.w_full()
.h(HANDLE_SIZE)
.py(HANDLE_PADDING)
}),
})
.child(
div()
.bg(bg_color)
.group_hover("handle", |this| this.bg(bg_color))
.when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE))
.when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)),
)
.into_any_element();
let layout_id = el.request_layout(window, cx);
((layout_id, el), state)
})
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
request_layout.prepaint(window, cx);
}
fn paint(
&mut self,
id: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
bounds: gpui::Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
request_layout.paint(window, cx);
window.with_element_state(id.unwrap(), |state: Option<ResizeHandleState>, window| {
let state = state.unwrap_or_default();
if self.on_drag.is_some() {
window.on_mouse_event({
let state = state.clone();
move |ev: &MouseDownEvent, phase, window, _| {
if bounds.contains(&ev.position) && phase.bubble() {
state.set_active(true);
window.refresh();
}
}
});
window.on_mouse_event({
let state = state.clone();
move |_: &MouseUpEvent, _, window, _| {
if state.is_active() {
state.set_active(false);
window.refresh();
}
}
});
}
((), state)
});
}
}
+453
View File
@@ -0,0 +1,453 @@
use std::collections::HashMap;
use gpui::{
Context, Focusable as _, Hsla, InteractiveElement as _, IntoElement, MouseButton,
ParentElement as _, Styled as _, Window, div, prelude::FluentBuilder as _, px,
};
use gpui_component::{
ActiveTheme as _, Disableable as _, ElementExt as _, IconName, Sizable as _,
button::ButtonVariants as _, h_flex, input::Input,
};
use rust_i18n::t;
use crate::{
Ashell,
app::controls::{pointer_button, ui_rems},
};
impl Ashell {
pub(crate) fn toggle_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.search_active {
self.close_search(window, cx);
} else {
self.open_search(window, cx);
}
}
pub(crate) fn open_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.search_active = true;
// Focus the search input on the next frame so it happens after the
// current render cycle completes, avoiding focus being stolen back
// by the terminal panel's track_focus.
let search_input = self.search_input.clone();
cx.on_next_frame(window, move |_this, window, cx| {
search_input.update(cx, |state, cx| {
state.focus_handle(cx).focus(window, cx);
});
});
cx.notify();
}
pub(crate) fn close_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.search_active = false;
self.search_query.clear();
self.search_matches.clear();
self.search_current = 0;
self.search_target_tab = None;
self.search_bar_bounds = None;
self.focus_handle.focus(window, cx);
cx.notify();
}
/// Move keyboard focus back to the search input so the user can keep typing.
/// Deferred to the next frame so it happens after the current render cycle,
/// preventing the terminal panel's track_focus from stealing focus back.
fn refocus_search_input(&self, window: &mut Window, cx: &mut Context<Self>) {
let search_input = self.search_input.clone();
cx.on_next_frame(window, move |_this, window, cx| {
search_input.update(cx, |state, cx| {
state.focus_handle(cx).focus(window, cx);
});
});
}
fn search_tab_index(&self) -> Option<usize> {
self.active_tab
.as_deref()
.and_then(|id| self.tabs.iter().position(|tab| tab.id == id))
.or_else(|| {
self.active_group
.as_ref()
.and_then(|group_id| self.tab_groups.iter().find(|group| &group.id == group_id))
.and_then(|group| group.pane_root.first_tab_id())
.and_then(|id| self.tabs.iter().position(|tab| tab.id == id))
})
.or_else(|| (!self.tabs.is_empty()).then_some(0))
}
pub(crate) fn perform_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let query = self.search_input.read(cx).text().to_string();
if query.is_empty() {
self.search_query.clear();
self.search_matches.clear();
self.search_current = 0;
self.refocus_search_input(window, cx);
cx.notify();
return;
}
let Some(tab_index) = self.search_tab_index() else {
self.status = t!("no_results").into();
self.refocus_search_input(window, cx);
cx.notify();
return;
};
// Remember which tab was searched so highlights only appear in that pane.
self.search_target_tab = Some(self.tabs[tab_index].id.clone());
let tab = &self.tabs[tab_index];
let query_lower = query.to_lowercase();
let query_byte_len = query.len();
// Search the ENTIRE terminal buffer (scrollback + visible screen).
// full_grid_rows returns grid line indices; the first row is at
// `grid_start` (typically -history_size).
let (grid_start, all_rows) = tab.full_grid_rows();
let mut matches: Vec<(i32, i32)> = Vec::new();
for (row_idx, row) in all_rows.iter().enumerate() {
if row.is_empty() {
continue;
}
// Build text string and byte→column index mapping.
let mut text = String::with_capacity(row.len());
let mut byte_to_col: Vec<i32> = Vec::new();
for &(col, c) in row {
text.push(c);
while byte_to_col.len() < text.len() {
byte_to_col.push(col);
}
}
let text_lower = text.to_lowercase();
// Grid line index for this row.
let abs_row = grid_start + row_idx as i32;
let mut search_start = 0;
while let Some(pos) = text_lower[search_start..].find(&query_lower) {
let abs = search_start + pos;
let start_col = byte_to_col[abs];
let end_byte = (abs + query_byte_len).min(byte_to_col.len());
let end_col = byte_to_col[end_byte - 1];
for c in start_col..=end_col {
matches.push((abs_row, c));
}
search_start = abs + query_byte_len;
}
}
matches.sort_unstable();
let match_count = count_match_groups(&matches);
self.search_query = query;
self.search_matches = matches;
if match_count > 0 {
self.search_current = 0;
self.jump_to_current_match(cx);
}
self.status = format!(
"{}: {} ({})",
t!("search"),
self.search_query,
if match_count == 0 {
t!("no_results").to_string()
} else {
format!("{}/{}", self.search_current + 1, match_count)
}
)
.into();
// Keep focus on the search input so the user can continue typing.
self.refocus_search_input(window, cx);
cx.notify();
}
pub(crate) fn search_goto_next(&mut self, cx: &mut Context<Self>) {
let match_count = count_match_groups(&self.search_matches);
if match_count == 0 {
return;
}
self.search_current = (self.search_current + 1) % match_count;
self.jump_to_current_match(cx);
cx.notify();
}
pub(crate) fn search_goto_prev(&mut self, cx: &mut Context<Self>) {
let match_count = count_match_groups(&self.search_matches);
if match_count == 0 {
return;
}
self.search_current = (self.search_current + match_count - 1) % match_count;
self.jump_to_current_match(cx);
cx.notify();
}
fn jump_to_current_match(&mut self, _cx: &mut Context<Self>) {
// target_grid_line is the grid line index (negative = history).
let Some((target_grid_line, _)) =
find_nth_match_start(&self.search_matches, self.search_current)
else {
return;
};
// Find the tab ID first (immutable borrow), then look up mutably.
let tab_id = self.active_tab.clone().or_else(|| {
self.active_group
.as_ref()
.and_then(|gid| self.tab_groups.iter().find(|g| &g.id == gid))
.and_then(|g| g.pane_root.first_tab_id())
.map(|s| s.to_string())
});
let tab = if let Some(id) = tab_id.as_deref() {
self.tabs.iter_mut().find(|t| t.id == id)
} else {
self.tabs.first_mut()
};
if let Some(tab) = tab {
let snapshot = tab.render_snapshot(false);
let display_offset = snapshot.display_offset as i32;
let rows = snapshot.rows as i32;
// viewport row = grid_line + display_offset
let vp_row = target_grid_line + display_offset;
let visible = vp_row >= 0 && vp_row < rows;
if !visible {
// Scroll so the target grid line appears near the top.
// display_offset = -grid_line puts it at viewport row 0.
let new_offset = (-target_grid_line).max(0) as usize;
if new_offset > snapshot.display_offset {
tab.scroll_up_by(new_offset - snapshot.display_offset);
} else if new_offset < snapshot.display_offset {
tab.scroll_down_by(snapshot.display_offset - new_offset);
}
}
}
}
/// Build a highlight map for search matches, converting grid line indices
/// to the current viewport coordinates. Only returns highlights for the
/// pane that was actually searched (`search_target_tab`).
pub(crate) fn search_highlight_map(
&self,
tab_id: &str,
match_color: Hsla,
current_color: Hsla,
) -> Option<HashMap<(i32, i32), Hsla>> {
if self.search_matches.is_empty()
|| self.search_query.is_empty()
|| self.search_target_tab.as_deref() != Some(tab_id)
{
return None;
}
let tab = self.tabs.get(self.search_tab_index()?)?;
let snapshot = tab.render_snapshot(false);
let display_offset = snapshot.display_offset as i32;
let rows = snapshot.rows as i32;
let mut map = HashMap::new();
let sorted = &self.search_matches;
let mut group_idx = 0;
let mut i = 0;
while i < sorted.len() {
let is_current = group_idx == self.search_current;
let color = if is_current {
current_color
} else {
match_color
};
// grid_line → viewport row: vp_row = grid_line + display_offset
let (grid_line, _) = sorted[i];
let vp_row = grid_line + display_offset;
let next_i = next_match_group_index(sorted, i);
if vp_row >= 0 && vp_row < rows {
for &(_, col) in &sorted[i..next_i] {
map.insert((vp_row, col), color);
}
}
group_idx += 1;
i = next_i;
}
Some(map)
}
/// Render the search button (used in the tab bar).
pub(crate) fn render_search_button(&self, cx: &mut Context<Self>) -> impl gpui::IntoElement {
// Wrap in a div so .hover() doesn't conflict with Button's internal hover.
div().child(
pointer_button("search-btn")
.ghost()
.icon(IconName::Search)
.tooltip(t!("search").to_string())
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_search(window, cx);
})),
)
}
/// Render the expanded search bar overlay (when search is active).
pub(crate) fn render_search_bar(
&mut self,
_window: &mut Window,
cx: &mut Context<Self>,
) -> impl gpui::IntoElement {
let match_count = count_match_groups(&self.search_matches);
let has_query = !self.search_query.is_empty();
let has_matches = match_count > 0;
let current_display = if has_matches {
format!("{}/{}", self.search_current + 1, match_count)
} else if has_query {
"0".to_string()
} else {
String::new()
};
let view = cx.entity();
div()
.absolute()
.top(px(8.))
.right(px(24.))
.on_prepaint(move |bounds, _window, cx| {
view.update(cx, |this, _| {
this.search_bar_bounds = Some(bounds);
});
})
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _, window, cx| {
this.refocus_search_input(window, cx);
cx.stop_propagation();
}),
)
.child(
h_flex()
.gap_1()
.items_center()
.p_1()
.rounded(px(6.))
.bg(cx.theme().popover)
.border_1()
.border_color(cx.theme().border)
.child(
div()
.w(px(200.))
.on_key_down(cx.listener(
|this, event: &gpui::KeyDownEvent, window, cx| {
if event.keystroke.key.as_str() == "escape" {
this.close_search(window, cx);
window.prevent_default();
cx.stop_propagation();
}
},
))
.child(Input::new(&self.search_input)),
)
.when(!current_display.is_empty(), |this| {
this.child(
div()
.text_size(ui_rems(0.75))
.text_color(cx.theme().muted_foreground)
.min_w(px(36.))
.text_center()
.child(current_display),
)
})
.child(
pointer_button("search-prev")
.ghost()
.small()
.icon(IconName::ChevronUp)
.disabled(!has_matches)
.on_click(cx.listener(|this, _, _, cx| {
this.search_goto_prev(cx);
})),
)
.child(
pointer_button("search-next")
.ghost()
.small()
.icon(IconName::ChevronDown)
.disabled(!has_matches)
.on_click(cx.listener(|this, _, _, cx| {
this.search_goto_next(cx);
})),
)
.child(
pointer_button("search-close")
.ghost()
.small()
.icon(IconName::Close)
.on_click(cx.listener(|this, _, window, cx| {
this.close_search(window, cx);
})),
),
)
.into_any_element()
}
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// Count distinct match groups in a sorted list of (row, col) positions.
/// A group is a run of consecutive columns in the same row.
fn count_match_groups(matches: &[(i32, i32)]) -> usize {
let mut count = 0;
let mut i = 0;
while i < matches.len() {
count += 1;
i = next_match_group_index(matches, i);
}
count
}
/// Find the (row, col) start of the Nth distinct match group.
fn find_nth_match_start(matches: &[(i32, i32)], n: usize) -> Option<(i32, i32)> {
let mut group_idx = 0;
let mut i = 0;
while i < matches.len() {
if group_idx == n {
return Some(matches[i]);
}
group_idx += 1;
i = next_match_group_index(matches, i);
}
None
}
fn next_match_group_index(matches: &[(i32, i32)], start: usize) -> usize {
let row = matches[start].0;
let mut end = start + 1;
while end < matches.len() && matches[end].0 == row && matches[end].1 == matches[end - 1].1 + 1 {
end += 1;
}
end
}
#[cfg(test)]
mod tests {
use super::{count_match_groups, find_nth_match_start};
#[test]
fn groups_consecutive_search_matches_by_row() {
let matches = [(0, 1), (0, 2), (0, 5), (1, 0), (1, 1)];
assert_eq!(count_match_groups(&matches), 3);
assert_eq!(find_nth_match_start(&matches, 0), Some((0, 1)));
assert_eq!(find_nth_match_start(&matches, 1), Some((0, 5)));
assert_eq!(find_nth_match_start(&matches, 2), Some((1, 0)));
assert_eq!(find_nth_match_start(&matches, 3), None);
}
}
+370
View File
@@ -0,0 +1,370 @@
use gpui::{App, AppContext as _, Bounds, WindowOptions, point, px, size};
use gpui_component::Root;
use crate::Ashell;
use crate::session::config::{ConfigStore, SavedWindowBounds};
fn clamp_saved_window_bounds(
x: f32,
y: f32,
width: f32,
height: f32,
cx: &App,
) -> Bounds<gpui::Pixels> {
let displays = cx.displays();
let Some(display) = displays.iter().max_by(|left, right| {
let intersection_area = |display: &std::rc::Rc<dyn gpui::PlatformDisplay>| {
let visible = display.visible_bounds();
let left_edge = x.max(visible.origin.x.as_f32());
let top_edge = y.max(visible.origin.y.as_f32());
let right_edge =
(x + width).min(visible.origin.x.as_f32() + visible.size.width.as_f32());
let bottom_edge =
(y + height).min(visible.origin.y.as_f32() + visible.size.height.as_f32());
(right_edge - left_edge).max(0.0) * (bottom_edge - top_edge).max(0.0)
};
intersection_area(left).total_cmp(&intersection_area(right))
}) else {
return Bounds::new(point(px(x), px(y)), size(px(width), px(height)));
};
let visible = display.visible_bounds();
let visible_x = visible.origin.x.as_f32();
let visible_y = visible.origin.y.as_f32();
let visible_width = visible.size.width.as_f32();
let visible_height = visible.size.height.as_f32();
let width = width.max(1.0).min(visible_width);
let height = height.max(1.0).min(visible_height);
let x = if x.is_finite() {
x.clamp(visible_x, visible_x + visible_width - width)
} else {
visible_x + (visible_width - width) / 2.0
};
let y = if y.is_finite() {
y.clamp(visible_y, visible_y + visible_height - height)
} else {
visible_y + (visible_height - height) / 2.0
};
Bounds::new(point(px(x), px(y)), size(px(width), px(height)))
}
fn restore_window_bounds(bounds: &SavedWindowBounds, cx: &App) -> gpui::WindowBounds {
match bounds {
SavedWindowBounds::Fullscreen {
x,
y,
width,
height,
} => gpui::WindowBounds::Fullscreen(clamp_saved_window_bounds(*x, *y, *width, *height, cx)),
SavedWindowBounds::Maximized {
x,
y,
width,
height,
} => gpui::WindowBounds::Maximized(clamp_saved_window_bounds(*x, *y, *width, *height, cx)),
SavedWindowBounds::Windowed {
x,
y,
width,
height,
} => gpui::WindowBounds::Windowed(clamp_saved_window_bounds(*x, *y, *width, *height, cx)),
}
}
pub(crate) fn bind_workspace_keys(cx: &mut gpui::App) {
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
crate::app::keybinding_recorder::bind_workspace_keys_from_config(cx, &config);
}
struct LocalMinutelyRoller {
dir: std::path::PathBuf,
prefix: String,
current_minute: u32,
file: Option<std::fs::File>,
}
impl LocalMinutelyRoller {
fn new(dir: std::path::PathBuf, prefix: String) -> Self {
Self {
dir,
prefix,
current_minute: 60,
file: None,
}
}
fn rollover(&mut self, now: chrono::DateTime<chrono::Local>) -> std::io::Result<()> {
use chrono::Timelike;
let minute = now.minute();
if self.current_minute != minute || self.file.is_none() {
let filename = format!("{}-{}.log", self.prefix, now.format("%Y-%m-%d-%H-%M"));
let path = self.dir.join(filename);
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
self.file = Some(file);
self.current_minute = minute;
// Cleanup old files keeping last 6
if let Ok(entries) = std::fs::read_dir(&self.dir) {
let mut files: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with(&self.prefix))
.collect();
files.sort_by_key(|e| {
e.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
});
if files.len() > 6 {
for file in files.iter().take(files.len() - 6) {
let _ = std::fs::remove_file(file.path());
}
}
}
}
Ok(())
}
}
impl std::io::Write for LocalMinutelyRoller {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let now = chrono::Local::now();
let _ = self.rollover(now);
if let Some(f) = &mut self.file {
f.write(buf)
} else {
Ok(buf.len())
}
}
fn flush(&mut self) -> std::io::Result<()> {
if let Some(f) = &mut self.file {
f.flush()
} else {
Ok(())
}
}
}
pub(crate) fn init_logging() {
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
let log_dir = directories::BaseDirs::new()
.map(|dirs| dirs.home_dir().join(".config").join("ashell").join("log"))
.unwrap_or_else(|| std::path::PathBuf::from("."));
std::fs::create_dir_all(&log_dir).ok();
let roller = LocalMinutelyRoller::new(log_dir.clone(), "ashell".to_string());
let (non_blocking, _guard) = tracing_appender::non_blocking(roller);
// Leak the guard so it lives for the entire duration of the app since GPUI's run might not return
std::mem::forget(_guard);
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
let stdout_layer = if cfg!(debug_assertions) {
Some(
tracing_subscriber::fmt::layer()
.with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339())
.with_target(true),
)
} else {
None
};
let file_layer = tracing_subscriber::fmt::layer()
.with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339())
.with_writer(non_blocking)
.with_ansi(false)
.with_target(true);
tracing_subscriber::registry()
.with(env_filter)
.with(stdout_layer)
.with(file_layer)
.init();
}
#[cfg(target_os = "macos")]
pub(crate) fn sync_macos_launch_environment() {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
let Ok(output) = std::process::Command::new(&shell)
.args(["-l", "-c", "env -0"])
.output()
else {
return;
};
if !output.status.success() {
return;
}
for entry in output.stdout.split(|b| *b == 0) {
if entry.is_empty() {
continue;
}
let Some(eq) = entry.iter().position(|b| *b == b'=') else {
continue;
};
let Ok(key) = std::str::from_utf8(&entry[..eq]) else {
continue;
};
let Ok(value) = std::str::from_utf8(&entry[eq + 1..]) else {
continue;
};
let should_import = matches!(
key,
"PATH"
| "MANPATH"
| "INFOPATH"
| "LANG"
| "LC_ALL"
| "LC_CTYPE"
| "SHELL"
| "HOME"
| "HOMEBREW_PREFIX"
| "HOMEBREW_CELLAR"
| "HOMEBREW_REPOSITORY"
| "HTTP_PROXY"
| "HTTPS_PROXY"
| "ALL_PROXY"
| "http_proxy"
| "https_proxy"
| "all_proxy"
) || key.starts_with("LC_");
if should_import {
unsafe {
std::env::set_var(key, value);
}
}
}
}
fn read_proxy_from_env() -> Option<(String, String, Option<u16>, String, String)> {
let vars = [
"ALL_PROXY",
"all_proxy",
"HTTPS_PROXY",
"https_proxy",
"HTTP_PROXY",
"http_proxy",
];
for var in vars {
if let Ok(val) = std::env::var(var) {
if val.is_empty() {
continue;
}
if let Ok(url) = reqwest::Url::parse(&val) {
let scheme = url.scheme();
let proxy_type = match scheme {
"socks5" | "socks5h" => "socks5".to_string(),
"http" | "https" => "http".to_string(),
_ => "socks5".to_string(),
};
let host = url.host_str().unwrap_or("").to_string();
let port = url.port();
let user = url.username().to_string();
let password = url.password().unwrap_or("").to_string();
return Some((proxy_type, host, port, user, password));
}
}
}
None
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn sync_macos_launch_environment() {}
pub(crate) fn open_main_window(cx: &mut App) {
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
let _ = crate::session::config::ENV_PROXY.get_or_init(|| {
read_proxy_from_env().map(|(proxy_type, host, port, user, password)| {
tracing::info!(
"[proxy] Loaded proxy configuration from environment: type={}, host={}, port={:?}, user={}",
proxy_type,
host,
port,
user
);
crate::session::config::EnvProxy {
proxy_type,
host,
port,
user,
pass: password,
}
})
});
let mut window_options = WindowOptions::default();
if config.title_bar_style() == crate::session::config::TitleBarStyle::Integrated {
window_options.titlebar = Some(gpui::TitlebarOptions {
title: None,
appears_transparent: true,
traffic_light_position: Some(gpui::point(px(9.0), px(9.0))),
});
}
#[cfg(not(target_os = "macos"))]
if let Ok(img) = image::load_from_memory(include_bytes!("../../assets/icons/ashell.png")) {
window_options.icon = Some(std::sync::Arc::new(img.into_rgba8()));
}
if let Some(bounds) = config.window_bounds() {
window_options.window_bounds = Some(restore_window_bounds(bounds, cx));
} else if let Some(display) = cx.displays().first().cloned() {
let display_bounds = display.bounds();
let width = display_bounds.size.width * 0.8;
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));
crate::app::system_menu::set_app_menus(cx);
tracing::info!("[ui] main application window opened");
let focus_handle = view.read(cx).focus_handle.clone();
window.focus(&focus_handle, cx);
let view_clone = view.clone();
window.on_window_should_close(cx, move |window: &mut gpui::Window, cx: &mut gpui::App| {
let handle = window.window_handle();
if !cx.windows().contains(&handle) {
tracing::warn!(
"[ui] window not found in app during close, skipping save layout state."
);
return true;
}
view_clone.update(cx, |this, cx| {
this.save_layout_state(window, cx);
});
true
});
cx.new(|cx| Root::new(view, window, cx))
})
.expect("failed to open window");
}
+132
View File
@@ -0,0 +1,132 @@
#[cfg(target_os = "macos")]
use gpui::KeyBinding;
use gpui::{App, Menu, MenuItem};
use rust_i18n::t;
gpui::actions!(
ashell_system_menu,
[
AboutAshell,
CloseWindow,
HideAshell,
HideOtherApplications,
MinimizeWindow,
NewLocalTerminal,
OpenProjectWebsite,
ShowAllApplications,
ToggleFullScreen,
ZoomWindow
]
);
const PROJECT_URL: &str = "https://github.com/rust-kotlin/ashell";
pub(crate) fn init(cx: &mut App) {
#[cfg(target_os = "macos")]
{
cx.on_action(|_: &HideAshell, cx| cx.hide());
cx.on_action(|_: &HideOtherApplications, cx| cx.hide_other_apps());
cx.on_action(|_: &ShowAllApplications, cx| cx.unhide_other_apps());
cx.bind_keys([
KeyBinding::new("cmd-h", HideAshell, None),
KeyBinding::new("alt-cmd-h", HideOtherApplications, None),
KeyBinding::new("cmd-m", MinimizeWindow, None),
KeyBinding::new("ctrl-cmd-f", ToggleFullScreen, None),
]);
}
cx.on_action(|_: &OpenProjectWebsite, _| {
if let Err(error) = open::that(PROJECT_URL) {
tracing::warn!("failed to open Ashell project website: {error}");
}
});
}
pub(crate) fn set_app_menus(cx: &mut App) {
let mut application_items = vec![
MenuItem::action(t!("menu_about_ashell").to_string(), AboutAshell),
MenuItem::separator(),
MenuItem::action(t!("settings").to_string(), crate::OpenSettings),
];
#[cfg(target_os = "macos")]
application_items.extend([
MenuItem::separator(),
MenuItem::os_submenu(
t!("menu_services").to_string(),
gpui::SystemMenuType::Services,
),
MenuItem::separator(),
MenuItem::action(t!("menu_hide_ashell").to_string(), HideAshell),
MenuItem::action(t!("menu_hide_others").to_string(), HideOtherApplications),
MenuItem::action(t!("menu_show_all").to_string(), ShowAllApplications),
]);
application_items.extend([
MenuItem::separator(),
MenuItem::action(t!("menu_quit_ashell").to_string(), crate::QuitApplication),
]);
cx.set_menus([
Menu::new("Ashell").items(application_items),
Menu::new(t!("menu_file").to_string()).items([
MenuItem::action(t!("local_terminal").to_string(), NewLocalTerminal),
MenuItem::action(t!("new_connection").to_string(), crate::NewSsh),
MenuItem::separator(),
MenuItem::action(t!("menu_close_window").to_string(), CloseWindow),
]),
Menu::new(t!("menu_edit").to_string()).items([
MenuItem::os_action(
t!("menu_undo").to_string(),
gpui_component::input::Undo,
gpui::OsAction::Undo,
),
MenuItem::os_action(
t!("menu_redo").to_string(),
gpui_component::input::Redo,
gpui::OsAction::Redo,
),
MenuItem::separator(),
MenuItem::os_action(
t!("menu_cut").to_string(),
gpui_component::input::Cut,
gpui::OsAction::Cut,
),
MenuItem::os_action(
t!("menu_copy").to_string(),
crate::Copy,
gpui::OsAction::Copy,
),
MenuItem::os_action(
t!("menu_paste").to_string(),
crate::Paste,
gpui::OsAction::Paste,
),
MenuItem::separator(),
MenuItem::os_action(
t!("menu_select_all").to_string(),
gpui_component::input::SelectAll,
gpui::OsAction::SelectAll,
),
]),
Menu::new(t!("menu_view").to_string()).items([
MenuItem::action(t!("search").to_string(), crate::OpenSearch),
MenuItem::action(t!("menu_toggle_sidebar").to_string(), crate::ToggleSidebar),
MenuItem::action(
t!("settings_open_transfers").to_string(),
crate::OpenTransfers,
),
MenuItem::separator(),
MenuItem::action(t!("menu_toggle_full_screen").to_string(), ToggleFullScreen),
]),
// GPUI recognizes this exact title as the native macOS Window menu.
Menu::new("Window").items([
MenuItem::action(t!("menu_minimize").to_string(), MinimizeWindow),
MenuItem::action(t!("menu_zoom").to_string(), ZoomWindow),
]),
Menu::new(t!("menu_help").to_string()).items([MenuItem::action(
t!("menu_project_website").to_string(),
OpenProjectWebsite,
)]),
]);
}
+233
View File
@@ -0,0 +1,233 @@
use anyhow::{Context as _, Result};
use gpui::{App, Context, SharedString, Window, px};
use gpui_component::{ActiveTheme as _, Theme, ThemeMode, ThemeRegistry};
use rust_i18n::t;
use crate::Ashell;
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"),
include_str!("../../assets/themes/phygerr.json"),
];
use std::sync::atomic::{AtomicBool, Ordering};
pub(crate) static USING_SYSTEM_MAPLE: AtomicBool = AtomicBool::new(false);
pub(crate) const COMMON_SYSTEM_UI_FONTS: &[&str] = &[
"Helvetica",
"Segoe UI",
"Ubuntu",
"Adwaita Sans",
"Cantarell",
"Noto Sans",
"DejaVu Sans",
"Arial",
"San Francisco",
".SF NS Text",
".SF NS Display",
"Segoe UI Variable",
"PingFang SC",
"Microsoft YaHei",
"SimSun",
];
pub(crate) fn resolve_safe_ui_font(cx: &App, requested_font: &str) -> String {
let all_fonts = cx.text_system().all_font_names();
if requested_font == ".SystemUIFont" {
let has_system_ui = COMMON_SYSTEM_UI_FONTS
.iter()
.any(|font| all_fonts.contains(&font.to_string()));
if has_system_ui {
".SystemUIFont".to_string()
} else {
"Maple Mono NF CN".to_string()
}
} else if all_fonts.contains(&requested_font.to_string()) {
requested_font.to_string()
} else {
let has_system_ui = COMMON_SYSTEM_UI_FONTS
.iter()
.any(|font| all_fonts.contains(&font.to_string()));
if has_system_ui {
".SystemUIFont".to_string()
} else {
"Maple Mono NF CN".to_string()
}
}
}
pub(crate) fn load_fonts(cx: &mut App) -> Result<()> {
let has_system_maple = cx
.text_system()
.all_font_names()
.contains(&"Maple Mono NF CN".to_string());
if has_system_maple {
USING_SYSTEM_MAPLE.store(true, Ordering::Relaxed);
} else {
let regular = std::borrow::Cow::Borrowed(
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Regular.ttf").as_slice(),
);
let bold = std::borrow::Cow::Borrowed(
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Bold.ttf").as_slice(),
);
cx.text_system()
.add_fonts(vec![regular, bold])
.context("load Maple Mono NF CN fonts")?;
}
let safe_font = resolve_safe_ui_font(cx, ".SystemUIFont");
set_theme_font_names(cx.global_mut::<Theme>(), &safe_font);
Ok(())
}
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);
crate::app::system_menu::set_app_menus(cx);
self.connection_filter_input.update(cx, |input, cx| {
input.set_placeholder(t!("filter_connections").to_string(), window, cx);
});
self.connection_group_name_input.update(cx, |input, cx| {
input.set_placeholder(t!("connection_group_name").to_string(), window, cx);
});
self.command_history_filter_input.update(cx, |input, cx| {
input.set_placeholder(t!("filter_command_history").to_string(), window, cx);
});
self.remote_process_filter_input.update(cx, |input, cx| {
input.set_placeholder(t!("filter_processes").to_string(), window, cx);
});
self.remote_port_filter_input.update(cx, |input, cx| {
input.set_placeholder(t!("filter_ports").to_string(), window, cx);
});
self.save_preferences_background();
window.refresh();
cx.notify();
}
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 safe_font = resolve_safe_ui_font(cx, &self.ui_font_family);
let theme = Theme::global_mut(cx);
theme.light_theme = light_theme;
theme.dark_theme = dark_theme;
theme.font_size = px(self.ui_font_size);
set_theme_font_names(theme, &safe_font);
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(),
);
self.save_preferences_background();
}
}
+6041
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
//! Window dragging helpers for the integrated title bar.
//!
//! GPUI implements `Window::start_window_move` on macOS and Linux, but the
//! Windows backend leaves it as a no-op. On Windows we post a native
//! `WM_SYSCOMMAND` / `SC_MOVE | HTCAPTION` message so the GPUI pointer callback
//! can finish before `DefWindowProc` enters its modal window-move loop.
/// Begin a native window drag from a mouse-down on the integrated title bar.
pub(crate) fn start_window_drag(window: &gpui::Window) {
#[cfg(target_os = "windows")]
{
use windows::Win32::Foundation::{HWND, LPARAM, WPARAM};
use windows::Win32::UI::Input::KeyboardAndMouse::ReleaseCapture;
use windows::Win32::UI::WindowsAndMessaging::{
HTCAPTION, PostMessageW, SC_MOVE, WM_SYSCOMMAND,
};
let Some(handle) = crate::desktop_notification::native_window_handle(window) else {
tracing::warn!("failed to start window drag: missing native window handle");
return;
};
unsafe {
// The button-down capture must be released before the system move
// loop can take over the pointer.
let _ = ReleaseCapture();
if let Err(err) = PostMessageW(
HWND(handle as _),
WM_SYSCOMMAND,
WPARAM((SC_MOVE | HTCAPTION) as usize),
LPARAM(0),
) {
tracing::warn!("failed to start native window drag: {err}");
}
}
}
#[cfg(not(target_os = "windows"))]
{
window.start_window_move();
}
}
+744
View File
@@ -0,0 +1,744 @@
use std::{
fs,
io::{Read, Write},
path::{Path, PathBuf},
sync::{OnceLock, mpsc},
thread,
time::Duration,
};
#[cfg(not(windows))]
use std::time::Instant;
use anyhow::{Context, Result};
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
#[cfg(not(windows))]
use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
use crate::session::config::LocalTerminalShell;
use crate::terminal::{BackendCommand, BackendEvent, BackendTx, GuardedBackendEventSender};
#[cfg(not(windows))]
const DIRECTORY_POLL_INTERVAL: Duration = Duration::from_secs(1);
const BASH_INTEGRATION_RC: &str = r#"# Generated by ashell. User startup files remain authoritative.
if [[ ${ASHELL_BASH_LOGIN:-0} == 1 ]]; then
[[ -r /etc/profile ]] && source /etc/profile
if [[ -r $HOME/.bash_profile ]]; then
source "$HOME/.bash_profile"
elif [[ -r $HOME/.bash_login ]]; then
source "$HOME/.bash_login"
elif [[ -r $HOME/.profile ]]; then
source "$HOME/.profile"
fi
elif [[ -r $HOME/.bashrc ]]; then
source "$HOME/.bashrc"
fi
if [[ -z ${__ASHELL_BASH_INTEGRATION_ACTIVE:-} ]]; then
__ASHELL_BASH_INTEGRATION_ACTIVE=1
__ashell_prompt_suffix='\[\e]133;B\a\]'
__ashell_secondary_prefix='\[\e]133;A;k=s\a\]'
__ashell_install_prompt_markers() {
PS1=${PS1//"$__ashell_prompt_suffix"/}
PS1+=$__ashell_prompt_suffix
PS2=${PS2//"$__ashell_secondary_prefix"/}
PS2=${PS2//"$__ashell_prompt_suffix"/}
PS2="${__ashell_secondary_prefix}${PS2}${__ashell_prompt_suffix}"
}
__ashell_precmd() {
local __ashell_last_status=$?
local __ashell_cwd=$PWD
__ashell_install_prompt_markers
if [[ ${ASHELL_WINDOWS_GIT_BASH:-0} == 1 ]]; then
__ashell_cwd=$(pwd -W 2>/dev/null) || __ashell_cwd=$PWD
fi
printf '\033]133;D;%s\007\033]0;ASHELL_CWD:%s\007\033]133;A\007' \
"$__ashell_last_status" "$__ashell_cwd"
return "$__ashell_last_status"
}
case $(declare -p PROMPT_COMMAND 2>/dev/null) in
'declare -a'*)
PROMPT_COMMAND+=(__ashell_precmd)
;;
*)
[[ -n ${PROMPT_COMMAND:-} ]] && PROMPT_COMMAND+=$'\n'
PROMPT_COMMAND+='__ashell_precmd'
;;
esac
__ashell_install_prompt_markers
fi
"#;
const ZSH_ENV_WRAPPER: &str = r#"# Generated by ashell. Redirect startup back to the user's files first.
typeset -g ASHELL_USER_ZDOTDIR="${ASHELL_ORIGINAL_ZDOTDIR:-$HOME}"
if [[ "$ASHELL_USER_ZDOTDIR" != "$ASHELL_INTEGRATION_ZDOTDIR" && -r "$ASHELL_USER_ZDOTDIR/.zshenv" ]]; then
export ZDOTDIR="$ASHELL_USER_ZDOTDIR"
source "$ASHELL_USER_ZDOTDIR/.zshenv"
typeset -g ASHELL_USER_ZDOTDIR="${ZDOTDIR:-$ASHELL_USER_ZDOTDIR}"
fi
export ASHELL_USER_ZDOTDIR
export ZDOTDIR="$ASHELL_INTEGRATION_ZDOTDIR"
"#;
const ZSH_INTEGRATION_RC: &str = r#"# Generated by ashell. User startup files remain authoritative.
if [[ ${HISTFILE:-} == "$ASHELL_INTEGRATION_ZDOTDIR"/* ]]; then
HISTFILE="$ASHELL_USER_ZDOTDIR/${HISTFILE#"$ASHELL_INTEGRATION_ZDOTDIR"/}"
fi
if [[ "$ASHELL_USER_ZDOTDIR" != "$ASHELL_INTEGRATION_ZDOTDIR" && -r "$ASHELL_USER_ZDOTDIR/.zshrc" ]]; then
export ZDOTDIR="$ASHELL_USER_ZDOTDIR"
source "$ASHELL_USER_ZDOTDIR/.zshrc"
typeset -g ASHELL_USER_ZDOTDIR="${ZDOTDIR:-$ASHELL_USER_ZDOTDIR}"
export ZDOTDIR="$ASHELL_INTEGRATION_ZDOTDIR"
fi
if [[ -z ${__ASHELL_ZSH_INTEGRATION_ACTIVE:-} ]]; then
typeset -g __ASHELL_ZSH_INTEGRATION_ACTIVE=1
typeset -g __ashell_prompt_suffix=$'%{\e]133;B\a%}'
typeset -g __ashell_secondary_prefix=$'%{\e]133;A;k=s\a%}'
__ashell_install_prompt_markers() {
PROMPT=${PROMPT//$__ashell_prompt_suffix/}
PROMPT+=$__ashell_prompt_suffix
PS2=${PS2//$__ashell_secondary_prefix/}
PS2=${PS2//$__ashell_prompt_suffix/}
PS2="${__ashell_secondary_prefix}${PS2}${__ashell_prompt_suffix}"
}
__ashell_precmd() {
local __ashell_last_status=$?
__ashell_install_prompt_markers
print -rn -- $'\e]133;D;'"$__ashell_last_status"$'\a'
print -rn -- $'\e]0;ASHELL_CWD:'"$PWD"$'\a'
print -rn -- $'\e]133;A\a'
return "$__ashell_last_status"
}
__ashell_preexec() {
print -rn -- $'\e]133;C\a'
}
autoload -Uz add-zsh-hook
add-zsh-hook -d precmd __ashell_precmd 2>/dev/null
add-zsh-hook -d preexec __ashell_preexec 2>/dev/null
add-zsh-hook precmd __ashell_precmd
add-zsh-hook preexec __ashell_preexec
__ashell_install_prompt_markers
fi
export ZDOTDIR="$ASHELL_USER_ZDOTDIR"
"#;
const FISH_INTEGRATION_COMMAND: &str = r#"
if status --is-interactive; and not functions -q __ashell_original_fish_prompt
functions -c fish_prompt __ashell_original_fish_prompt
function __ashell_common_prompt
set -l __ashell_last_status $status
printf '\033]133;D;%s\007' $__ashell_last_status
printf '\033]0;ASHELL_CWD:%s\007' $PWD
printf '\033]133;A\007'
return $__ashell_last_status
end
function __ashell_preexec --on-event fish_preexec
printf '\033]133;C\007'
end
function fish_prompt
__ashell_common_prompt
__ashell_original_fish_prompt
printf '\033]133;B\007'
end
end
"#;
#[cfg(any(windows, test))]
const POWERSHELL_SHELL_INTEGRATION: &str = r#"& {
$global:AshellOriginalPrompt = $function:prompt
function global:prompt {
$promptText = if ($global:AshellOriginalPrompt) { & $global:AshellOriginalPrompt } else { "PS $PWD> " }
$cwd = $PWD.ProviderPath
$encoded = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($cwd))
[Console]::Write("$([char]27)]133;D$([char]7)$([char]27)]133;A$([char]7)")
[Console]::Write("$([char]27)]0;ASHELL_CWD_B64:$encoded$([char]7)")
"$promptText$([char]27)]133;B$([char]7)"
}
try {
$options = Get-PSReadLineOption -ErrorAction Stop
$continuation = "$([char]27)]133;A;k=s$([char]7)$($options.ContinuationPrompt)$([char]27)]133;B$([char]7)"
Set-PSReadLineOption -ContinuationPrompt $continuation -ErrorAction Stop
} catch {}
}"#;
#[derive(Debug)]
struct ShellIntegrationPaths {
bash_rc: PathBuf,
zsh_dir: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FileShellIntegration {
Bash { login: bool },
Zsh,
Fish,
}
static SHELL_INTEGRATION_PATHS: OnceLock<Result<ShellIntegrationPaths, String>> = OnceLock::new();
fn file_shell_integration(executable: &Path, login_bash: bool) -> Option<FileShellIntegration> {
let executable_name = executable
.file_stem()?
.to_string_lossy()
.to_ascii_lowercase();
match executable_name.as_str() {
"bash" => Some(FileShellIntegration::Bash { login: login_bash }),
"zsh" => Some(FileShellIntegration::Zsh),
"fish" => Some(FileShellIntegration::Fish),
_ => None,
}
}
fn shell_integration_paths() -> Result<&'static ShellIntegrationPaths> {
SHELL_INTEGRATION_PATHS
.get_or_init(|| create_shell_integration_paths().map_err(|error| format!("{error:#}")))
.as_ref()
.map_err(|message| anyhow::anyhow!("{message}"))
}
fn create_shell_integration_paths() -> Result<ShellIntegrationPaths> {
let root = std::env::temp_dir().join(format!(
"ashell-shell-integration-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
let zsh_dir = root.join("zsh");
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(&zsh_dir)
.with_context(|| format!("create shell integration directory {}", zsh_dir.display()))?;
}
#[cfg(not(unix))]
fs::create_dir_all(&zsh_dir)
.with_context(|| format!("create shell integration directory {}", zsh_dir.display()))?;
let bash_rc = root.join("bashrc");
write_shell_integration_file(&bash_rc, BASH_INTEGRATION_RC)?;
write_shell_integration_file(&zsh_dir.join(".zshenv"), ZSH_ENV_WRAPPER)?;
write_shell_integration_file(&zsh_dir.join(".zshrc"), ZSH_INTEGRATION_RC)?;
Ok(ShellIntegrationPaths { bash_rc, zsh_dir })
}
fn write_shell_integration_file(path: &Path, contents: &str) -> Result<()> {
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options
.open(path)
.with_context(|| format!("create shell integration file {}", path.display()))?;
file.write_all(contents.as_bytes())
.with_context(|| format!("write shell integration file {}", path.display()))
}
fn configure_file_shell_integration(
cmd: &mut CommandBuilder,
executable: &Path,
login_bash: bool,
) -> Result<bool> {
let Some(integration) = file_shell_integration(executable, login_bash) else {
return Ok(false);
};
match integration {
FileShellIntegration::Bash { login } => {
let paths = shell_integration_paths()?;
cmd.env("ASHELL_BASH_LOGIN", if login { "1" } else { "0" });
cmd.env("ASHELL_WINDOWS_GIT_BASH", if login { "1" } else { "0" });
cmd.arg("--rcfile");
cmd.arg(paths.bash_rc.as_os_str());
cmd.arg("-i");
}
FileShellIntegration::Zsh => {
let paths = shell_integration_paths()?;
if let Some(original_zdotdir) = std::env::var_os("ZDOTDIR") {
cmd.env("ASHELL_ORIGINAL_ZDOTDIR", original_zdotdir);
}
cmd.env("ASHELL_INTEGRATION_ZDOTDIR", paths.zsh_dir.as_os_str());
cmd.env("ZDOTDIR", paths.zsh_dir.as_os_str());
}
FileShellIntegration::Fish => {
cmd.args(["-C", FISH_INTEGRATION_COMMAND]);
}
}
cmd.env("ASHELL_SHELL_INTEGRATION", "1");
Ok(true)
}
#[cfg(any(windows, test))]
fn command_prompt_with_shell_integration(original_prompt: &str) -> String {
format!("\x1b]133;D\x07\x1b]133;A\x07\x1b]0;ASHELL_CWD:$P\x07{original_prompt}\x1b]133;B\x07")
}
#[cfg(not(windows))]
fn local_process_directory(system: &mut System, pid: Pid) -> Option<std::path::PathBuf> {
system.refresh_processes_specifics(
ProcessesToUpdate::Some(&[pid]),
false,
ProcessRefreshKind::nothing().with_cwd(UpdateKind::Always),
);
system
.process(pid)
.and_then(|process| process.cwd())
.map(std::path::PathBuf::from)
}
#[derive(Debug, Clone)]
pub struct LocalTerminalShellLaunch {
pub executable: PathBuf,
}
/// Resolves a configured local shell to an executable available on this host.
pub fn resolve_local_terminal_shell(
shell: LocalTerminalShell,
) -> anyhow::Result<LocalTerminalShellLaunch> {
#[cfg(not(windows))]
let _ = shell;
#[cfg(windows)]
{
let executable = match shell {
LocalTerminalShell::WindowsPowerShell => resolve_windows_powershell(),
LocalTerminalShell::PowerShell7 => resolve_powershell7(),
LocalTerminalShell::CommandPrompt => resolve_command_prompt(),
LocalTerminalShell::GitBash => resolve_git_bash(),
}
.ok_or_else(|| anyhow::anyhow!("configured local shell is not installed: {shell:?}"))?;
return Ok(LocalTerminalShellLaunch { executable });
}
#[cfg(not(windows))]
{
let executable = std::env::var_os("SHELL")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/bin/zsh"));
Ok(LocalTerminalShellLaunch { executable })
}
}
pub fn local_terminal_shell_available(shell: LocalTerminalShell) -> bool {
resolve_local_terminal_shell(shell).is_ok()
}
#[cfg(windows)]
fn existing_file(path: PathBuf) -> Option<PathBuf> {
path.is_file().then_some(path)
}
#[cfg(windows)]
fn env_path(name: &str) -> Option<PathBuf> {
std::env::var_os(name).map(PathBuf::from)
}
#[cfg(windows)]
fn find_on_path(name: &str, predicate: impl Fn(&Path) -> bool) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|directory| directory.join(name))
.find(|candidate| candidate.is_file() && predicate(candidate))
}
#[cfg(windows)]
fn resolve_windows_powershell() -> Option<PathBuf> {
let system_root = env_path("SystemRoot").unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
existing_file(system_root.join(r"System32\WindowsPowerShell\v1.0\powershell.exe"))
.or_else(|| find_on_path("powershell.exe", |_| true))
}
#[cfg(windows)]
fn resolve_powershell7() -> Option<PathBuf> {
let mut roots = Vec::new();
if let Some(program_files) = env_path("ProgramFiles") {
roots.push(program_files.join("PowerShell"));
}
if let Some(program_files) = env_path("ProgramW6432") {
roots.push(program_files.join("PowerShell"));
}
if let Some(program_files) = env_path("ProgramFiles(x86)") {
roots.push(program_files.join("PowerShell"));
}
if let Some(local_app_data) = env_path("LOCALAPPDATA") {
roots.push(local_app_data.join(r"Programs\PowerShell"));
}
let mut candidates = Vec::new();
if let Some(local_app_data) = env_path("LOCALAPPDATA") {
candidates.push(local_app_data.join(r"Microsoft\WindowsApps\pwsh.exe"));
}
if let Some(scoop) = env_path("SCOOP") {
candidates.push(scoop.join(r"apps\powershell\current\pwsh.exe"));
}
if let Some(user_profile) = env_path("USERPROFILE") {
candidates.push(user_profile.join(r"scoop\apps\powershell\current\pwsh.exe"));
}
for root in roots {
if let Some(candidate) = existing_file(root.join("pwsh.exe")) {
candidates.push(candidate);
}
let Ok(entries) = std::fs::read_dir(root) else {
continue;
};
let mut versions = entries
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false))
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.chars()
.all(|character| character.is_ascii_digit() || character == '.')
})
.collect::<Vec<_>>();
versions.sort_by_key(|entry| entry.file_name());
candidates.extend(
versions
.into_iter()
.rev()
.filter_map(|entry| existing_file(entry.path().join("pwsh.exe"))),
);
}
candidates
.into_iter()
.next()
.or_else(|| find_on_path("pwsh.exe", |_| true))
}
#[cfg(windows)]
fn resolve_command_prompt() -> Option<PathBuf> {
env_path("ComSpec")
.and_then(existing_file)
.or_else(|| {
let system_root =
env_path("SystemRoot").unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
existing_file(system_root.join(r"System32\cmd.exe"))
})
.or_else(|| find_on_path("cmd.exe", |_| true))
}
#[cfg(windows)]
fn resolve_git_bash() -> Option<PathBuf> {
let mut candidates = Vec::new();
for variable in ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] {
if let Some(program_files) = env_path(variable) {
candidates.push(program_files.join(r"Git\bin\bash.exe"));
}
}
if let Some(local_app_data) = env_path("LOCALAPPDATA") {
candidates.push(local_app_data.join(r"Programs\Git\bin\bash.exe"));
}
if let Some(scoop) = env_path("SCOOP") {
candidates.push(scoop.join(r"apps\git\current\bin\bash.exe"));
}
if let Some(user_profile) = env_path("USERPROFILE") {
candidates.push(user_profile.join(r"scoop\apps\git\current\bin\bash.exe"));
}
candidates.into_iter().find_map(existing_file).or_else(|| {
find_on_path("bash.exe", |candidate| {
let path = candidate.to_string_lossy().to_ascii_lowercase();
path.contains("\\git\\") || path.contains("/git/")
})
})
}
pub fn spawn_local_terminal_at(
tab_id: String,
cols: u16,
rows: u16,
events: GuardedBackendEventSender,
initial_directory: Option<&Path>,
shell: LocalTerminalShell,
) -> Result<BackendTx> {
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.context("open local PTY")?;
let launch = resolve_local_terminal_shell(shell).context("resolve local shell")?;
let mut cmd = CommandBuilder::new(&launch.executable);
#[cfg(windows)]
{
match shell {
LocalTerminalShell::WindowsPowerShell | LocalTerminalShell::PowerShell7 => {
cmd.args([
"-NoLogo",
"-NoExit",
"-Command",
POWERSHELL_SHELL_INTEGRATION,
]);
}
LocalTerminalShell::CommandPrompt => {
let original_prompt =
std::env::var("PROMPT").unwrap_or_else(|_| "$P$G".to_string());
cmd.args(["/Q"]);
cmd.env(
"PROMPT",
command_prompt_with_shell_integration(&original_prompt),
);
}
LocalTerminalShell::GitBash => {
cmd.env("CHERE_INVOKING", "1");
}
}
}
let login_bash = cfg!(windows) && shell == LocalTerminalShell::GitBash;
let shell_integration_configured =
match configure_file_shell_integration(&mut cmd, &launch.executable, login_bash) {
Ok(configured) => configured,
Err(error) => {
tracing::warn!(
"[local] shell integration unavailable for {}: {error:#}",
launch.executable.display()
);
false
}
};
#[cfg(windows)]
if shell == LocalTerminalShell::GitBash && !shell_integration_configured {
cmd.args(["--login", "-i"]);
}
#[cfg(not(windows))]
let _ = shell_integration_configured;
cmd.env(
"TERM",
std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".into()),
);
cmd.env(
"COLORTERM",
std::env::var("COLORTERM").unwrap_or_else(|_| "truecolor".into()),
);
cmd.env("TERM_PROGRAM", "ashell");
if let Ok(path) = std::env::var("PATH") {
cmd.env("PATH", path);
}
if let Ok(lang) = std::env::var("LANG") {
cmd.env("LANG", lang);
} else {
cmd.env("LANG", "en_US.UTF-8");
}
if let Ok(home) = std::env::var("HOME") {
cmd.env("HOME", home);
}
if let Some(directory) = initial_directory.filter(|path| path.is_dir()) {
cmd.cwd(directory.as_os_str());
}
cmd.env("SHELL", launch.executable.as_os_str());
let mut child = pair.slave.spawn_command(cmd).context("spawn local shell")?;
#[cfg(not(windows))]
let child_pid = child.process_id().map(Pid::from_u32);
drop(pair.slave);
let master = pair.master;
let mut reader = master.try_clone_reader().context("clone PTY reader")?;
let mut writer = master.take_writer().context("take PTY writer")?;
let (cmd_tx, cmd_rx) = mpsc::channel::<BackendCommand>();
let read_tab = tab_id.clone();
let read_events = events.clone();
thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let _ = read_events.send(BackendEvent::Output {
tab_id: read_tab.clone(),
bytes: buf[..n].to_vec(),
});
}
Err(err) => {
let _ = read_events.send(BackendEvent::Closed {
tab_id: read_tab.clone(),
reason: format!("local read error: {err}"),
});
return;
}
}
}
let _ = read_events.send(BackendEvent::Closed {
tab_id: read_tab,
reason: "local shell closed".into(),
});
});
let write_tab = tab_id.clone();
let write_events = events.clone();
thread::spawn(move || {
#[cfg(not(windows))]
let mut process_system = System::new();
#[cfg(not(windows))]
let mut last_directory = None;
#[cfg(not(windows))]
let mut last_directory_check = None;
loop {
match cmd_rx.recv_timeout(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
| BackendCommand::SampleProcesses
| BackendCommand::SamplePorts
| BackendCommand::TerminateProcess { .. } => {}
},
Err(mpsc::RecvTimeoutError::Timeout) => {
if let Ok(Some(status)) = child.try_wait() {
let _ = write_events.send(BackendEvent::Closed {
tab_id: write_tab,
reason: format!("local shell exited: {status}"),
});
return;
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
#[cfg(not(windows))]
{
if last_directory_check.is_none_or(|checked_at: Instant| {
checked_at.elapsed() >= DIRECTORY_POLL_INTERVAL
}) {
last_directory_check = Some(Instant::now());
if let Some(directory) =
child_pid.and_then(|pid| local_process_directory(&mut process_system, pid))
{
if last_directory.as_ref() != Some(&directory) {
last_directory = Some(directory.clone());
let _ = write_events.send(BackendEvent::LocalDirectoryChanged {
tab_id: write_tab.clone(),
path: directory,
});
}
}
}
}
}
let _ = child.kill();
});
let _ = events.send(BackendEvent::Status {
tab_id,
text: "local shell ready".into(),
});
Ok(BackendTx::Local(cmd_tx))
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::{
BASH_INTEGRATION_RC, FISH_INTEGRATION_COMMAND, FileShellIntegration,
POWERSHELL_SHELL_INTEGRATION, ZSH_ENV_WRAPPER, ZSH_INTEGRATION_RC,
command_prompt_with_shell_integration, file_shell_integration,
};
#[test]
fn recognizes_supported_file_based_shells_on_unix_and_windows() {
assert_eq!(
file_shell_integration(Path::new("/bin/bash"), false),
Some(FileShellIntegration::Bash { login: false })
);
assert_eq!(
file_shell_integration(Path::new("bash.exe"), true),
Some(FileShellIntegration::Bash { login: true })
);
assert_eq!(
file_shell_integration(Path::new("/bin/zsh"), false),
Some(FileShellIntegration::Zsh)
);
assert_eq!(
file_shell_integration(Path::new("/opt/homebrew/bin/fish"), false),
Some(FileShellIntegration::Fish)
);
assert_eq!(file_shell_integration(Path::new("pwsh.exe"), false), None);
}
#[test]
fn shell_integrations_mark_prompt_and_command_boundaries() {
for integration in [
BASH_INTEGRATION_RC,
ZSH_INTEGRATION_RC,
FISH_INTEGRATION_COMMAND,
POWERSHELL_SHELL_INTEGRATION,
] {
assert!(integration.contains("133;A"));
assert!(integration.contains("133;B"));
assert!(integration.contains("133;C") || integration.contains("133;D"));
}
assert!(BASH_INTEGRATION_RC.contains("133;A;k=s"));
assert!(ZSH_INTEGRATION_RC.contains("133;A;k=s"));
assert!(POWERSHELL_SHELL_INTEGRATION.contains("133;A;k=s"));
let cmd_prompt = command_prompt_with_shell_integration("$P$G");
assert!(cmd_prompt.contains("133;A"));
assert!(cmd_prompt.contains("133;B"));
assert!(cmd_prompt.contains("ASHELL_CWD:$P"));
}
#[test]
fn startup_wrappers_load_user_configuration_before_installing_hooks() {
assert!(BASH_INTEGRATION_RC.contains("$HOME/.bashrc"));
assert!(BASH_INTEGRATION_RC.contains("$HOME/.bash_profile"));
assert!(ZSH_ENV_WRAPPER.contains("$ASHELL_USER_ZDOTDIR/.zshenv"));
assert!(ZSH_INTEGRATION_RC.contains("$ASHELL_USER_ZDOTDIR/.zshrc"));
}
#[test]
fn zsh_integration_restores_user_history_path() {
assert!(ZSH_INTEGRATION_RC.contains(r#"${HISTFILE:-} == "$ASHELL_INTEGRATION_ZDOTDIR"/*"#));
assert!(ZSH_INTEGRATION_RC.contains(
r#"HISTFILE="$ASHELL_USER_ZDOTDIR/${HISTFILE#"$ASHELL_INTEGRATION_ZDOTDIR"/}""#
));
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod local;
pub mod serial;
pub mod ssh;
+233
View File
@@ -0,0 +1,233 @@
use crate::session::config::Session;
use crate::terminal::{BackendCommand, BackendEvent, GuardedBackendEventSender};
use std::io::{Read, Write};
/// Spawn the serial port backend threads.
/// Returns a sender to send commands (like keyboard inputs) to the serial port.
pub fn spawn_serial_client(
_handle: &tokio::runtime::Handle,
tab_id: String,
session: Session,
events_tx: GuardedBackendEventSender,
) -> tokio::sync::mpsc::UnboundedSender<BackendCommand> {
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<BackendCommand>();
let tab_id_clone = tab_id.clone();
let events_tx_clone = events_tx.clone();
std::thread::spawn(move || {
let _ = events_tx_clone.send(BackendEvent::Status {
tab_id: tab_id_clone.clone(),
text: rust_i18n::t!("starting_connection").to_string(),
});
let port_name = session.host;
let baud_rate = session.baud_rate;
tracing::info!(
"[serial] opening port {} at baud rate {}",
port_name,
baud_rate
);
let mut port_result = serialport::new(&port_name, baud_rate)
.timeout(std::time::Duration::from_millis(100))
.open();
if port_result.is_err() && baud_rate != 0 {
tracing::info!(
"[serial] failed to open port with baud rate {}, retrying with 0 (virtual port mode)",
baud_rate
);
port_result = serialport::new(&port_name, 0)
.timeout(std::time::Duration::from_millis(100))
.open();
}
let mut port = match port_result {
Ok(p) => p,
Err(e) => {
tracing::error!("[serial] failed to open port {}: {}", port_name, e);
let _ = events_tx_clone.send(BackendEvent::Closed {
tab_id: tab_id_clone,
reason: format!("Failed to open serial port {port_name}: {e}"),
});
return;
}
};
let mut port_write = match port.try_clone() {
Ok(pw) => pw,
Err(e) => {
tracing::error!("[serial] failed to clone port: {}", e);
let _ = events_tx_clone.send(BackendEvent::Closed {
tab_id: tab_id_clone,
reason: format!("Failed to clone serial port: {e}"),
});
return;
}
};
// Notify connected
let _ = events_tx_clone.send(BackendEvent::Connected {
tab_id: tab_id_clone.clone(),
});
// Spawn write thread
let tab_id_write = tab_id_clone.clone();
let events_tx_write = events_tx_clone.clone();
std::thread::spawn(move || {
while let Some(cmd) = cmd_rx.blocking_recv() {
match cmd {
BackendCommand::Input(bytes) => {
if let Err(e) = port_write.write_all(&bytes) {
tracing::error!("[serial] write error: {}", e);
let _ = events_tx_write.send(BackendEvent::Closed {
tab_id: tab_id_write.clone(),
reason: format!("Serial write error: {e}"),
});
break;
}
let _ = port_write.flush();
}
BackendCommand::Close => break,
BackendCommand::Resize { .. }
| BackendCommand::SampleMetrics
| BackendCommand::SampleProcesses
| BackendCommand::SamplePorts
| BackendCommand::TerminateProcess { .. } => {}
}
}
});
// Read loop in current thread
let mut buf = [0u8; 1024];
let mut last_was_cr = false;
loop {
match port.read(&mut buf) {
Ok(n) if n > 0 => {
let mut processed = Vec::with_capacity(n * 2);
let read_bytes = &buf[..n];
for i in 0..n {
let b = read_bytes[i];
if b == b'\n' {
let prev_was_cr = if i > 0 {
read_bytes[i - 1] == b'\r'
} else {
last_was_cr
};
if !prev_was_cr {
processed.push(b'\r');
}
}
processed.push(b);
}
last_was_cr = read_bytes[n - 1] == b'\r';
let _ = events_tx_clone.send(BackendEvent::Output {
tab_id: tab_id_clone.clone(),
bytes: processed,
});
}
Ok(_) => {}
Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => {}
Err(e) => {
tracing::info!("[serial] port read error/closed: {}", e);
let _ = events_tx_clone.send(BackendEvent::Closed {
tab_id: tab_id_clone,
reason: format!("Serial read error: {e}"),
});
break;
}
}
}
});
cmd_tx
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use portable_pty::{NativePtySystem, PtySize, PtySystem};
#[tokio::test]
async fn test_serial_read_write_simulation() {
// 1. Create a PTY pair using portable-pty to simulate a serial device
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
// On macOS/Linux, the slave name path behaves like a TTY device.
let fd = pair.master.as_raw_fd().unwrap();
let slave_name = unsafe {
let ptr = libc::ptsname(fd);
assert!(!ptr.is_null());
std::ffi::CStr::from_ptr(ptr).to_string_lossy().into_owned()
};
println!("Simulating serial device on PTY slave path: {}", slave_name);
// 2. Spawn the serial backend targeting the PTY slave path
let (events_tx, events_rx) = std::sync::mpsc::channel();
let handle = tokio::runtime::Handle::current();
let session = Session::serial(slave_name, 0);
let backend_events = GuardedBackendEventSender::new(events_tx);
let cmd_tx = spawn_serial_client(&handle, "test-tab".to_string(), session, backend_events);
// Wait for the Status event
let status_event = events_rx
.recv_timeout(std::time::Duration::from_secs(2))
.expect("Failed to receive Status event")
.into_current();
match status_event {
Some(BackendEvent::Status { tab_id, .. }) => assert_eq!(tab_id, "test-tab"),
event => panic!("Expected Status event, got: {event:?}"),
}
// Wait for the Connected event
let connected_event = events_rx
.recv_timeout(std::time::Duration::from_secs(2))
.expect("Failed to receive Connected event")
.into_current();
match connected_event {
Some(BackendEvent::Connected { tab_id }) => assert_eq!(tab_id, "test-tab"),
event => panic!("Expected Connected event, got: {event:?}"),
}
// 3. Test Reading: Write to PTY master, verify serial backend outputs it to UI
let mut master_writer = pair.master.take_writer().unwrap();
master_writer.write_all(b"hello serial simulator").unwrap();
master_writer.flush().unwrap();
let output_event = events_rx
.recv_timeout(std::time::Duration::from_secs(2))
.expect("Failed to receive Output event")
.into_current();
match output_event {
Some(BackendEvent::Output { tab_id, bytes }) => {
assert_eq!(tab_id, "test-tab");
assert_eq!(bytes, b"hello serial simulator");
}
event => panic!("Expected Output event, got: {event:?}"),
}
// 4. Test Writing: Send BackendCommand::Input to backend, verify PTY master reads it
cmd_tx
.send(BackendCommand::Input(b"world serial simulator".to_vec()))
.unwrap();
let mut master_reader = pair.master.try_clone_reader().unwrap();
let mut read_buf = [0u8; 128];
let bytes_read = master_reader.read(&mut read_buf).unwrap();
assert_eq!(&read_buf[..bytes_read], b"world serial simulator");
// 5. Clean up
cmd_tx.send(BackendCommand::Close).unwrap();
}
}
+1161
View File
File diff suppressed because it is too large Load Diff
-368
View File
@@ -1,368 +0,0 @@
use std::{fs, path::PathBuf};
use anyhow::{Context, Result};
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AuthMethod {
Password,
Key,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: String,
pub name: String,
pub host: String,
pub port: u16,
pub user: String,
pub auth: AuthMethod,
#[serde(default)]
pub password: String,
#[serde(default)]
pub private_key_path: String,
#[serde(default)]
pub private_key_inline: String,
#[serde(default)]
pub last_used: Option<String>,
}
impl Session {
pub fn password(host: String, port: u16, user: String, password: String) -> Self {
let name = format!("{user}@{host}");
Self {
id: Uuid::new_v4().to_string(),
name,
host,
port,
user,
auth: AuthMethod::Password,
password,
private_key_path: String::new(),
private_key_inline: String::new(),
last_used: None,
}
}
pub fn key(
host: String,
port: u16,
user: String,
private_key_path: String,
private_key_inline: String,
) -> Self {
let name = format!("{user}@{host}");
Self {
id: Uuid::new_v4().to_string(),
name,
host,
port,
user,
auth: AuthMethod::Key,
password: String::new(),
private_key_path,
private_key_inline,
last_used: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum SavedWindowBounds {
Fullscreen {
x: f32,
y: f32,
width: f32,
height: f32,
},
Maximized {
x: f32,
y: f32,
width: f32,
height: f32,
},
Windowed {
x: f32,
y: f32,
width: f32,
height: f32,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConfigFile {
#[serde(default)]
pub follow_system_theme: bool,
#[serde(default)]
pub theme_mode: String,
#[serde(default)]
pub light_theme_name: String,
#[serde(default)]
pub dark_theme_name: String,
#[serde(default = "default_locale")]
pub locale: String,
#[serde(default = "default_terminal_font_size")]
pub terminal_font_size: f32,
#[serde(default = "default_ui_font_size")]
pub ui_font_size: f32,
#[serde(default)]
pub right_click_copy_paste: bool,
#[serde(default = "default_ui_font_family")]
pub ui_font_family: String,
#[serde(default = "default_terminal_font_family")]
pub terminal_font_family: String,
#[serde(default)]
pub sessions: Vec<Session>,
#[serde(default)]
pub window_bounds: Option<SavedWindowBounds>,
#[serde(default)]
pub workspace_panels: Option<Vec<f32>>,
#[serde(default)]
pub body_panels: Option<Vec<f32>>,
#[serde(default)]
pub transfers: Vec<crate::terminal::Transfer>,
#[serde(default)]
pub show_hidden_files: bool,
}
fn default_locale() -> String {
"system".to_string()
}
fn default_terminal_font_size() -> f32 {
13.0
}
fn default_ui_font_size() -> f32 {
12.0
}
pub fn default_ui_font_family() -> String {
// ".SystemUIFont" is a GPUI sentinel that resolves to the platform system UI font.
// This matches gpui-component's own Theme default.
".SystemUIFont".to_string()
}
fn default_terminal_font_family() -> String {
"Maple Mono NF CN".to_string()
}
pub struct ConfigStore {
path: PathBuf,
cache: ConfigFile,
}
impl ConfigStore {
pub fn load() -> Result<Self> {
let path = Self::config_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create config dir {}", parent.display()))?;
let tmp_dir = parent.join("tmp");
let _ = fs::remove_dir_all(&tmp_dir);
let _ = fs::create_dir_all(&tmp_dir);
}
let cache = if path.exists() {
let raw = fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
serde_json::from_str::<ConfigFile>(&raw).unwrap_or_default()
} else {
ConfigFile::default()
};
Ok(Self { path, cache })
}
pub fn in_memory() -> Self {
Self {
path: PathBuf::new(),
cache: ConfigFile::default(),
}
}
fn config_path() -> Result<PathBuf> {
let dirs = BaseDirs::new().context("could not determine user home directory")?;
Ok(dirs
.home_dir()
.join(".config")
.join("ashell")
.join("sessions.json"))
}
pub fn sessions(&self) -> &[Session] {
&self.cache.sessions
}
pub fn tmp_dir(&self) -> Option<PathBuf> {
self.path.parent().map(|p| p.join("tmp"))
}
pub fn follow_system_theme(&self) -> bool {
self.cache.follow_system_theme
}
pub fn theme_mode(&self) -> &str {
&self.cache.theme_mode
}
pub fn light_theme_name(&self) -> &str {
&self.cache.light_theme_name
}
pub fn dark_theme_name(&self) -> &str {
&self.cache.dark_theme_name
}
pub fn locale(&self) -> &str {
if self.cache.locale.is_empty() {
"system"
} else {
&self.cache.locale
}
}
pub fn set_locale(&mut self, locale: &str) {
self.cache.locale = locale.to_string();
}
pub fn terminal_font_size(&self) -> f32 {
if self.cache.terminal_font_size <= 0.0 {
default_terminal_font_size()
} else {
self.cache.terminal_font_size
}
}
pub fn set_theme_preferences(
&mut self,
follow_system_theme: bool,
theme_mode: impl Into<String>,
light_theme_name: impl Into<String>,
dark_theme_name: impl Into<String>,
) {
self.cache.follow_system_theme = follow_system_theme;
self.cache.theme_mode = theme_mode.into();
self.cache.light_theme_name = light_theme_name.into();
self.cache.dark_theme_name = dark_theme_name.into();
}
pub fn window_bounds(&self) -> Option<&SavedWindowBounds> {
self.cache.window_bounds.as_ref()
}
pub fn workspace_panels(&self) -> Option<&Vec<f32>> {
self.cache.workspace_panels.as_ref()
}
pub fn body_panels(&self) -> Option<&Vec<f32>> {
self.cache.body_panels.as_ref()
}
pub fn transfers(&self) -> Vec<crate::terminal::Transfer> {
self.cache.transfers.clone()
}
pub fn set_transfers(&mut self, transfers: Vec<crate::terminal::Transfer>) {
self.cache.transfers = transfers;
if let Err(err) = self.save() {
tracing::error!("failed to save config: {err:#}");
}
}
pub fn set_layout_state(
&mut self,
window_bounds: Option<SavedWindowBounds>,
workspace_panels: Option<Vec<f32>>,
body_panels: Option<Vec<f32>>,
) {
self.cache.window_bounds = window_bounds;
self.cache.workspace_panels = workspace_panels;
self.cache.body_panels = body_panels;
}
pub fn set_terminal_font_size(&mut self, terminal_font_size: f32) {
self.cache.terminal_font_size = terminal_font_size.max(10.0);
}
pub fn ui_font_size(&self) -> f32 {
if self.cache.ui_font_size <= 0.0 {
default_ui_font_size()
} else {
self.cache.ui_font_size
}
}
pub fn set_ui_font_size(&mut self, ui_font_size: f32) {
self.cache.ui_font_size = ui_font_size.max(8.0);
}
pub fn ui_font_family(&self) -> &str {
if self.cache.ui_font_family.is_empty() {
".SystemUIFont"
} else {
&self.cache.ui_font_family
}
}
pub fn set_ui_font_family(&mut self, family: &str) {
self.cache.ui_font_family = family.to_string();
}
pub fn right_click_copy_paste(&self) -> bool {
self.cache.right_click_copy_paste
}
pub fn set_right_click_copy_paste(&mut self, val: bool) {
self.cache.right_click_copy_paste = val;
}
pub fn terminal_font_family(&self) -> &str {
if self.cache.terminal_font_family.is_empty() {
"Maple Mono NF CN"
} else {
&self.cache.terminal_font_family
}
}
pub fn set_terminal_font_family(&mut self, family: &str) {
self.cache.terminal_font_family = family.to_string();
}
pub fn show_hidden_files(&self) -> bool {
self.cache.show_hidden_files
}
pub fn set_show_hidden_files(&mut self, val: bool) {
self.cache.show_hidden_files = val;
}
pub fn get(&self, id: &str) -> Option<&Session> {
self.cache.sessions.iter().find(|s| s.id == id)
}
pub fn upsert(&mut self, session: Session) {
if let Some(existing) = self.cache.sessions.iter_mut().find(|s| s.id == session.id) {
*existing = session;
} else {
self.cache.sessions.push(session);
}
}
pub fn remove(&mut self, id: &str) {
self.cache.sessions.retain(|s| s.id != id);
}
pub fn save(&self) -> Result<()> {
if self.path.as_os_str().is_empty() {
return Ok(());
}
let raw = serde_json::to_string_pretty(&self.cache)?;
fs::write(&self.path, raw)
.with_context(|| format!("failed to write {}", self.path.display()))
}
}
+291
View File
@@ -0,0 +1,291 @@
#[cfg(any(target_os = "macos", target_os = "windows"))]
use std::{collections::VecDeque, sync::Mutex};
#[cfg(target_os = "macos")]
const APP_BUNDLE_ID: &str = "dev.ashell.app";
#[cfg(target_os = "windows")]
const APP_USER_MODEL_ID: &str = "dev.ashell.app";
#[cfg(any(target_os = "macos", target_os = "windows"))]
const MAX_PENDING_TERMINAL_NOTIFICATION_ACTIVATIONS: usize = 64;
#[cfg(any(target_os = "macos", target_os = "windows"))]
static TERMINAL_NOTIFICATION_ACTIVATIONS: Mutex<VecDeque<String>> = Mutex::new(VecDeque::new());
pub(crate) fn initialize() {
#[cfg(target_os = "macos")]
match desktop_notify::set_application(APP_BUNDLE_ID) {
Ok(()) => {
tracing::info!("registered the macOS notification application identity {APP_BUNDLE_ID}")
}
Err(error) => {
tracing::warn!(
"failed to register the macOS notification application identity {APP_BUNDLE_ID}: {error}"
);
}
}
#[cfg(target_os = "windows")]
if let Err(error) = unsafe {
windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID(windows::core::w!(
"dev.ashell.app"
))
} {
tracing::warn!("failed to set Windows application identity: {error}");
}
}
pub(crate) fn show_terminal_notification(tab_id: String, title: String, body: String) {
#[cfg(any(target_os = "macos", target_os = "windows"))]
if let Err(error) = std::thread::Builder::new()
.name("desktop-notification".to_string())
.spawn(move || show_native_notification(&title, &body, &tab_id))
{
tracing::warn!("failed to start desktop notification thread: {error}");
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
let _ = (tab_id, title, body);
}
}
#[cfg(target_os = "macos")]
fn show_native_notification(title: &str, body: &str, tab_id: &str) {
let result = mac_notification_sys::Notification::new()
.title(title)
.message(body)
.wait_for_click(true)
.send();
match result {
Ok(mac_notification_sys::NotificationResponse::Click) => {
queue_terminal_notification_activation(tab_id.to_string());
}
Ok(_) => tracing::info!("terminal notification closed without activation on macOS"),
Err(error) => tracing::warn!("failed to show macOS terminal notification: {error}"),
}
}
#[cfg(target_os = "windows")]
fn show_native_notification(title: &str, body: &str, tab_id: &str) {
match show_windows_notification(APP_USER_MODEL_ID, title, body, tab_id) {
Ok(()) => tracing::info!("submitted terminal notification to Windows"),
Err(primary_error) => {
if let Err(fallback_error) = show_windows_notification(
winrt_notification::Toast::POWERSHELL_APP_ID,
title,
body,
tab_id,
) {
tracing::warn!(
"failed to show Windows terminal notification: {primary_error}; fallback failed: {fallback_error}"
);
}
}
}
}
#[cfg(target_os = "windows")]
fn show_windows_notification(
app_id: &str,
title: &str,
body: &str,
tab_id: &str,
) -> winrt_notification::Result<()> {
let activation_tab_id = tab_id.to_string();
winrt_notification::Toast::new(app_id)
.title(title)
.text2(body)
.duration(winrt_notification::Duration::Short)
.sound(None)
.on_activated(move || {
queue_terminal_notification_activation(activation_tab_id.clone());
Ok(())
})
.show()
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn queue_terminal_notification_activation(tab_id: String) {
let Ok(mut activations) = TERMINAL_NOTIFICATION_ACTIVATIONS.lock() else {
tracing::warn!("failed to lock terminal notification activation queue");
return;
};
if activations.len() >= MAX_PENDING_TERMINAL_NOTIFICATION_ACTIVATIONS {
activations.pop_front();
}
activations.push_back(tab_id);
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
pub(crate) fn take_terminal_notification_activation() -> Option<String> {
let Ok(mut activations) = TERMINAL_NOTIFICATION_ACTIVATIONS.lock() else {
tracing::warn!("failed to lock terminal notification activation queue");
return None;
};
activations.pop_front()
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub(crate) fn take_terminal_notification_activation() -> Option<String> {
None
}
pub(crate) fn native_window_handle(window: &gpui::Window) -> Option<isize> {
#[cfg(target_os = "windows")]
{
use raw_window_handle::RawWindowHandle;
let handle = raw_window_handle::HasWindowHandle::window_handle(window).ok()?;
return match handle.as_raw() {
RawWindowHandle::Win32(handle) => Some(handle.hwnd.get()),
_ => None,
};
}
#[cfg(not(target_os = "windows"))]
{
let _ = window;
None
}
}
pub(crate) fn set_unread_indicator(unread: bool, window_handle: Option<isize>) {
#[cfg(target_os = "macos")]
{
use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication;
use objc2_foundation::NSString;
let _ = window_handle;
let Some(main_thread) = MainThreadMarker::new() else {
tracing::warn!("attempted to update the Dock badge outside the main thread");
return;
};
let application = NSApplication::sharedApplication(main_thread);
let dock_tile = application.dockTile();
let label = unread.then(|| NSString::from_str("\u{2022}"));
dock_tile.setBadgeLabel(label.as_deref());
}
#[cfg(target_os = "windows")]
if let Some(window_handle) = window_handle {
if let Err(error) = set_windows_taskbar_badge(window_handle, unread) {
tracing::warn!("failed to update Windows taskbar badge: {error}");
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let _ = (unread, window_handle);
}
pub(crate) fn clear_unread_indicator(window_handle: Option<isize>) {
set_unread_indicator(false, window_handle);
}
#[allow(deprecated)]
/// Clears notifications delivered by the current application from Notification Center.
pub(crate) fn clear_current_app_delivered_notifications() {
#[cfg(target_os = "macos")]
{
use objc2_foundation::NSUserNotificationCenter;
// The default center is scoped to this application, so other apps' notifications remain untouched.
NSUserNotificationCenter::defaultUserNotificationCenter().removeAllDeliveredNotifications();
}
#[cfg(target_os = "windows")]
{
use windows::UI::Notifications::ToastNotificationManager;
if let Err(error) = ToastNotificationManager::History().and_then(|history| history.Clear())
{
tracing::warn!("failed to clear Windows notification history: {error}");
}
}
}
#[cfg(target_os = "windows")]
fn set_windows_taskbar_badge(window_handle: isize, unread: bool) -> windows::core::Result<()> {
use windows::{
Win32::{
Foundation::HWND,
System::Com::{CLSCTX_INPROC_SERVER, CoCreateInstance},
UI::{
Shell::{ITaskbarList3, TaskbarList},
WindowsAndMessaging::{
CreateIconFromResourceEx, DestroyIcon, HICON, LR_DEFAULTCOLOR,
},
},
},
core::{IUnknown, PCWSTR, w},
};
let taskbar: ITaskbarList3 =
unsafe { CoCreateInstance(&TaskbarList, None::<&IUnknown>, CLSCTX_INPROC_SERVER)? };
unsafe { taskbar.HrInit()? };
let hwnd = HWND(window_handle);
if !unread {
return unsafe { taskbar.SetOverlayIcon(hwnd, HICON(0), PCWSTR::null()) };
}
let icon_resource = windows_badge_icon_resource();
let icon = unsafe {
CreateIconFromResourceEx(&icon_resource, true, 0x0003_0000, 16, 16, LR_DEFAULTCOLOR)?
};
let result = unsafe { taskbar.SetOverlayIcon(hwnd, icon, w!("Unread terminal notification")) };
let destroy_result = unsafe { DestroyIcon(icon) };
result?;
destroy_result
}
#[cfg(target_os = "windows")]
fn windows_badge_icon_resource() -> Vec<u8> {
const WIDTH: usize = 16;
const HEIGHT: usize = 16;
const BITMAP_HEADER_SIZE: u32 = 40;
const XOR_BYTES: usize = WIDTH * HEIGHT * 4;
const AND_ROW_BYTES: usize = 4;
let mut resource = Vec::with_capacity(BITMAP_HEADER_SIZE as usize + XOR_BYTES + 64);
resource.extend_from_slice(&BITMAP_HEADER_SIZE.to_le_bytes());
resource.extend_from_slice(&(WIDTH as i32).to_le_bytes());
resource.extend_from_slice(&((HEIGHT * 2) as i32).to_le_bytes());
resource.extend_from_slice(&1u16.to_le_bytes());
resource.extend_from_slice(&32u16.to_le_bytes());
resource.extend_from_slice(&0u32.to_le_bytes());
resource.extend_from_slice(&(XOR_BYTES as u32).to_le_bytes());
resource.extend_from_slice(&0i32.to_le_bytes());
resource.extend_from_slice(&0i32.to_le_bytes());
resource.extend_from_slice(&0u32.to_le_bytes());
resource.extend_from_slice(&0u32.to_le_bytes());
for y in (0..HEIGHT).rev() {
for x in 0..WIDTH {
if windows_badge_pixel_is_red(x, y) {
resource.extend_from_slice(&[42, 48, 239, 255]);
} else {
resource.extend_from_slice(&[0, 0, 0, 0]);
}
}
}
for y in (0..HEIGHT).rev() {
let mut row = [0u8; AND_ROW_BYTES];
for x in 0..WIDTH {
if !windows_badge_pixel_is_red(x, y) {
row[x / 8] |= 0x80 >> (x % 8);
}
}
resource.extend_from_slice(&row);
}
resource
}
#[cfg(target_os = "windows")]
fn windows_badge_pixel_is_red(x: usize, y: usize) -> bool {
let dx = x as f32 - 7.5;
let dy = y as f32 - 7.5;
dx * dx + dy * dy <= 42.25
}
-129
View File
@@ -1,129 +0,0 @@
use std::{
io::{Read, Write},
sync::mpsc::{self, Sender},
thread,
};
use anyhow::{Context, Result};
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use crate::terminal::{BackendCommand, BackendEvent, BackendTx};
pub fn spawn_local_terminal(
tab_id: String,
cols: u16,
rows: u16,
events: Sender<BackendEvent>,
) -> Result<BackendTx> {
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.context("open local PTY")?;
let shell = std::env::var("SHELL").unwrap_or_else(|_| {
if cfg!(windows) {
"powershell.exe".into()
} else {
"/bin/zsh".into()
}
});
let mut cmd = CommandBuilder::new(&shell);
cmd.env(
"TERM",
std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".into()),
);
cmd.env(
"COLORTERM",
std::env::var("COLORTERM").unwrap_or_else(|_| "truecolor".into()),
);
cmd.env("TERM_PROGRAM", "ashell");
if let Ok(path) = std::env::var("PATH") {
cmd.env("PATH", path);
}
if let Ok(lang) = std::env::var("LANG") {
cmd.env("LANG", lang);
} else {
cmd.env("LANG", "en_US.UTF-8");
}
if let Ok(home) = std::env::var("HOME") {
cmd.env("HOME", home);
}
cmd.env("SHELL", shell);
let mut child = pair.slave.spawn_command(cmd).context("spawn local shell")?;
drop(pair.slave);
let master = pair.master;
let mut reader = master.try_clone_reader().context("clone PTY reader")?;
let mut writer = master.take_writer().context("take PTY writer")?;
let (cmd_tx, cmd_rx) = mpsc::channel::<BackendCommand>();
let read_tab = tab_id.clone();
let read_events = events.clone();
thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let _ = read_events.send(BackendEvent::Output {
tab_id: read_tab.clone(),
bytes: buf[..n].to_vec(),
});
}
Err(err) => {
let _ = read_events.send(BackendEvent::Closed {
tab_id: read_tab.clone(),
reason: format!("local read error: {err}"),
});
return;
}
}
}
let _ = read_events.send(BackendEvent::Closed {
tab_id: read_tab,
reason: "local shell closed".into(),
});
});
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) {
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,
}
}
let _ = child.kill();
});
let _ = events.send(BackendEvent::Status {
tab_id,
text: "local shell ready".into(),
});
Ok(BackendTx::Local(cmd_tx))
}
+40 -4776
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2902
View File
File diff suppressed because it is too large Load Diff
+179
View File
@@ -0,0 +1,179 @@
use std::fs;
use std::path::PathBuf;
use anyhow::{Context, Result};
use directories::BaseDirs;
/// A parsed entry from ~/.ssh/config
#[derive(Debug, Clone)]
pub struct SshConfigEntry {
/// The Host pattern (alias) from the config, e.g. "myserver"
pub host_alias: String,
/// The actual hostname (HostName), defaults to the host alias if not specified
pub hostname: String,
/// The user, defaults to empty (will use current OS user)
pub user: String,
/// The port, defaults to 22
pub port: u16,
/// Identity files specified for this host
pub identity_files: Vec<String>,
/// Whether this is a wildcard/pattern host (Host * or Host *)
pub is_wildcard: bool,
}
/// Parse ~/.ssh/config and return a list of concrete host entries.
/// Wildcard patterns (Host *) are excluded.
pub fn parse_ssh_config() -> Result<Vec<SshConfigEntry>> {
let config_path = ssh_config_path()?;
if !config_path.is_file() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&config_path)
.with_context(|| format!("failed to read {}", config_path.display()))?;
parse_ssh_config_content(&content)
}
fn ssh_config_path() -> Result<PathBuf> {
BaseDirs::new()
.map(|dirs| dirs.home_dir().join(".ssh/config"))
.context("failed to determine home directory")
}
/// Parse the content of an ssh config file into entries.
pub fn parse_ssh_config_content(content: &str) -> Result<Vec<SshConfigEntry>> {
let mut entries: Vec<SshConfigEntry> = Vec::new();
let mut current_host: Option<SshConfigEntry> = None;
for line in content.lines() {
let line = line.trim();
// Skip empty lines and comments
if line.is_empty() || line.starts_with('#') {
continue;
}
// Split into keyword and value
// OpenSSH supports both "keyword value" and "keyword=value" formats
let (keyword, value) = if let Some(pos) = line.find('=') {
(&line[..pos], line[pos + 1..].trim())
} else if let Some(pos) = line.find(' ') {
(&line[..pos], line[pos..].trim())
} else {
continue;
};
let keyword_lower = keyword.trim().to_lowercase();
let value = value.trim();
match keyword_lower.as_str() {
"host" => {
// Save previous entry if it exists and is not a wildcard
if let Some(entry) = current_host.take() {
if !entry.is_wildcard {
entries.push(entry);
}
}
// Host line may contain multiple patterns (Host a b c)
// Take the first non-wildcard pattern as the display alias
let patterns: Vec<&str> = value.split_whitespace().collect();
if patterns.is_empty() {
continue;
}
let host_alias = patterns
.iter()
.find(|p| !p.contains('*') && !p.contains('?'))
.map(|p| p.to_string())
.unwrap_or_else(|| patterns[0].to_string());
let is_wildcard = value.contains('*') || value.contains('?');
current_host = Some(SshConfigEntry {
host_alias: host_alias.clone(),
hostname: host_alias,
user: String::new(),
port: 22,
identity_files: Vec::new(),
is_wildcard,
});
}
"hostname" => {
if let Some(entry) = current_host.as_mut() {
entry.hostname = value.to_string();
}
}
"user" => {
if let Some(entry) = current_host.as_mut() {
entry.user = value.to_string();
}
}
"port" => {
if let Some(entry) = current_host.as_mut() {
entry.port = value.parse::<u16>().unwrap_or(22);
}
}
"identityfile" => {
if let Some(entry) = current_host.as_mut() {
entry.identity_files.push(value.to_string());
}
}
// Skip Match blocks and Include directives — not supported yet
"match" | "include" => {
// If we encounter a Match block, flush the current Host entry
// since Match blocks apply to all subsequent hosts until the next Match/Host
if let Some(entry) = current_host.take() {
if !entry.is_wildcard {
entries.push(entry);
}
}
// Don't create a new entry for Match/Include
}
_ => {}
}
}
// Save the last entry if it's not a wildcard
if let Some(entry) = current_host.take() {
if !entry.is_wildcard {
entries.push(entry);
}
}
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_ssh_config_content() {
let content = r#"
Host myhost
HostName 1.2.3.4
User git
Port 2222
IdentityFile ~/.ssh/id_rsa
Host
Host =
Host anotherhost
HostName 5.6.7.8
"#;
let entries = parse_ssh_config_content(content).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].host_alias, "myhost");
assert_eq!(entries[0].hostname, "1.2.3.4");
assert_eq!(entries[0].user, "git");
assert_eq!(entries[0].port, 2222);
assert_eq!(entries[0].identity_files, vec!["~/.ssh/id_rsa".to_string()]);
assert_eq!(entries[1].host_alias, "anotherhost");
assert_eq!(entries[1].hostname, "5.6.7.8");
}
}
+96
View File
@@ -0,0 +1,96 @@
use std::sync::Arc;
use anyhow::{Result, anyhow};
use directories::BaseDirs;
use russh::{
client::{self, Handler},
keys::{HashAlg, PrivateKey, key::PrivateKeyWithHashAlg, load_secret_key},
};
use crate::session::config::Session;
pub const DEFAULT_KEY_NAMES: &[&str] = &["id_ed25519", "id_rsa", "id_ecdsa", "id_dsa"];
pub fn session_has_explicit_key(session: &Session) -> bool {
!session.private_key_path.trim().is_empty()
|| !normalize_inline_private_key(&session.private_key_inline).is_empty()
}
pub fn normalize_inline_private_key(value: &str) -> String {
let mut normalized = value
.trim()
.replace("\\r\\n", "\n")
.replace("\\n", "\n")
.replace("\r\n", "\n");
if normalized.is_empty() {
return String::new();
}
if !normalized.ends_with('\n') {
normalized.push('\n');
}
normalized
}
pub fn private_keys_with_algs(keypair: PrivateKey) -> Result<Vec<PrivateKeyWithHashAlg>> {
let mut algs = Vec::new();
let key_arc = Arc::new(keypair);
if key_arc.algorithm().is_rsa() {
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha512)) {
algs.push(k);
}
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha256)) {
algs.push(k);
}
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
algs.push(k);
}
} else if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
algs.push(k);
}
if algs.is_empty() {
return Err(anyhow!(
"Failed to construct PrivateKeyWithHashAlg for any supported hash algorithm"
));
}
Ok(algs)
}
pub async fn authenticate_with_default_keys<H>(
handle: &mut client::Handle<H>,
user: &str,
passphrase: Option<&str>,
) -> Result<bool>
where
H: Handler + Send + Sync,
H::Error: Into<anyhow::Error>,
{
let Some(ssh_dir) = BaseDirs::new().map(|d| d.home_dir().join(".ssh")) else {
return Ok(false);
};
for key_name in DEFAULT_KEY_NAMES {
let key_path = ssh_dir.join(key_name);
if !key_path.exists() {
continue;
}
tracing::debug!("[ssh] trying default key {}", key_path.display());
match load_secret_key(&key_path, passphrase) {
Ok(keypair) => {
if let Ok(keys) = private_keys_with_algs(keypair) {
for key in keys {
match handle.authenticate_publickey(user, key).await {
Ok(true) => return Ok(true),
Ok(false) | Err(_) => continue,
}
}
}
}
Err(_) => continue,
}
}
Ok(false)
}
File diff suppressed because it is too large Load Diff
+525
View File
@@ -0,0 +1,525 @@
use gpui::{Context, PathPromptOptions, Pixels, Point, Window};
use crate::{
Ashell, SftpContextMenuState,
sftp::{RemoteEntry, SftpHandle},
terminal,
};
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>) {
let Some((current_path, home_dir)) = self
.active_sftp()
.map(|sftp| (sftp.current_path.clone(), sftp.home_dir.clone()))
else {
return;
};
let path = crate::sftp::normalize_remote_path(&path, &current_path, &home_dir);
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
tracing::info!("[sftp] navigating to directory: '{}'", path);
let ancestors = crate::sftp::remote_path_ancestors(&path);
let mut paths_to_load = Vec::new();
if let Some(sftp) = self.active_sftp_mut() {
sftp.current_path = path.clone();
sftp.selected_path = None;
sftp.selection_anchor = None;
sftp.preview = None;
sftp.selected_entries.clear();
sftp.expand_to(&path);
sftp.begin_directory_load(&path);
for ancestor in ancestors {
if ancestor != path
&& !sftp.directory_cache.contains_key(&ancestor)
&& !sftp.loading_directories.contains(&ancestor)
{
sftp.begin_directory_load(&ancestor);
paths_to_load.push(ancestor);
}
}
}
self.pending_sftp_path_sync = Some(path.clone());
for ancestor in paths_to_load {
handle.list_dir(ancestor);
}
handle.list_dir(path);
cx.notify();
}
pub(crate) fn toggle_sftp_tree_directory(&mut self, path: String, cx: &mut Context<Self>) {
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
let mut should_load = false;
if let Some(sftp) = self.active_sftp_mut() {
let has_error = sftp.directory_errors.contains_key(&path);
let has_cache = sftp.directory_cache.contains_key(&path);
if sftp.expanded_directories.contains(&path) && !has_error && has_cache {
sftp.expanded_directories.remove(&path);
cx.notify();
return;
}
sftp.expanded_directories.insert(path.clone());
should_load = (has_error || !has_cache) && !sftp.loading_directories.contains(&path);
if should_load {
sftp.begin_directory_load(&path);
}
}
if should_load {
handle.list_dir(path);
}
cx.notify();
}
pub(crate) fn reveal_current_sftp_directory(&mut self, cx: &mut Context<Self>) {
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
let Some(current_path) = self.active_sftp().map(|sftp| sftp.current_path.clone()) else {
return;
};
let ancestors = crate::sftp::remote_path_ancestors(&current_path);
let mut paths_to_load = Vec::new();
if let Some(sftp) = self.active_sftp_mut() {
sftp.expand_to(&current_path);
for path in ancestors {
if !sftp.directory_cache.contains_key(&path)
&& !sftp.loading_directories.contains(&path)
{
sftp.begin_directory_load(&path);
paths_to_load.push(path);
}
}
}
for path in paths_to_load {
handle.list_dir(path);
}
if let Some(index) = self.active_sftp().and_then(|sftp| {
sftp.tree_rows(self.show_hidden_files)
.iter()
.position(|row| row.path == current_path)
}) {
self.remote_tree_scroll_handle
.scroll_to_item(index, gpui::ScrollStrategy::Center);
}
cx.notify();
}
pub(crate) fn collapse_sftp_tree(&mut self, cx: &mut Context<Self>) {
if let Some(sftp) = self.active_sftp_mut() {
sftp.collapse_all();
cx.notify();
}
}
pub(crate) fn select_sftp_entry(
&mut self,
entry: RemoteEntry,
visible_paths: &[String],
extend_range: bool,
cx: &mut Context<Self>,
) {
if extend_range {
if let Some(sftp) = self.active_sftp_mut() {
let target_path = entry.full_path.clone();
let target_index = visible_paths.iter().position(|path| path == &target_path);
let anchor_index = sftp
.selection_anchor
.as_ref()
.and_then(|anchor| visible_paths.iter().position(|path| path == anchor));
sftp.selected_entries.clear();
if let Some(target_index) = target_index {
let anchor_index = anchor_index.unwrap_or(target_index);
let start = anchor_index.min(target_index);
let end = anchor_index.max(target_index);
sftp.selected_entries
.extend(visible_paths[start..=end].iter().cloned());
} else {
sftp.selected_entries.insert(target_path.clone());
}
if sftp.selection_anchor.is_none() {
sftp.selection_anchor = Some(target_path.clone());
}
sftp.selected_path = Some(target_path);
}
cx.notify();
return;
}
if entry.is_dir {
self.navigate_sftp(entry.full_path, cx);
return;
}
self.mark_sftp_entry_selected(&entry.full_path, cx);
if let Some(sftp) = self.active_sftp_mut() {
sftp.selection_anchor = Some(entry.full_path.clone());
if !sftp.selected_entries.remove(&entry.full_path) {
sftp.selected_entries.insert(entry.full_path);
}
}
}
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,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(menu) = self.sftp_context_menu.take() else {
return;
};
tracing::info!("[sftp] opening in-app editor for: '{}'", menu.remote_path);
self.show_sftp_editor_dialog(menu.remote_path, window, cx);
}
pub(crate) fn trigger_sftp_context_rename(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(menu) = self.sftp_context_menu.take() else {
return;
};
self.show_sftp_rename_dialog(menu.remote_path, window, cx);
}
pub(crate) fn download_sftp_entry(
&mut self,
remote_path: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Select Download Folder".into()),
});
cx.spawn_in(window, async move |this, cx| {
match path_prompt.await {
Ok(Ok(Some(mut paths))) => {
if let Some(folder) = paths.pop() {
let local_path = folder.to_string_lossy().to_string();
tracing::info!(
"[sftp] initiating download of '{}' to '{}'",
remote_path,
local_path
);
handle.download(remote_path, local_path);
this.update(cx, |this, cx| {
this.show_transfers_dialog = true;
cx.notify();
})?;
}
}
Ok(Err(err)) => {
this.update(cx, |this, cx| {
this.status = format!("download picker failed: {err}").into();
cx.notify();
})?;
}
_ => {}
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
pub(crate) fn upload_sftp_files(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
let remote_dir = self
.active_sftp()
.map(|sftp| sftp.current_path.clone())
.unwrap_or_else(|| "/".into());
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: Some("Select File to Upload".into()),
});
cx.spawn_in(window, async move |this, cx| {
match path_prompt.await {
Ok(Ok(Some(mut paths))) => {
if let Some(file) = paths.pop() {
let local_path = file.to_string_lossy().to_string();
tracing::info!(
"[sftp] initiating upload of file '{}' to '{}'",
local_path,
remote_dir
);
handle.upload_paths(vec![local_path], remote_dir);
this.update(cx, |this, cx| {
this.show_transfers_dialog = true;
cx.notify();
})?;
}
}
Ok(Err(err)) => {
this.update(cx, |this, cx| {
this.status = format!("upload picker failed: {err}").into();
cx.notify();
})?;
}
_ => {}
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
pub(crate) fn upload_sftp_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
let remote_dir = self
.active_sftp()
.map(|sftp| sftp.current_path.clone())
.unwrap_or_else(|| "/".into());
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Select Folder to Upload".into()),
});
cx.spawn_in(window, async move |this, cx| {
match path_prompt.await {
Ok(Ok(Some(mut paths))) => {
if let Some(folder) = paths.pop() {
let local_path = folder.to_string_lossy().to_string();
tracing::info!(
"[sftp] initiating upload of folder '{}' to '{}'",
local_path,
remote_dir
);
handle.upload_paths(vec![local_path], remote_dir);
this.update(cx, |this, cx| {
this.show_transfers_dialog = true;
cx.notify();
})?;
}
}
Ok(Err(err)) => {
this.update(cx, |this, cx| {
this.status = format!("upload picker failed: {err}").into();
cx.notify();
})?;
}
_ => {}
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
pub(crate) fn toggle_sftp_entry(
&mut self,
path: String,
checked: bool,
cx: &mut Context<Self>,
) {
if let Some(sftp) = self.active_sftp_mut() {
sftp.selected_path = Some(path.clone());
sftp.selection_anchor = Some(path.clone());
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
.current_entries()
.iter()
.map(|entry| entry.full_path.clone())
.collect();
for path in paths {
sftp.selected_entries.insert(path);
}
} else {
sftp.selected_entries.clear();
sftp.selected_path = None;
sftp.selection_anchor = None;
}
cx.notify();
}
}
pub(crate) fn download_selected_sftp_entries(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(sftp) = self.active_sftp() else {
return;
};
let selected: Vec<String> = sftp.selected_entries.iter().cloned().collect();
if selected.is_empty() {
return;
}
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Select Download Folder".into()),
});
cx.spawn_in(window, async move |this, cx| {
if let Ok(Ok(Some(mut paths))) = path_prompt.await {
if let Some(folder) = paths.pop() {
let local_dir = folder.to_string_lossy().to_string();
tracing::info!(
"[sftp] initiating batch download of {} entries to '{}'",
selected.len(),
local_dir
);
for remote in selected {
let _ = handle.commands.send(crate::sftp::SftpCommand::Download {
remote,
local_dir: local_dir.clone(),
});
}
let _ = this.update(cx, |this, cx| {
if let Some(sftp_mut) = this.active_sftp_mut() {
sftp_mut.selected_entries.clear();
}
this.show_transfers_dialog = true;
cx.notify();
});
}
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
pub(crate) fn upload_sftp_files_batch(&mut self, paths: Vec<String>, cx: &mut Context<Self>) {
if paths.is_empty() {
return;
}
if let Some(sftp) = self.active_sftp() {
if let Some(handle) = self.active_sftp_handle() {
tracing::info!(
"[sftp] initiating batch upload of {} files to '{}'",
paths.len(),
sftp.current_path
);
let _ = handle.commands.send(crate::sftp::SftpCommand::UploadPaths {
locals: paths,
remote_dir: sftp.current_path.clone(),
});
self.show_transfers_dialog = true;
cx.notify();
}
}
}
}
-432
View File
@@ -1,432 +0,0 @@
use std::{
path::{Path, PathBuf},
sync::Arc,
};
use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use directories::BaseDirs;
use russh::{
ChannelMsg, Disconnect,
client::{self, Handler},
keys::{HashAlg, PrivateKey, decode_secret_key, key::PrivateKeyWithHashAlg, load_secret_key},
};
use tokio::sync::mpsc;
use crate::{
config::{AuthMethod, Session},
system::{SystemSnapshot, remote_snapshot_from_kv},
terminal::{BackendCommand, BackendEvent, BackendTx},
};
pub fn spawn_ssh_terminal(
runtime: &tokio::runtime::Handle,
tab_id: String,
session: Session,
cols: u16,
rows: u16,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> BackendTx {
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<BackendCommand>();
let task_tab = tab_id.clone();
runtime.spawn(async move {
if let Err(err) = run_ssh(
task_tab.clone(),
session,
cols,
rows,
cmd_rx,
events.clone(),
)
.await
{
let _ = events.send(BackendEvent::Closed {
tab_id: task_tab,
reason: format!("{err:#}"),
});
}
});
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?;
let mut channel = handle
.channel_open_session()
.await
.context("open metrics session")?;
channel
.exec(true, REMOTE_SYSTEM_PROBE)
.await
.context("exec remote metrics probe")?;
let mut stdout = Vec::new();
while let Some(msg) = channel.wait().await {
match msg {
ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, ext: _ } => {
stdout.extend_from_slice(&data);
}
ChannelMsg::Close => break,
_ => {}
}
}
let _ = handle
.disconnect(Disconnect::ByApplication, "metrics done", "")
.await;
let output = String::from_utf8_lossy(&stdout);
remote_snapshot_from_kv(&output)
}
async fn run_ssh(
tab_id: String,
session: Session,
cols: u16,
rows: u16,
mut commands: mpsc::UnboundedReceiver<BackendCommand>,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Result<()> {
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.clone(),
text: format!(
"connecting {}@{}:{}...",
session.user, session.host, session.port
),
});
let handle = connect_and_authenticate(&tab_id, &session, &events).await?;
let mut channel = handle
.channel_open_session()
.await
.context("open session")?;
channel
.request_pty(true, "xterm-256color", cols.into(), rows.into(), 0, 0, &[])
.await
.context("request pty")?;
channel.request_shell(true).await.context("request shell")?;
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.clone(),
text: format!("connected {}@{}", session.user, session.host),
});
let _ = events.send(BackendEvent::Connected {
tab_id: tab_id.clone(),
});
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}"),
});
break;
}
}
Some(BackendCommand::Resize { cols, rows }) => {
let _ = channel.window_change(cols.into(), rows.into(), 0, 0).await;
}
Some(BackendCommand::Close) | None => {
let _ = channel.eof().await;
break;
}
}
}
msg = channel.wait() => {
match msg {
Some(ChannelMsg::Data { data }) | Some(ChannelMsg::ExtendedData { data, ext: _ }) => {
let _ = events.send(BackendEvent::Output {
tab_id: tab_id.clone(),
bytes: data.to_vec(),
});
}
Some(ChannelMsg::Close) | None => break,
_ => {}
}
}
}
}
let _ = handle
.disconnect(Disconnect::ByApplication, "bye", "")
.await;
let _ = events.send(BackendEvent::Closed {
tab_id,
reason: "ssh session closed".into(),
});
Ok(())
}
async fn connect_and_authenticate(
tab_id: &str,
session: &Session,
events: &std::sync::mpsc::Sender<BackendEvent>,
) -> Result<russh::client::Handle<ClientHandler>> {
let config = Arc::new(client::Config {
inactivity_timeout: Some(std::time::Duration::from_secs(600)),
..Default::default()
});
let addr = format!("{}:{}", session.host, session.port);
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!("opening tcp connection to {addr}"),
});
let mut handle = client::connect(config, addr.as_str(), ClientHandler)
.await
.with_context(|| format!("connect {addr} failed"))?;
let authed = match session.auth {
AuthMethod::Password => {
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!(
"connected to {addr}, sending password authentication for {}",
session.user
),
});
handle
.authenticate_password(&session.user, &session.password)
.await
.context("password authentication failed")?
}
AuthMethod::Key => {
let source = key_source_label(session);
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!("connected to {addr}, loading private key from {source}"),
});
let keypair = load_session_private_key(session)?;
let algorithm = format!("{:?}", keypair.algorithm());
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!("private key loaded from {source}, algorithm {algorithm}, sending public key authentication for {}", session.user),
});
let 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
)
})?
}
};
if !authed {
let _ = handle
.disconnect(Disconnect::ByApplication, "auth failed", "")
.await;
return Err(anyhow!(
"{}",
match session.auth {
AuthMethod::Password => format!(
"authentication failed: server rejected password authentication for {}@{}:{}",
session.user, session.host, session.port
),
AuthMethod::Key => format!(
"authentication failed: server rejected public key authentication for {}@{}:{} using {}",
session.user,
session.host,
session.port,
key_source_label(session)
),
}
));
}
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!(
"authentication accepted, opening shell for {}@{}",
session.user, session.host
),
});
Ok(handle)
}
fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
let inline_key = normalize_inline_private_key(&session.private_key_inline);
let key_path = expand_key_path(session.private_key_path.trim());
let has_inline = !inline_key.is_empty();
let has_path = key_path.is_some();
if !has_inline && !has_path {
return Err(anyhow!("private key content or path is required"));
}
let mut errors = Vec::new();
if has_inline {
match decode_secret_key(&inline_key, None) {
Ok(key) => return Ok(key),
Err(err) => errors.push(format!("decode private key content: {err}")),
}
}
if let Some(path) = key_path {
match load_secret_key(path.as_path(), None) {
Ok(key) => return Ok(key),
Err(err) => errors.push(format!("load key {}: {err}", path.display())),
}
}
Err(anyhow!(errors.join("; ")))
}
fn private_key_with_alg(keypair: PrivateKey) -> Result<PrivateKeyWithHashAlg> {
let hash_alg = if keypair.algorithm().is_rsa() {
Some(HashAlg::Sha512)
} else {
None
};
Ok(
PrivateKeyWithHashAlg::new(Arc::new(keypair.clone()), hash_alg)
.or_else(|_| PrivateKeyWithHashAlg::new(Arc::new(keypair), Some(HashAlg::Sha256)))?,
)
}
fn normalize_inline_private_key(value: &str) -> String {
let mut normalized = value
.trim()
.replace("\\r\\n", "\n")
.replace("\\n", "\n")
.replace("\r\n", "\n");
if !normalized.ends_with('\n') {
normalized.push('\n');
}
normalized
}
fn expand_key_path(value: &str) -> Option<PathBuf> {
if value.is_empty() {
return None;
}
if value == "~" {
return BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf());
}
if let Some(rest) = value.strip_prefix("~/") {
return BaseDirs::new().map(|dirs| dirs.home_dir().join(rest));
}
Some(Path::new(value).to_path_buf())
}
fn key_source_label(session: &Session) -> String {
let path = session.private_key_path.trim();
let has_inline = !session.private_key_inline.trim().is_empty();
match (!path.is_empty(), has_inline) {
(true, true) => format!("inline key or {}", path),
(true, false) => path.to_string(),
(false, true) => "inline key text".to_string(),
(false, false) => "unknown key source".to_string(),
}
}
const REMOTE_SYSTEM_PROBE: &str = r#"sh -lc '
os=$(uname -s 2>/dev/null || echo unknown)
if [ "$os" = "Linux" ] && [ -r /proc/stat ]; then
cpu_stat() { awk '"'"'/^cpu / { print ($2+$3+$4+$5+$6+$7+$8), $5 }'"'"' /proc/stat 2>/dev/null; }
net_stat() { awk -F"[: ]+" '"'"'/:/ && $1!="Inter" && $1!="face" { rx += $3; tx += $11 } END { print rx+0, tx+0 }'"'"' /proc/net/dev 2>/dev/null; }
read cpu_total_1 cpu_idle_1 <<EOF
$(cpu_stat)
EOF
read net_rx_1 net_tx_1 <<EOF
$(net_stat)
EOF
sleep 1
read cpu_total_2 cpu_idle_2 <<EOF
$(cpu_stat)
EOF
read net_rx_2 net_tx_2 <<EOF
$(net_stat)
EOF
cpu_delta=$((cpu_total_2 - cpu_total_1))
idle_delta=$((cpu_idle_2 - cpu_idle_1))
cpu_percent=$(awk -v total="$cpu_delta" -v idle="$idle_delta" '"'"'BEGIN { if (total <= 0) print "0.00"; else printf "%.2f", ((total-idle)/total)*100 }'"'"')
mem_total=$(awk '"'"'/^MemTotal:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
mem_available=$(awk '"'"'/^MemAvailable:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
swap_total=$(awk '"'"'/^SwapTotal:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
swap_free=$(awk '"'"'/^SwapFree:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
echo "CPU_PERCENT=${cpu_percent:-0.00}"
echo "MEM_TOTAL=${mem_total:-0}"
echo "MEM_USED=$(( ${mem_total:-0} - ${mem_available:-0} ))"
echo "SWAP_TOTAL=${swap_total:-0}"
echo "SWAP_USED=$(( ${swap_total:-0} - ${swap_free:-0} ))"
echo "NET_RX=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))"
echo "NET_TX=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))"
df -kP 2>/dev/null | awk "NR > 1 { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 4
exit 0
fi
if [ "$os" = "Darwin" ]; then
net_stat() { netstat -ibn 2>/dev/null | awk '"'"'NR > 1 && $7 ~ /^[0-9]+$/ && $10 ~ /^[0-9]+$/ { rx += $7; tx += $10 } END { print rx+0, tx+0 }'"'"'; }
read net_rx_1 net_tx_1 <<EOF
$(net_stat)
EOF
sleep 1
read net_rx_2 net_tx_2 <<EOF
$(net_stat)
EOF
cpu_percent=$(top -l 2 -n 0 -s 1 2>/dev/null | awk -F"[:,% ]+" '"'"'/CPU usage:/ { user=$3; sys=$5 } END { if (user == "" && sys == "") print "0.00"; else printf "%.2f", user + sys }'"'"')
mem_total=$(sysctl -n hw.memsize 2>/dev/null || echo 0)
pagesize=$(sysctl -n hw.pagesize 2>/dev/null || echo 4096)
vm_output=$(vm_stat 2>/dev/null)
pages_active=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages active/ { gsub("\\.","",$3); print $3+0 }'"'"')
pages_wired=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages wired down/ { gsub("\\.","",$4); print $4+0 }'"'"')
pages_compressed=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages occupied by compressor/ { gsub("\\.","",$5); print $5+0 }'"'"')
pages_speculative=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages speculative/ { gsub("\\.","",$3); print $3+0 }'"'"')
mem_used=$(( (${pages_active:-0} + ${pages_wired:-0} + ${pages_compressed:-0} + ${pages_speculative:-0}) * ${pagesize:-4096} ))
swap_line=$(sysctl vm.swapusage 2>/dev/null || true)
swap_used=$(printf "%s\n" "$swap_line" | awk -F"[= ,]+" '"'"'
function mult(unit) { return unit=="K"?1024:(unit=="M"?1048576:(unit=="G"?1073741824:(unit=="T"?1099511627776:1))) }
/used/ { value=$4; unit=substr(value, length(value), 1); sub(/[A-Za-z]+$/, "", value); printf "%.0f", value * mult(unit) }'"'"')
swap_total=$(printf "%s\n" "$swap_line" | awk -F"[= ,]+" '"'"'
function mult(unit) { return unit=="K"?1024:(unit=="M"?1048576:(unit=="G"?1073741824:(unit=="T"?1099511627776:1))) }
/used/ && /free/ { used=$4; free=$8; unit1=substr(used, length(used), 1); unit2=substr(free, length(free), 1); sub(/[A-Za-z]+$/, "", used); sub(/[A-Za-z]+$/, "", free); printf "%.0f", (used * mult(unit1)) + (free * mult(unit2)) }'"'"')
echo "CPU_PERCENT=${cpu_percent:-0.00}"
echo "MEM_TOTAL=${mem_total:-0}"
echo "MEM_USED=${mem_used:-0}"
echo "SWAP_TOTAL=${swap_total:-0}"
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
exit 0
fi
echo "CPU_PERCENT=0.00"
echo "MEM_TOTAL=0"
echo "MEM_USED=0"
echo "SWAP_TOTAL=0"
echo "SWAP_USED=0"
echo "NET_RX=0"
echo "NET_TX=0"
'"#;
struct ClientHandler;
#[async_trait]
impl Handler for ClientHandler {
type Error = anyhow::Error;
async fn check_server_key(
&mut self,
_server_public_key: &russh::keys::ssh_key::PublicKey,
) -> Result<bool, Self::Error> {
Ok(true)
}
}
+577
View File
@@ -0,0 +1,577 @@
use anyhow::{Context, Result, anyhow};
use argon2::Argon2;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use chacha20poly1305::{
XChaCha20Poly1305, XNonce,
aead::{Aead, KeyInit},
};
use hmac::{Hmac, Mac};
use rand::{RngCore, rngs::OsRng};
use reqwest::{Client, StatusCode, header};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::session::config::Session;
const SYNC_FILE_NAME: &str = "ashell-sync.json";
const FORMAT_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncPayload {
pub schema_version: u32,
pub revision: String,
pub updated_at: String,
pub device_id: String,
pub sessions: Vec<Session>,
}
impl SyncPayload {
pub fn new(device_id: String, sessions: Vec<Session>) -> Self {
Self {
schema_version: FORMAT_VERSION,
revision: Uuid::new_v4().to_string(),
updated_at: chrono::Utc::now().to_rfc3339(),
device_id,
sessions,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct EncryptedEnvelope {
format_version: u32,
kdf: String,
cipher: String,
salt: String,
nonce: String,
payload: String,
}
#[derive(Clone)]
pub struct SyncCredentials {
pub backend: SyncBackendCredentials,
pub encryption_password: String,
}
#[derive(Clone)]
pub enum SyncBackendCredentials {
WebDav {
endpoint: String,
username: String,
password: String,
},
S3 {
endpoint: String,
region: String,
bucket: String,
object_key: String,
access_key: String,
secret_key: String,
session_token: String,
},
}
#[derive(Debug, Clone)]
pub enum SyncResult {
Uploaded {
etag: Option<String>,
},
Downloaded {
payload: SyncPayload,
etag: Option<String>,
},
Failed(String),
}
pub async fn upload(
credentials: SyncCredentials,
payload: SyncPayload,
expected_etag: Option<String>,
) -> Result<Option<String>> {
validate_credentials(&credentials)?;
let body = encrypt_payload(&payload, &credentials.encryption_password)?;
match credentials.backend {
SyncBackendCredentials::WebDav {
endpoint,
username,
password,
} => upload_webdav(&endpoint, &username, &password, body, expected_etag).await,
SyncBackendCredentials::S3 {
endpoint,
region,
bucket,
object_key,
access_key,
secret_key,
session_token,
} => {
let config = S3Config {
endpoint,
region,
bucket,
object_key,
access_key,
secret_key,
session_token,
};
upload_s3(&config, body, expected_etag).await
}
}
}
async fn upload_webdav(
endpoint: &str,
username: &str,
password: &str,
body: Vec<u8>,
expected_etag: Option<String>,
) -> Result<Option<String>> {
let client = Client::new();
let mut request = client
.put(sync_url(endpoint))
.basic_auth(username, Some(password))
.header(header::CONTENT_TYPE, "application/json")
.body(body);
request = if let Some(etag) = expected_etag {
request.header(header::IF_MATCH, etag)
} else {
// An uninitialized client may only create a new remote file. This keeps
// it from silently replacing configuration uploaded by another device.
request.header(header::IF_NONE_MATCH, "*")
};
let response = request.send().await.context("send WebDAV upload")?;
if response.status() == StatusCode::PRECONDITION_FAILED
|| response.status() == StatusCode::CONFLICT
{
return Err(anyhow!(
"remote configuration changed; download it before uploading"
));
}
if !response.status().is_success() {
return Err(anyhow!("WebDAV upload failed: HTTP {}", response.status()));
}
Ok(response
.headers()
.get(header::ETAG)
.and_then(|value| value.to_str().ok())
.map(str::to_string))
}
pub async fn download(credentials: SyncCredentials) -> Result<(SyncPayload, Option<String>)> {
validate_credentials(&credentials)?;
let encryption_password = credentials.encryption_password;
let (body, etag) = match credentials.backend {
SyncBackendCredentials::WebDav {
endpoint,
username,
password,
} => download_webdav(&endpoint, &username, &password).await?,
SyncBackendCredentials::S3 {
endpoint,
region,
bucket,
object_key,
access_key,
secret_key,
session_token,
} => {
let config = S3Config {
endpoint,
region,
bucket,
object_key,
access_key,
secret_key,
session_token,
};
download_s3(&config).await?
}
};
let payload = decrypt_payload(&body, &encryption_password)?;
Ok((payload, etag))
}
async fn download_webdav(
endpoint: &str,
username: &str,
password: &str,
) -> Result<(Vec<u8>, Option<String>)> {
let response = Client::new()
.get(sync_url(endpoint))
.basic_auth(username, Some(password))
.send()
.await
.context("send WebDAV download")?;
if response.status() == StatusCode::NOT_FOUND {
return Err(anyhow!("no remote configuration exists yet"));
}
if !response.status().is_success() {
return Err(anyhow!(
"WebDAV download failed: HTTP {}",
response.status()
));
}
let etag = response
.headers()
.get(header::ETAG)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let body = response
.bytes()
.await
.context("read WebDAV response")?
.to_vec();
Ok((body, etag))
}
fn validate_credentials(credentials: &SyncCredentials) -> Result<()> {
if credentials.encryption_password.len() < 8 {
return Err(anyhow!(
"encryption password must contain at least 8 characters"
));
}
match &credentials.backend {
SyncBackendCredentials::WebDav { endpoint, .. } if endpoint.trim().is_empty() => {
Err(anyhow!("WebDAV endpoint is required"))
}
SyncBackendCredentials::S3 {
region,
bucket,
access_key,
secret_key,
..
} if region.trim().is_empty()
|| bucket.trim().is_empty()
|| access_key.trim().is_empty()
|| secret_key.is_empty() =>
{
Err(anyhow!(
"S3 region, bucket, access key and secret key are required"
))
}
_ => Ok(()),
}
}
struct S3Config {
endpoint: String,
region: String,
bucket: String,
object_key: String,
access_key: String,
secret_key: String,
session_token: String,
}
async fn upload_s3(
config: &S3Config,
body: Vec<u8>,
expected_etag: Option<String>,
) -> Result<Option<String>> {
let url = s3_url(config)?;
let mut headers = signed_s3_headers("PUT", &url, &body, config)?;
headers.insert(
header::CONTENT_TYPE,
header::HeaderValue::from_static("application/json"),
);
if let Some(etag) = expected_etag {
headers.insert(header::IF_MATCH, header_value(&etag, "S3 ETag")?);
} else {
headers.insert(header::IF_NONE_MATCH, header::HeaderValue::from_static("*"));
}
let response = Client::new()
.put(url)
.headers(headers)
.body(body)
.send()
.await
.context("send S3 upload")?;
if response.status() == StatusCode::PRECONDITION_FAILED
|| response.status() == StatusCode::CONFLICT
{
return Err(anyhow!(
"remote configuration changed; download it before uploading"
));
}
if !response.status().is_success() {
let status = response.status();
let detail = response.text().await.unwrap_or_default();
return Err(anyhow!("S3 upload failed: HTTP {status}: {detail}"));
}
Ok(response
.headers()
.get(header::ETAG)
.and_then(|value| value.to_str().ok())
.map(str::to_string))
}
async fn download_s3(config: &S3Config) -> Result<(Vec<u8>, Option<String>)> {
let url = s3_url(config)?;
let headers = signed_s3_headers("GET", &url, &[], config)?;
let response = Client::new()
.get(url)
.headers(headers)
.send()
.await
.context("send S3 download")?;
if response.status() == StatusCode::NOT_FOUND {
return Err(anyhow!("no remote configuration exists yet"));
}
if !response.status().is_success() {
let status = response.status();
let detail = response.text().await.unwrap_or_default();
return Err(anyhow!("S3 download failed: HTTP {status}: {detail}"));
}
let etag = response
.headers()
.get(header::ETAG)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let body = response.bytes().await.context("read S3 response")?.to_vec();
Ok((body, etag))
}
fn s3_url(config: &S3Config) -> Result<reqwest::Url> {
let endpoint = if config.endpoint.trim().is_empty() {
format!("https://s3.{}.amazonaws.com", config.region.trim())
} else {
config.endpoint.trim().trim_end_matches('/').to_string()
};
let key = if config.object_key.trim().is_empty() {
SYNC_FILE_NAME
} else {
config.object_key.trim().trim_start_matches('/')
};
let url = format!(
"{}/{}/{}",
endpoint,
aws_uri_encode(config.bucket.trim(), true),
aws_uri_encode(key, false)
);
reqwest::Url::parse(&url).context("parse S3 object URL")
}
fn signed_s3_headers(
method: &str,
url: &reqwest::Url,
body: &[u8],
config: &S3Config,
) -> Result<header::HeaderMap> {
let now = chrono::Utc::now();
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
let date = now.format("%Y%m%d").to_string();
let host = url
.host_str()
.ok_or_else(|| anyhow!("S3 endpoint has no host"))?;
let host = match url.port() {
Some(port) => format!("{host}:{port}"),
None => host.to_string(),
};
let payload_hash = hex_sha256(body);
let mut canonical_headers =
format!("host:{host}\nx-amz-content-sha256:{payload_hash}\nx-amz-date:{amz_date}\n");
let mut signed_headers = "host;x-amz-content-sha256;x-amz-date".to_string();
if !config.session_token.is_empty() {
canonical_headers.push_str(&format!(
"x-amz-security-token:{}\n",
config.session_token.trim()
));
signed_headers.push_str(";x-amz-security-token");
}
let canonical_request = format!(
"{method}\n{}\n\n{canonical_headers}\n{signed_headers}\n{payload_hash}",
url.path()
);
let scope = format!("{date}/{}/s3/aws4_request", config.region.trim());
let string_to_sign = format!(
"AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{}",
hex_sha256(canonical_request.as_bytes())
);
let date_key = hmac_sha256(
format!("AWS4{}", config.secret_key).as_bytes(),
date.as_bytes(),
)?;
let region_key = hmac_sha256(&date_key, config.region.trim().as_bytes())?;
let service_key = hmac_sha256(&region_key, b"s3")?;
let signing_key = hmac_sha256(&service_key, b"aws4_request")?;
let signature = hex::encode(hmac_sha256(&signing_key, string_to_sign.as_bytes())?);
let authorization = format!(
"AWS4-HMAC-SHA256 Credential={}/{scope}, SignedHeaders={signed_headers}, Signature={signature}",
config.access_key.trim()
);
let mut headers = header::HeaderMap::new();
headers.insert("x-amz-date", header_value(&amz_date, "S3 date")?);
headers.insert(
"x-amz-content-sha256",
header_value(&payload_hash, "S3 payload hash")?,
);
headers.insert(
header::AUTHORIZATION,
header_value(&authorization, "S3 authorization")?,
);
if !config.session_token.is_empty() {
headers.insert(
"x-amz-security-token",
header_value(config.session_token.trim(), "S3 session token")?,
);
}
Ok(headers)
}
fn header_value(value: &str, name: &str) -> Result<header::HeaderValue> {
header::HeaderValue::from_str(value).with_context(|| format!("invalid {name}"))
}
fn hmac_sha256(key: &[u8], value: &[u8]) -> Result<Vec<u8>> {
let mut mac =
<Hmac<Sha256> as Mac>::new_from_slice(key).map_err(|_| anyhow!("initialize S3 signer"))?;
mac.update(value);
Ok(mac.finalize().into_bytes().to_vec())
}
fn hex_sha256(value: &[u8]) -> String {
hex::encode(Sha256::digest(value))
}
fn aws_uri_encode(value: &str, encode_slash: bool) -> String {
let mut encoded = String::new();
for byte in value.bytes() {
if byte.is_ascii_alphanumeric()
|| matches!(byte, b'-' | b'_' | b'.' | b'~')
|| (!encode_slash && byte == b'/')
{
encoded.push(byte as char);
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
fn sync_url(endpoint: &str) -> String {
let endpoint = endpoint.trim();
if endpoint.ends_with('/') {
format!("{endpoint}{SYNC_FILE_NAME}")
} else if endpoint.ends_with(".json") {
endpoint.to_string()
} else {
format!("{endpoint}/{SYNC_FILE_NAME}")
}
}
fn encrypt_payload(payload: &SyncPayload, password: &str) -> Result<Vec<u8>> {
let mut salt = [0u8; 16];
let mut nonce = [0u8; 24];
OsRng.fill_bytes(&mut salt);
OsRng.fill_bytes(&mut nonce);
let key = derive_key(password, &salt)?;
let plaintext = serde_json::to_vec(payload).context("serialize sync payload")?;
let ciphertext = XChaCha20Poly1305::new((&key).into())
.encrypt(XNonce::from_slice(&nonce), plaintext.as_ref())
.map_err(|_| anyhow!("encrypt sync payload"))?;
serde_json::to_vec_pretty(&EncryptedEnvelope {
format_version: FORMAT_VERSION,
kdf: "argon2id".to_string(),
cipher: "xchacha20poly1305".to_string(),
salt: STANDARD.encode(salt),
nonce: STANDARD.encode(nonce),
payload: STANDARD.encode(ciphertext),
})
.context("serialize encrypted sync envelope")
}
fn decrypt_payload(raw: &[u8], password: &str) -> Result<SyncPayload> {
let envelope: EncryptedEnvelope =
serde_json::from_slice(raw).context("parse encrypted sync envelope")?;
if envelope.format_version != FORMAT_VERSION
|| envelope.kdf != "argon2id"
|| envelope.cipher != "xchacha20poly1305"
{
return Err(anyhow!("unsupported remote sync format"));
}
let salt = STANDARD.decode(envelope.salt).context("decode sync salt")?;
let nonce = STANDARD
.decode(envelope.nonce)
.context("decode sync nonce")?;
if nonce.len() != 24 {
return Err(anyhow!("invalid sync nonce"));
}
let ciphertext = STANDARD
.decode(envelope.payload)
.context("decode encrypted sync payload")?;
let key = derive_key(password, &salt)?;
let plaintext = XChaCha20Poly1305::new((&key).into())
.decrypt(XNonce::from_slice(&nonce), ciphertext.as_ref())
.map_err(|_| anyhow!("cannot decrypt remote configuration; check the password"))?;
let payload: SyncPayload =
serde_json::from_slice(&plaintext).context("parse decrypted sync payload")?;
if payload.schema_version != FORMAT_VERSION {
return Err(anyhow!("unsupported synchronized configuration version"));
}
Ok(payload)
}
fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; 32]> {
let mut key = [0u8; 32];
Argon2::default()
.hash_password_into(password.as_bytes(), salt, &mut key)
.map_err(|err| anyhow!("derive encryption key: {err}"))?;
Ok(key)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encrypted_payload_round_trip() {
let payload = SyncPayload::new("test-device".into(), Vec::new());
let encrypted = encrypt_payload(&payload, "correct horse battery staple").unwrap();
assert!(!String::from_utf8_lossy(&encrypted).contains("test-device"));
let decrypted = decrypt_payload(&encrypted, "correct horse battery staple").unwrap();
assert_eq!(decrypted.revision, payload.revision);
}
#[test]
fn wrong_password_is_rejected() {
let payload = SyncPayload::new("test-device".into(), Vec::new());
let encrypted = encrypt_payload(&payload, "correct horse battery staple").unwrap();
assert!(decrypt_payload(&encrypted, "incorrect password").is_err());
}
#[test]
fn endpoint_can_be_a_collection_or_file() {
assert_eq!(
sync_url("https://example.test/dav/"),
"https://example.test/dav/ashell-sync.json"
);
assert_eq!(
sync_url("https://example.test/config.json"),
"https://example.test/config.json"
);
}
#[test]
fn s3_url_uses_path_style_and_encodes_object_key() {
let config = S3Config {
endpoint: "https://s3.example.test".into(),
region: "us-east-1".into(),
bucket: "my-bucket".into(),
object_key: "configs/my file.json".into(),
access_key: "access".into(),
secret_key: "secret".into(),
session_token: String::new(),
};
assert_eq!(
s3_url(&config).unwrap().as_str(),
"https://s3.example.test/my-bucket/configs/my%20file.json"
);
}
#[test]
fn aws_uri_encoding_preserves_only_object_key_slashes() {
assert_eq!(aws_uri_encode("a b/c", false), "a%20b/c");
assert_eq!(aws_uri_encode("a/b", true), "a%2Fb");
}
}
-195
View File
@@ -1,195 +0,0 @@
use std::{
collections::BTreeMap,
time::{Duration, Instant},
};
use anyhow::{Result, anyhow};
use sysinfo::{Disks, Networks, System};
#[derive(Debug, Clone, Default)]
pub struct DiskSample {
pub mount: String,
pub available_bytes: u64,
pub total_bytes: u64,
}
#[derive(Debug, Clone, Default)]
pub struct SystemSnapshot {
pub cpu_percent: f32,
pub mem_percent: f32,
pub swap_percent: f32,
pub mem_detail: String,
pub swap_detail: String,
pub net_rx: String,
pub net_tx: String,
pub disks: Vec<DiskSample>,
}
pub struct SystemSampler {
sys: System,
nets: Networks,
disks: Disks,
last_rx_total: u64,
last_tx_total: u64,
last_instant: Instant,
}
impl SystemSampler {
pub fn new() -> Self {
let mut sys = System::new_all();
sys.refresh_all();
let nets = Networks::new_with_refreshed_list();
let disks = Disks::new_with_refreshed_list();
let last_rx_total = nets.iter().map(|(_, d)| d.total_received()).sum();
let last_tx_total = nets.iter().map(|(_, d)| d.total_transmitted()).sum();
Self {
sys,
nets,
disks,
last_rx_total,
last_tx_total,
last_instant: Instant::now(),
}
}
pub fn interval() -> Duration {
Duration::from_millis(1000)
}
pub fn sample(&mut self) -> SystemSnapshot {
self.sys.refresh_cpu_usage();
self.sys.refresh_memory();
self.nets.refresh(true);
self.disks.refresh(true);
let cpu_percent = self.sys.global_cpu_usage() / 100.0;
let mem_total = self.sys.total_memory();
let mem_used = self.sys.used_memory();
let swap_total = self.sys.total_swap();
let swap_used = self.sys.used_swap();
let rx_total: u64 = self.nets.iter().map(|(_, d)| d.total_received()).sum();
let tx_total: u64 = self.nets.iter().map(|(_, d)| d.total_transmitted()).sum();
let now = Instant::now();
let elapsed = now
.duration_since(self.last_instant)
.as_secs_f64()
.max(0.001);
let rx_rate = (rx_total.saturating_sub(self.last_rx_total) as f64 / elapsed) as u64;
let tx_rate = (tx_total.saturating_sub(self.last_tx_total) as f64 / elapsed) as u64;
self.last_rx_total = rx_total;
self.last_tx_total = tx_total;
self.last_instant = now;
let disks = self
.disks
.iter()
.filter(|disk| disk.total_space() > 0)
.take(4)
.map(|disk| DiskSample {
mount: disk.mount_point().to_string_lossy().to_string(),
available_bytes: disk.available_space(),
total_bytes: disk.total_space(),
})
.collect();
SystemSnapshot {
cpu_percent,
mem_percent: ratio(mem_used, mem_total),
swap_percent: ratio(swap_used, swap_total),
mem_detail: format!("{}/{}", format_bytes(mem_used), format_bytes(mem_total)),
swap_detail: format!("{}/{}", format_bytes(swap_used), format_bytes(swap_total)),
net_rx: format!("{}/s", format_bytes(rx_rate)),
net_tx: format!("{}/s", format_bytes(tx_rate)),
disks,
}
}
}
fn ratio(used: u64, total: u64) -> f32 {
if total == 0 {
0.0
} else {
(used as f32 / total as f32).clamp(0.0, 1.0)
}
}
pub fn format_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} {}", UNITS[unit])
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
let mut kv = BTreeMap::new();
let mut disks = Vec::new();
for line in raw.lines().map(str::trim).filter(|line| !line.is_empty()) {
if let Some(rest) = line.strip_prefix("DISK=") {
let mut parts = rest.split('\t');
let mount = parts.next().unwrap_or_default().to_string();
let available_bytes = parts
.next()
.unwrap_or("0")
.parse::<u64>()
.unwrap_or_default();
let total_bytes = parts
.next()
.unwrap_or("0")
.parse::<u64>()
.unwrap_or_default();
disks.push(DiskSample {
mount,
available_bytes,
total_bytes,
});
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
kv.insert(key.to_string(), value.to_string());
}
let cpu_percent = kv
.get("CPU_PERCENT")
.ok_or_else(|| anyhow!("missing CPU_PERCENT"))?
.parse::<f32>()
.unwrap_or_default()
/ 100.0;
let mem_used = parse_u64(&kv, "MEM_USED");
let mem_total = parse_u64(&kv, "MEM_TOTAL");
let swap_used = parse_u64(&kv, "SWAP_USED");
let swap_total = parse_u64(&kv, "SWAP_TOTAL");
let rx_rate = parse_u64(&kv, "NET_RX");
let tx_rate = parse_u64(&kv, "NET_TX");
Ok(SystemSnapshot {
cpu_percent: cpu_percent.clamp(0.0, 1.0),
mem_percent: ratio(mem_used, mem_total),
swap_percent: ratio(swap_used, swap_total),
mem_detail: format!("{}/{}", format_bytes(mem_used), format_bytes(mem_total)),
swap_detail: format!("{}/{}", format_bytes(swap_used), format_bytes(swap_total)),
net_rx: format!("{}/s", format_bytes(rx_rate)),
net_tx: format!("{}/s", format_bytes(tx_rate)),
disks,
})
}
fn parse_u64(kv: &BTreeMap<String, String>, key: &str) -> u64 {
kv.get(key)
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or_default()
}
+416
View File
@@ -0,0 +1,416 @@
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,
pub available_bytes: u64,
pub total_bytes: u64,
}
#[derive(Debug, Clone, Default)]
pub struct RemoteProcess {
pub pid: u32,
pub user: String,
pub cpu_percent: f32,
pub memory_bytes: u64,
pub command: String,
}
#[derive(Debug, Clone, Default)]
pub struct RemotePort {
pub protocol: String,
pub address: String,
pub port: u16,
pub state: String,
pub pid: Option<u32>,
pub process: String,
}
#[derive(Debug, Clone, Default)]
pub struct SystemSnapshot {
pub cpu_percent: f32,
pub mem_percent: f32,
pub swap_percent: f32,
pub mem_detail: String,
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 {
sys: System,
nets: Networks,
disks: Disks,
last_rx_total: u64,
last_tx_total: u64,
last_instant: Instant,
}
impl SystemSampler {
pub fn new() -> Self {
let mut sys = System::new_all();
sys.refresh_all();
let nets = Networks::new_with_refreshed_list();
let disks = Disks::new_with_refreshed_list();
let last_rx_total = nets.values().map(|d| d.total_received()).sum();
let last_tx_total = nets.values().map(|d| d.total_transmitted()).sum();
Self {
sys,
nets,
disks,
last_rx_total,
last_tx_total,
last_instant: Instant::now(),
}
}
pub fn interval() -> Duration {
Duration::from_millis(1000)
}
pub fn sample(&mut self) -> SystemSnapshot {
self.sys.refresh_cpu_usage();
self.sys.refresh_memory();
self.nets.refresh(true);
self.disks.refresh(true);
let cpu_percent = self.sys.global_cpu_usage() / 100.0;
let mem_total = self.sys.total_memory();
let mem_used = self.sys.used_memory();
let swap_total = self.sys.total_swap();
let swap_used = self.sys.used_swap();
let rx_total: u64 = self.nets.values().map(|d| d.total_received()).sum();
let tx_total: u64 = self.nets.values().map(|d| d.total_transmitted()).sum();
let now = Instant::now();
let elapsed = now
.duration_since(self.last_instant)
.as_secs_f64()
.max(0.001);
let rx_rate = (rx_total.saturating_sub(self.last_rx_total) as f64 / elapsed) as u64;
let tx_rate = (tx_total.saturating_sub(self.last_tx_total) as f64 / elapsed) as u64;
self.last_rx_total = rx_total;
self.last_tx_total = tx_total;
self.last_instant = now;
let mut disks: Vec<DiskSample> = self
.disks
.iter()
.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();
sort_disks(&mut disks);
SystemSnapshot {
cpu_percent,
mem_percent: ratio(mem_used, mem_total),
swap_percent: ratio(swap_used, swap_total),
mem_detail: format_usage(mem_used, mem_total),
swap_detail: format_usage(swap_used, swap_total),
net_rx: format_rate(rx_rate),
net_tx: format_rate(tx_rate),
net_rx_rate: rx_rate,
net_tx_rate: tx_rate,
disks,
total_swap: swap_total,
}
}
}
fn ratio(used: u64, total: u64) -> f32 {
if total == 0 {
0.0
} else {
(used as f32 / total as f32).clamp(0.0, 1.0)
}
}
pub fn format_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} {}", UNITS[unit])
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
fn format_usage(used: u64, total: u64) -> String {
format!("{}/{}", format_bytes(used), format_bytes(total))
}
fn format_rate(bytes: u64) -> String {
format!("{}/s", format_bytes(bytes))
}
fn sort_disks(disks: &mut [DiskSample]) {
disks.sort_by(|a, b| {
(a.mount != "/")
.cmp(&(b.mount != "/"))
.then_with(|| a.mount.cmp(&b.mount))
});
}
pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
let mut kv = BTreeMap::new();
let mut disks = Vec::new();
for line in raw.lines().map(str::trim).filter(|line| !line.is_empty()) {
if let Some(rest) = line.strip_prefix("DISK=") {
let mut parts = rest.split('\t');
let mount = parts.next().unwrap_or_default().to_string();
let available_bytes = parts
.next()
.unwrap_or("0")
.parse::<u64>()
.unwrap_or_default();
let total_bytes = parts
.next()
.unwrap_or("0")
.parse::<u64>()
.unwrap_or_default();
disks.push(DiskSample {
mount,
available_bytes: available_bytes.min(total_bytes),
total_bytes,
});
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
kv.insert(key.to_string(), value.to_string());
}
let cpu_percent = kv
.get("CPU_PERCENT")
.ok_or_else(|| anyhow!("missing CPU_PERCENT"))?
.parse::<f32>()
.ok()
.filter(|value| value.is_finite())
.unwrap_or_default()
/ 100.0;
let mem_total = parse_u64(&kv, "MEM_TOTAL");
let swap_total = parse_u64(&kv, "SWAP_TOTAL");
let mem_used = parse_u64(&kv, "MEM_USED").min(mem_total);
let swap_used = parse_u64(&kv, "SWAP_USED").min(swap_total);
let rx_rate = parse_u64(&kv, "NET_RX");
let tx_rate = parse_u64(&kv, "NET_TX");
// 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);
sort_disks(&mut disks);
Ok(SystemSnapshot {
cpu_percent: cpu_percent.clamp(0.0, 1.0),
mem_percent: ratio(mem_used, mem_total),
swap_percent: ratio(swap_used, swap_total),
mem_detail: format_usage(mem_used, mem_total),
swap_detail: format_usage(swap_used, swap_total),
net_rx: format_rate(rx_rate),
net_tx: format_rate(tx_rate),
net_rx_rate: rx_rate,
net_tx_rate: tx_rate,
disks,
total_swap: swap_total,
})
}
pub fn remote_processes_from_ps(raw: &str) -> Vec<RemoteProcess> {
raw.lines()
.filter_map(|line| {
if let Some(record) = line.strip_prefix("PROCESS\t") {
let mut fields = record.splitn(5, '\t');
let pid = fields.next()?.parse::<u32>().ok()?;
let user = fields.next()?.to_string();
let cpu_percent = fields
.next()?
.parse::<f32>()
.ok()
.filter(|value| value.is_finite())
.map(|value| value.max(0.0))?;
let memory_bytes = fields.next()?.parse::<u64>().ok()?;
let command = fields.next()?.trim().to_string();
return (!command.is_empty()).then_some(RemoteProcess {
pid,
user,
cpu_percent,
memory_bytes,
command,
});
}
let mut fields = line.split_whitespace();
let pid = fields.next()?.parse::<u32>().ok()?;
let user = fields.next()?.to_string();
let cpu_percent = fields
.next()?
.parse::<f32>()
.ok()
.filter(|value| value.is_finite())
.map(|value| value.max(0.0))?;
let memory_bytes = fields.next()?.parse::<u64>().ok()?.saturating_mul(1024);
let command = fields.collect::<Vec<_>>().join(" ");
if command.is_empty() {
return None;
}
Some(RemoteProcess {
pid,
user,
cpu_percent,
memory_bytes,
command,
})
})
.collect()
}
pub fn remote_ports_from_probe(raw: &str) -> Vec<RemotePort> {
raw.lines()
.filter_map(|line| {
let record = line.strip_prefix("PORT\t")?;
let mut fields = record.splitn(6, '\t');
let protocol = fields.next()?.trim().to_string();
let address = fields.next()?.trim().to_string();
let port = fields.next()?.parse::<u16>().ok()?;
let state = fields.next()?.trim().to_string();
let pid = match fields.next()?.trim() {
"" | "-" | "0" => None,
value => value.parse::<u32>().ok(),
};
let process = fields.next()?.trim().to_string();
if protocol.is_empty() || address.is_empty() || state.is_empty() {
return None;
}
Some(RemotePort {
protocol,
address,
port,
state,
pid,
process: if process.is_empty() {
"-".to_string()
} else {
process
},
})
})
.collect()
}
fn parse_u64(kv: &BTreeMap<String, String>, key: &str) -> u64 {
kv.get(key)
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::{remote_ports_from_probe, remote_processes_from_ps, remote_snapshot_from_kv};
#[test]
fn parses_remote_process_rows_and_rss_bytes() {
let processes = remote_processes_from_ps(
" 42 alice 12.5 2048 worker --queue main\n 7 root 0.0 512 sshd\n",
);
assert_eq!(processes.len(), 2);
assert_eq!(processes[0].pid, 42);
assert_eq!(processes[0].user, "alice");
assert_eq!(processes[0].cpu_percent, 12.5);
assert_eq!(processes[0].memory_bytes, 2 * 1024 * 1024);
assert_eq!(processes[0].command, "worker --queue main");
}
#[test]
fn parses_structured_current_process_rows() {
let processes =
remote_processes_from_ps("PROCESS\t42\talice\t37.25\t2097152\tworker --queue main\n");
assert_eq!(processes.len(), 1);
assert_eq!(processes[0].pid, 42);
assert_eq!(processes[0].user, "alice");
assert_eq!(processes[0].cpu_percent, 37.25);
assert_eq!(processes[0].memory_bytes, 2 * 1024 * 1024);
assert_eq!(processes[0].command, "worker --queue main");
}
#[test]
fn skips_malformed_remote_process_rows() {
let processes = remote_processes_from_ps("PID USER CPU RSS COMMAND\n9 root bad 64 init\n");
assert!(processes.is_empty());
}
#[test]
fn clamps_remote_resource_values_to_valid_ranges() {
let snapshot = remote_snapshot_from_kv(
"CPU_PERCENT=250\nMEM_TOTAL=100\nMEM_USED=150\nSWAP_TOTAL=0\nSWAP_USED=10\nNET_RX=12\nNET_TX=34\nDISK=/data\t1048576\t2097152\nDISK=/\t3145728\t2097152\n",
)
.unwrap();
assert_eq!(snapshot.cpu_percent, 1.0);
assert_eq!(snapshot.mem_percent, 1.0);
assert_eq!(snapshot.swap_percent, 0.0);
assert_eq!(snapshot.disks[0].mount, "/");
assert_eq!(snapshot.disks[0].available_bytes, 2097152);
}
#[test]
fn parses_structured_remote_port_rows() {
let ports = remote_ports_from_probe(
"PORT\ttcp\t0.0.0.0\t22\tLISTEN\t123\tsshd\nPORT\tudp\t127.0.0.1\t5353\tUNCONN\t-\t-\n",
);
assert_eq!(ports.len(), 2);
assert_eq!(ports[0].protocol, "tcp");
assert_eq!(ports[0].address, "0.0.0.0");
assert_eq!(ports[0].port, 22);
assert_eq!(ports[0].state, "LISTEN");
assert_eq!(ports[0].pid, Some(123));
assert_eq!(ports[0].process, "sshd");
assert_eq!(ports[1].pid, None);
}
#[test]
fn skips_malformed_remote_port_rows() {
let ports = remote_ports_from_probe(
"PORT\ttcp\t0.0.0.0\tbad\tLISTEN\t1\tsshd\nPORT\ttcp\t\t80\tLISTEN\t1\tnginx\n",
);
assert!(ports.is_empty());
}
}
-704
View File
@@ -1,704 +0,0 @@
use std::sync::mpsc::Sender;
use alacritty_terminal::{
event::{Event, EventListener},
grid::{Dimensions, Scroll},
index::{Column, Point, Side},
selection::{Selection, SelectionRange, SelectionType},
term::{Config, Term, TermMode, cell::Cell, point_to_viewport, viewport_to_point},
vte::ansi::{CursorShape, Processor},
};
use gpui::Keystroke;
use crate::config::Session;
use crate::sftp::{PreviewData, RemoteEntry};
use crate::system::SystemSnapshot;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TabKind {
Local,
Ssh,
}
#[derive(Debug)]
pub enum BackendCommand {
Input(Vec<u8>),
Resize { cols: u16, rows: u16 },
Close,
}
#[derive(Debug, Clone)]
pub enum BackendEvent {
Output {
tab_id: String,
bytes: Vec<u8>,
},
Status {
tab_id: String,
text: String,
},
Connected {
tab_id: String,
},
SftpEntries {
tab_id: String,
path: String,
entries: Vec<RemoteEntry>,
},
SftpPreview {
tab_id: String,
preview: PreviewData,
},
SftpStatus {
tab_id: String,
text: String,
},
RemoteSystem {
tab_id: String,
snapshot: SystemSnapshot,
},
RemoteSystemUnavailable {
tab_id: String,
reason: String,
},
SftpHome {
tab_id: String,
home: String,
},
TransferProgress {
#[allow(dead_code)]
tab_id: String,
id: String,
transferred: u64,
total: Option<u64>,
state: TransferState,
},
TransferStarted {
tab_id: String,
info: TransferInfo,
},
Closed {
tab_id: String,
reason: String,
},
TerminalTitleChanged {
tab_id: String,
title: String,
},
}
#[derive(Clone)]
pub enum BackendTx {
Local(Sender<BackendCommand>),
Ssh(tokio::sync::mpsc::UnboundedSender<BackendCommand>),
}
impl BackendTx {
pub fn send(&self, command: BackendCommand) {
match self {
Self::Local(tx) => {
let _ = tx.send(command);
}
Self::Ssh(tx) => {
let _ = tx.send(command);
}
}
}
}
pub struct TerminalTab {
pub id: String,
pub title: String,
pub kind: TabKind,
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,
}
#[derive(Clone, Copy)]
pub struct CursorState {
pub row: usize,
pub col: usize,
pub shape: CursorShape,
}
#[derive(Clone)]
pub struct RenderCell {
pub row: i32,
pub col: i32,
pub cell: Cell,
}
#[derive(Clone)]
pub struct RenderSnapshot {
pub cells: Vec<RenderCell>,
pub cursor: Option<CursorState>,
pub selection: Option<ViewportSelection>,
pub display_offset: usize,
pub history_size: usize,
pub rows: usize,
pub cols: usize,
}
#[derive(Clone, Copy)]
pub struct ViewportSelection {
pub start_row: usize,
pub start_col: usize,
pub end_row: usize,
pub end_col: usize,
pub is_block: bool,
}
#[derive(Clone, Default)]
pub struct SftpUiState {
pub current_path: String,
pub status: String,
pub entries: Vec<RemoteEntry>,
pub selected_path: Option<String>,
pub preview: Option<PreviewData>,
pub selected_entries: std::collections::HashSet<String>,
pub home_dir: String,
}
impl TerminalTab {
pub fn new_local(
id: String,
title: String,
backend: BackendTx,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Self {
Self::new(
id,
title,
TabKind::Local,
"local shell".into(),
backend,
events,
)
}
pub fn new_ssh(
id: String,
session: &Session,
backend: BackendTx,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Self {
let mut tab = Self::new(
id,
session.name.clone(),
TabKind::Ssh,
format!(
"connecting {}@{}:{}",
session.user, session.host, session.port
),
backend,
events,
);
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(),
home_dir: "/".into(),
});
tab
}
fn new(
id: String,
title: String,
kind: TabKind,
status: String,
backend: BackendTx,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Self {
Self {
id: id.clone(),
title,
kind,
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,
}
}
pub fn feed(&mut self, bytes: &[u8]) {
self.processor.advance(&mut self.term, bytes);
}
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 });
}
pub fn cursor_state(&self) -> Option<CursorState> {
let content = self.term.renderable_content();
if matches!(content.cursor.shape, CursorShape::Hidden) || content.display_offset > 0 {
return None;
}
let row = content.cursor.point.line.0;
if row < 0 {
return None;
}
let row = row as usize;
if row >= self.rows as usize {
return None;
}
Some(CursorState {
row,
col: content.cursor.point.column.0,
shape: content.cursor.shape,
})
}
pub fn app_cursor_mode(&self) -> bool {
self.term.mode().contains(TermMode::APP_CURSOR)
}
pub fn render_snapshot(&self) -> RenderSnapshot {
let rows = self.rows;
let cols = self.cols;
let content = self.term.renderable_content();
let display_offset = content.display_offset as i32;
let mut cells = Vec::with_capacity((rows as usize) * (cols as usize));
for indexed in content.display_iter {
let line = indexed.point.line.0;
let row = line + display_offset;
if row < 0 {
continue;
}
if row >= rows as i32 {
continue;
}
let col = indexed.point.column.0 as i32;
if col >= cols as i32 {
continue;
}
cells.push(RenderCell {
row,
col,
cell: indexed.cell.clone(),
});
}
RenderSnapshot {
cells,
cursor: self.cursor_state(),
selection: viewport_selection_from_range(content.display_offset, &content.selection),
display_offset: content.display_offset,
history_size: self.term.grid().history_size(),
rows: self.rows as usize,
cols: self.cols as usize,
}
}
pub fn scroll_history(&mut self, delta: i32) {
if delta != 0 {
self.term.scroll_display(Scroll::Delta(delta));
}
}
pub fn scroll_up_by(&mut self, lines: usize) {
if lines != 0 {
self.term.scroll_display(Scroll::Delta(lines as i32));
}
}
pub fn scroll_down_by(&mut self, lines: usize) {
if lines != 0 {
self.term.scroll_display(Scroll::Delta(-(lines as i32)));
}
}
pub fn scroll_to_bottom(&mut self) {
self.term.scroll_display(Scroll::Bottom);
}
#[allow(dead_code)]
pub fn has_selection(&self) -> bool {
self.term
.selection_to_string()
.is_some_and(|text| !text.is_empty())
}
pub fn clear_selection(&mut self) {
self.term.selection = None;
}
pub fn selection_text(&self) -> Option<String> {
self.term
.selection_to_string()
.filter(|text| !text.is_empty())
}
pub fn begin_selection(
&mut self,
row: usize,
col: usize,
side: Side,
selection_type: SelectionType,
) {
let point = viewport_to_point(
self.term.grid().display_offset(),
Point::new(row, Column(col)),
);
self.term.selection = Some(Selection::new(selection_type, point, side));
}
pub fn update_selection(&mut self, row: usize, col: usize, side: Side) {
let point = viewport_to_point(
self.term.grid().display_offset(),
Point::new(row, Column(col)),
);
if let Some(selection) = self.term.selection.as_mut() {
selection.update(point, side);
}
}
pub fn paste_text(&mut self, text: &str) {
let paste_text = text
.replace('\x1b', "")
.replace("\r\n", "\r")
.replace('\n', "\r");
self.backend
.send(BackendCommand::Input(paste_text.into_bytes()));
}
}
fn viewport_selection_from_range(
display_offset: usize,
selection: &Option<SelectionRange>,
) -> Option<ViewportSelection> {
let SelectionRange {
start,
end,
is_block,
} = selection.as_ref().copied()?;
let start = point_to_viewport(display_offset, start)?;
let end = point_to_viewport(display_offset, end)?;
Some(ViewportSelection {
start_row: start.line,
start_col: start.column.0,
end_row: end.line,
end_col: end.column.0,
is_block,
})
}
#[derive(Clone)]
struct TerminalListener {
tab_id: String,
backend: BackendTx,
events: std::sync::mpsc::Sender<BackendEvent>,
}
impl EventListener for TerminalListener {
fn send_event(&self, event: Event) {
match event {
Event::PtyWrite(output) => self
.backend
.send(BackendCommand::Input(output.into_bytes())),
Event::TextAreaSizeRequest(format) => {
let size = alacritty_terminal::event::WindowSize {
num_lines: 30,
num_cols: 100,
cell_width: 8,
cell_height: 16,
};
self.backend
.send(BackendCommand::Input(format(size).into_bytes()));
}
Event::Title(title) => {
let _ = self.events.send(BackendEvent::TerminalTitleChanged {
tab_id: self.tab_id.clone(),
title,
});
}
_ => {}
}
}
}
use rust_i18n::t;
fn new_term(
cols: u16,
rows: u16,
backend: BackendTx,
tab_id: String,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Term<TerminalListener> {
Term::new(
Config {
scrolling_history: 2000,
..Config::default()
},
&TerminalSize::new(cols, rows),
TerminalListener {
tab_id,
backend,
events,
},
)
}
struct TerminalSize {
cols: usize,
rows: usize,
}
impl TerminalSize {
fn new(cols: u16, rows: u16) -> Self {
Self {
cols: cols.max(1) as usize,
rows: rows.max(1) as usize,
}
}
}
impl Dimensions for TerminalSize {
fn total_lines(&self) -> usize {
self.rows
}
fn screen_lines(&self) -> usize {
self.rows
}
fn columns(&self) -> usize {
self.cols
}
}
pub fn encode_key(
keystroke: &Keystroke,
app_cursor_mode: bool,
option_as_meta: bool,
) -> Option<Vec<u8>> {
zed_like_to_esc_str(keystroke, app_cursor_mode, option_as_meta)
.map(|text| text.into_owned().into_bytes())
}
#[derive(Debug, PartialEq, Eq)]
enum TerminalModifiers {
None,
Alt,
Ctrl,
Shift,
CtrlShift,
Other,
}
impl TerminalModifiers {
fn new(ks: &Keystroke) -> Self {
match (
ks.modifiers.alt,
ks.modifiers.control,
ks.modifiers.shift,
ks.modifiers.platform,
) {
(false, false, false, false) => Self::None,
(true, false, false, false) => Self::Alt,
(false, true, false, false) => Self::Ctrl,
(false, false, true, false) => Self::Shift,
(false, true, true, false) => Self::CtrlShift,
_ => Self::Other,
}
}
fn any(&self) -> bool {
!matches!(self, Self::None)
}
}
fn zed_like_to_esc_str(
keystroke: &Keystroke,
app_cursor_mode: bool,
option_as_meta: bool,
) -> Option<std::borrow::Cow<'static, str>> {
let modifiers = TerminalModifiers::new(keystroke);
let key = keystroke.key.to_ascii_lowercase();
let manual_esc_str = match (key.as_str(), &modifiers) {
("tab", TerminalModifiers::None) => Some("\x09"),
("tab", TerminalModifiers::Shift) => Some("\x1b[Z"),
("escape", TerminalModifiers::None) => Some("\x1b"),
("enter", TerminalModifiers::None) => Some("\x0d"),
("enter", TerminalModifiers::Shift) => Some("\x0a"),
("enter", TerminalModifiers::Alt) => Some("\x1b\x0d"),
("backspace", TerminalModifiers::None) => Some("\x7f"),
("backspace", TerminalModifiers::Ctrl) => Some("\x08"),
("backspace", TerminalModifiers::Alt) => Some("\x1b\x7f"),
("backspace", TerminalModifiers::Shift) => Some("\x7f"),
("space", TerminalModifiers::Ctrl) => Some("\x00"),
("home", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOH"),
("home", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[H"),
("end", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOF"),
("end", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[F"),
("up", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOA"),
("up", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[A"),
("down", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOB"),
("down", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[B"),
("right", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOC"),
("right", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[C"),
("left", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOD"),
("left", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[D"),
("insert", TerminalModifiers::None) => Some("\x1b[2~"),
("delete", TerminalModifiers::None) => Some("\x1b[3~"),
("pageup", TerminalModifiers::None) => Some("\x1b[5~"),
("pagedown", TerminalModifiers::None) => Some("\x1b[6~"),
("a", TerminalModifiers::Ctrl) | ("A", TerminalModifiers::CtrlShift) => Some("\x01"),
("b", TerminalModifiers::Ctrl) | ("B", TerminalModifiers::CtrlShift) => Some("\x02"),
("c", TerminalModifiers::Ctrl) | ("C", TerminalModifiers::CtrlShift) => Some("\x03"),
("d", TerminalModifiers::Ctrl) | ("D", TerminalModifiers::CtrlShift) => Some("\x04"),
("e", TerminalModifiers::Ctrl) | ("E", TerminalModifiers::CtrlShift) => Some("\x05"),
("f", TerminalModifiers::Ctrl) | ("F", TerminalModifiers::CtrlShift) => Some("\x06"),
("g", TerminalModifiers::Ctrl) | ("G", TerminalModifiers::CtrlShift) => Some("\x07"),
("h", TerminalModifiers::Ctrl) | ("H", TerminalModifiers::CtrlShift) => Some("\x08"),
("i", TerminalModifiers::Ctrl) | ("I", TerminalModifiers::CtrlShift) => Some("\x09"),
("j", TerminalModifiers::Ctrl) | ("J", TerminalModifiers::CtrlShift) => Some("\x0a"),
("k", TerminalModifiers::Ctrl) | ("K", TerminalModifiers::CtrlShift) => Some("\x0b"),
("l", TerminalModifiers::Ctrl) | ("L", TerminalModifiers::CtrlShift) => Some("\x0c"),
("m", TerminalModifiers::Ctrl) | ("M", TerminalModifiers::CtrlShift) => Some("\x0d"),
("n", TerminalModifiers::Ctrl) | ("N", TerminalModifiers::CtrlShift) => Some("\x0e"),
("o", TerminalModifiers::Ctrl) | ("O", TerminalModifiers::CtrlShift) => Some("\x0f"),
("p", TerminalModifiers::Ctrl) | ("P", TerminalModifiers::CtrlShift) => Some("\x10"),
("q", TerminalModifiers::Ctrl) | ("Q", TerminalModifiers::CtrlShift) => Some("\x11"),
("r", TerminalModifiers::Ctrl) | ("R", TerminalModifiers::CtrlShift) => Some("\x12"),
("s", TerminalModifiers::Ctrl) | ("S", TerminalModifiers::CtrlShift) => Some("\x13"),
("t", TerminalModifiers::Ctrl) | ("T", TerminalModifiers::CtrlShift) => Some("\x14"),
("u", TerminalModifiers::Ctrl) | ("U", TerminalModifiers::CtrlShift) => Some("\x15"),
("v", TerminalModifiers::Ctrl) | ("V", TerminalModifiers::CtrlShift) => Some("\x16"),
("w", TerminalModifiers::Ctrl) | ("W", TerminalModifiers::CtrlShift) => Some("\x17"),
("x", TerminalModifiers::Ctrl) | ("X", TerminalModifiers::CtrlShift) => Some("\x18"),
("y", TerminalModifiers::Ctrl) | ("Y", TerminalModifiers::CtrlShift) => Some("\x19"),
("z", TerminalModifiers::Ctrl) | ("Z", TerminalModifiers::CtrlShift) => Some("\x1a"),
("@", TerminalModifiers::Ctrl) => Some("\x00"),
("[", TerminalModifiers::Ctrl) => Some("\x1b"),
("\\", TerminalModifiers::Ctrl) => Some("\x1c"),
("]", TerminalModifiers::Ctrl) => Some("\x1d"),
("^", TerminalModifiers::Ctrl) => Some("\x1e"),
("_", TerminalModifiers::Ctrl) => Some("\x1f"),
("?", TerminalModifiers::Ctrl) => Some("\x7f"),
_ => None,
};
if let Some(esc) = manual_esc_str {
return Some(esc.into());
}
if modifiers.any() {
let modifier_code = modifier_code(keystroke);
let modified = match key.as_str() {
"up" => Some(format!("\x1b[1;{}A", modifier_code)),
"down" => Some(format!("\x1b[1;{}B", modifier_code)),
"right" => Some(format!("\x1b[1;{}C", modifier_code)),
"left" => Some(format!("\x1b[1;{}D", modifier_code)),
"insert" => Some(format!("\x1b[2;{}~", modifier_code)),
"pageup" => Some(format!("\x1b[5;{}~", modifier_code)),
"pagedown" => Some(format!("\x1b[6;{}~", modifier_code)),
"end" => Some(format!("\x1b[1;{}F", modifier_code)),
"home" => Some(format!("\x1b[1;{}H", modifier_code)),
_ => None,
};
if let Some(esc) = modified {
return Some(esc.into());
}
}
if !cfg!(target_os = "macos") || option_as_meta {
let is_alt_lowercase_ascii =
modifiers == TerminalModifiers::Alt && keystroke.key.is_ascii();
let is_alt_uppercase_ascii =
keystroke.modifiers.alt && keystroke.modifiers.shift && keystroke.key.is_ascii();
if is_alt_lowercase_ascii || is_alt_uppercase_ascii {
let key = if is_alt_uppercase_ascii {
keystroke.key.to_ascii_uppercase()
} else {
keystroke.key.clone()
};
return Some(format!("\x1b{}", key).into());
}
}
if let Some(text) = &keystroke.key_char {
return Some(text.clone().into());
}
if keystroke.key.len() == 1 {
return Some(keystroke.key.clone().into());
}
None
}
fn modifier_code(keystroke: &Keystroke) -> u32 {
let mut modifier_code = 0;
if keystroke.modifiers.shift {
modifier_code |= 1;
}
if keystroke.modifiers.alt {
modifier_code |= 1 << 1;
}
if keystroke.modifiers.control {
modifier_code |= 1 << 2;
}
modifier_code + 1
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum TransferType {
Upload,
Download,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum TransferState {
Running,
Paused,
Completed,
Failed(String),
Cancelled,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TransferInfo {
pub id: String,
pub name: String,
pub source: String,
pub target: String,
pub kind: TransferType,
pub total_bytes: Option<u64>,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Transfer {
pub tab_id: String,
pub tab_title: String,
pub info: TransferInfo,
pub transferred: u64,
pub total: Option<u64>,
pub state: TransferState,
}
+337
View File
@@ -0,0 +1,337 @@
use gpui::{Bounds, Hsla, Path, Pixels, Window, fill, point, px, size};
pub fn is_custom_block_supported(c: char) -> bool {
match c as u32 {
0x2580..=0x258F | 0x2590 | 0x2594..=0x259F => true, // Block Elements
0x2500 | 0x2502 | 0x250C | 0x2510 | 0x2514 | 0x2518 | 0x251C | 0x2524 | 0x252C | 0x2534
| 0x253C => true, // Light lines
0x2501 | 0x2503 | 0x250F | 0x2513 | 0x2517 | 0x251B | 0x2523 | 0x252B | 0x2533 | 0x253B
| 0x254B => true, // Heavy lines
0xE0B0..=0xE0B6 => true, // Powerline
_ => false,
}
}
pub fn paint_custom_block(
window: &mut Window,
c: char,
raw_x: f32,
raw_y: f32,
raw_w: f32,
raw_h: f32,
color: Hsla,
) -> bool {
let scale = window.scale_factor();
let x = px((raw_x * scale).round() / scale);
let y = px((raw_y * scale).round() / scale);
let w = px(((raw_x + raw_w) * scale).round() / scale) - x;
let h = px(((raw_y + raw_h) * scale).round() / scale) - y;
let mut painted = false;
// Helper to paint a quad snapping its true physical boundaries to integer physical pixels
let mut paint_quad = |qx: Pixels, qy: Pixels, qw: Pixels, qh: Pixels| {
let l = px((qx.as_f32() * scale).round() / scale);
let t = px((qy.as_f32() * scale).round() / scale);
let r = px(((qx + qw).as_f32() * scale).round() / scale);
let b = px(((qy + qh).as_f32() * scale).round() / scale);
window.paint_quad(fill(Bounds::new(point(l, t), size(r - l, b - t)), color));
painted = true;
};
let px_1 = px(1.0);
let px_2 = px(2.0);
// Box drawing thickness
let light = (w * 0.1).max(px_1);
let heavy = (w * 0.2).max(px_2);
let cx = x + w / 2.0;
let cy = y + h / 2.0;
match c as u32 {
// --- Block Elements (U+2580..=U+259F) ---
0x2580 => paint_quad(x, y, w, h / 2.0),
0x2581 => paint_quad(x, y + h * 7.0 / 8.0, w, h / 8.0),
0x2582 => paint_quad(x, y + h * 3.0 / 4.0, w, h / 4.0),
0x2583 => paint_quad(x, y + h * 5.0 / 8.0, w, h * 3.0 / 8.0),
0x2584 => paint_quad(x, y + h / 2.0, w, h / 2.0),
0x2585 => paint_quad(x, y + h * 3.0 / 8.0, w, h * 5.0 / 8.0),
0x2586 => paint_quad(x, y + h / 4.0, w, h * 3.0 / 4.0),
0x2587 => paint_quad(x, y + h / 8.0, w, h * 7.0 / 8.0),
0x2588 => paint_quad(x, y, w, h),
0x2589 => paint_quad(x, y, w * 7.0 / 8.0, h),
0x258A => paint_quad(x, y, w * 3.0 / 4.0, h),
0x258B => paint_quad(x, y, w * 5.0 / 8.0, h),
0x258C => paint_quad(x, y, w / 2.0, h),
0x258D => paint_quad(x, y, w * 3.0 / 8.0, h),
0x258E => paint_quad(x, y, w / 4.0, h),
0x258F => paint_quad(x, y, w / 8.0, h),
0x2590 => paint_quad(x + w / 2.0, y, w / 2.0, h),
0x2594 => paint_quad(x, y, w, h / 8.0),
0x2595 => paint_quad(x + w * 7.0 / 8.0, y, w / 8.0, h),
0x2596 => paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0),
0x2597 => paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0),
0x2598 => paint_quad(x, y, w / 2.0, h / 2.0),
0x2599 => {
paint_quad(x, y, w / 2.0, h);
paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259A => {
paint_quad(x, y, w / 2.0, h / 2.0);
paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259B => {
paint_quad(x, y, w, h / 2.0);
paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259C => {
paint_quad(x, y, w, h / 2.0);
paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259D => paint_quad(x + w / 2.0, y, w / 2.0, h / 2.0),
0x259E => {
paint_quad(x + w / 2.0, y, w / 2.0, h / 2.0);
paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259F => {
paint_quad(x + w / 2.0, y, w / 2.0, h);
paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0);
}
// --- Basic Box Drawing (U+2500..=U+257F) ---
// Light lines
0x2500 => paint_quad(x, cy - light / 2.0, w, light), // Horizontal
0x2502 => paint_quad(cx - light / 2.0, y, light, h), // Vertical
0x250C => {
// Down + Right
paint_quad(
cx - light / 2.0,
cy - light / 2.0,
light,
h / 2.0 + light / 2.0,
);
paint_quad(
cx - light / 2.0,
cy - light / 2.0,
w / 2.0 + light / 2.0,
light,
);
}
0x2510 => {
// Down + Left
paint_quad(
cx - light / 2.0,
cy - light / 2.0,
light,
h / 2.0 + light / 2.0,
);
paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light);
}
0x2514 => {
// Up + Right
paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0);
paint_quad(
cx - light / 2.0,
cy - light / 2.0,
w / 2.0 + light / 2.0,
light,
);
}
0x2518 => {
// Up + Left
paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0);
paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light);
}
0x251C => {
// Vertical + Right
paint_quad(cx - light / 2.0, y, light, h);
paint_quad(
cx - light / 2.0,
cy - light / 2.0,
w / 2.0 + light / 2.0,
light,
);
}
0x2524 => {
// Vertical + Left
paint_quad(cx - light / 2.0, y, light, h);
paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light);
}
0x252C => {
// Horizontal + Down
paint_quad(x, cy - light / 2.0, w, light);
paint_quad(
cx - light / 2.0,
cy - light / 2.0,
light,
h / 2.0 + light / 2.0,
);
}
0x2534 => {
// Horizontal + Up
paint_quad(x, cy - light / 2.0, w, light);
paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0);
}
0x253C => {
// Vertical + Horizontal (Cross)
paint_quad(x, cy - light / 2.0, w, light);
paint_quad(cx - light / 2.0, y, light, h);
}
// Heavy lines
0x2501 => paint_quad(x, cy - heavy / 2.0, w, heavy), // Heavy Horizontal
0x2503 => paint_quad(cx - heavy / 2.0, y, heavy, h), // Heavy Vertical
0x250F => {
// Heavy Down + Right
paint_quad(
cx - heavy / 2.0,
cy - heavy / 2.0,
heavy,
h / 2.0 + heavy / 2.0,
);
paint_quad(
cx - heavy / 2.0,
cy - heavy / 2.0,
w / 2.0 + heavy / 2.0,
heavy,
);
}
0x2513 => {
// Heavy Down + Left
paint_quad(
cx - heavy / 2.0,
cy - heavy / 2.0,
heavy,
h / 2.0 + heavy / 2.0,
);
paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
}
0x2517 => {
// Heavy Up + Right
paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0);
paint_quad(
cx - heavy / 2.0,
cy - heavy / 2.0,
w / 2.0 + heavy / 2.0,
heavy,
);
}
0x251B => {
// Heavy Up + Left
paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0);
paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
}
0x2523 => {
// Heavy Vertical + Right
paint_quad(cx - heavy / 2.0, y, heavy, h);
paint_quad(
cx - heavy / 2.0,
cy - heavy / 2.0,
w / 2.0 + heavy / 2.0,
heavy,
);
}
0x252B => {
// Heavy Vertical + Left
paint_quad(cx - heavy / 2.0, y, heavy, h);
paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
}
0x2533 => {
// Heavy Horizontal + Down
paint_quad(x, cy - heavy / 2.0, w, heavy);
paint_quad(
cx - heavy / 2.0,
cy - heavy / 2.0,
heavy,
h / 2.0 + heavy / 2.0,
);
}
0x253B => {
// Heavy Horizontal + Up
paint_quad(x, cy - heavy / 2.0, w, heavy);
paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0);
}
0x254B => {
// Heavy Vertical + Horizontal (Cross)
paint_quad(x, cy - heavy / 2.0, w, heavy);
paint_quad(cx - heavy / 2.0, y, heavy, h);
}
// --- Powerline ---
0xE0B0 => {
// Rightward Solid Arrow
let mut path = Path::new(point(x, y));
path.line_to(point(x + w, y + h / 2.0));
path.line_to(point(x, y + h));
path.line_to(point(x, y));
window.paint_path(path, color);
painted = true;
}
0xE0B2 => {
// Leftward Solid Arrow
let mut path = Path::new(point(x + w, y));
path.line_to(point(x, y + h / 2.0));
path.line_to(point(x + w, y + h));
path.line_to(point(x + w, y));
window.paint_path(path, color);
painted = true;
}
0xE0B1 => {
// Rightward Line Arrow
let t = px(1.0);
let mut p = Path::new(point(x, y));
p.line_to(point(x + w, y + h / 2.0));
p.line_to(point(x + w - t, y + h / 2.0));
p.line_to(point(x, y + t));
p.line_to(point(x, y));
window.paint_path(p, color);
let mut p2 = Path::new(point(x, y + h));
p2.line_to(point(x + w, y + h / 2.0));
p2.line_to(point(x + w - t, y + h / 2.0));
p2.line_to(point(x, y + h - t));
p2.line_to(point(x, y + h));
window.paint_path(p2, color);
painted = true;
}
0xE0B3 => {
// Leftward Line Arrow
let t = px(1.0);
let mut p = Path::new(point(x + w, y));
p.line_to(point(x, y + h / 2.0));
p.line_to(point(x + t, y + h / 2.0));
p.line_to(point(x + w, y + t));
p.line_to(point(x + w, y));
window.paint_path(p, color);
let mut p2 = Path::new(point(x + w, y + h));
p2.line_to(point(x, y + h / 2.0));
p2.line_to(point(x + t, y + h / 2.0));
p2.line_to(point(x + w, y + h - t));
p2.line_to(point(x + w, y + h));
window.paint_path(p2, color);
painted = true;
}
// Half arches (Powerline rounded)
0xE0B4 => {
// Rightward Solid Semicircle
let mut path = Path::new(point(x, y));
path.curve_to(point(x, y + h), point(x + w * 2.0, y + h / 2.0));
path.line_to(point(x, y));
window.paint_path(path, color);
painted = true;
}
0xE0B6 => {
// Leftward Solid Semicircle
let mut path = Path::new(point(x + w, y));
path.curve_to(point(x + w, y + h), point(x - w, y + h / 2.0));
path.line_to(point(x + w, y));
window.paint_path(path, color);
painted = true;
}
_ => {}
}
painted
}
@@ -11,6 +11,8 @@ use gpui::{
use gpui_component::ActiveTheme as _;
use crate::Ashell;
use crate::terminal::custom_blocks::{is_custom_block_supported, paint_custom_block};
use crate::terminal::highlight::HighlightStyle;
use crate::terminal::{RenderSnapshot, ViewportSelection};
#[derive(Clone, Copy)]
@@ -38,6 +40,26 @@ impl LayoutRect {
}
}
#[derive(Clone)]
struct LayoutUnderline {
row: i32,
col: i32,
cells: usize,
color: Hsla,
}
impl LayoutUnderline {
fn paint(&self, origin: Point<Pixels>, metrics: TerminalMetrics, window: &mut Window) {
let thickness = px(1.0);
let position = point(
origin.x + metrics.cell_width * self.col as f32,
origin.y + metrics.line_height * (self.row as f32 + 1.0) - thickness,
);
let size = gpui::size(metrics.cell_width * self.cells as f32, thickness);
window.paint_quad(fill(Bounds::new(position, size), self.color));
}
}
#[derive(Clone)]
struct BatchedTextRun {
row: i32,
@@ -125,12 +147,29 @@ struct CursorLayout {
pub struct TerminalElement {
view: Entity<Ashell>,
focus_handle: FocusHandle,
pane_focused: bool,
snapshot: RenderSnapshot,
marked_text: Option<String>,
font_family: SharedString,
font_size: Pixels,
line_height: Pixels,
cell_width: Pixels,
tab_id: String,
search_highlights: Option<std::collections::HashMap<(i32, i32), Hsla>>,
}
pub(crate) struct TerminalElementConfig {
pub(crate) view: Entity<Ashell>,
pub(crate) focus_handle: FocusHandle,
pub(crate) pane_focused: bool,
pub(crate) snapshot: RenderSnapshot,
pub(crate) marked_text: Option<String>,
pub(crate) font_family: SharedString,
pub(crate) font_size: Pixels,
pub(crate) line_height: Pixels,
pub(crate) cell_width: Pixels,
pub(crate) tab_id: String,
pub(crate) search_highlights: Option<std::collections::HashMap<(i32, i32), Hsla>>,
}
pub struct PrepaintState {
@@ -138,7 +177,18 @@ pub struct PrepaintState {
metrics: TerminalMetrics,
rects: Vec<LayoutRect>,
runs: Vec<BatchedTextRun>,
custom_blocks: Vec<LayoutCustomBlock>,
cursor: Option<CursorLayout>,
underlines: Vec<LayoutUnderline>,
}
#[derive(Clone)]
struct LayoutCustomBlock {
c: char,
row: i32,
col: i32,
cells: usize,
color: Hsla,
}
struct TerminalInputHandler {
@@ -219,9 +269,12 @@ impl InputHandler for TerminalInputHandler {
_window: &mut Window,
cx: &mut App,
) -> Option<Bounds<Pixels>> {
self.view
.read(cx)
.terminal_ime_bounds_for_range(range_utf16, self.element_bounds, self.cell_width, self.line_height)
self.view.read(cx).terminal_ime_bounds_for_range(
range_utf16,
self.element_bounds,
self.cell_width,
self.line_height,
)
}
fn character_index_for_point(
@@ -247,25 +300,32 @@ impl InputHandler for TerminalInputHandler {
}
impl TerminalElement {
pub fn new(
view: Entity<Ashell>,
focus_handle: FocusHandle,
snapshot: RenderSnapshot,
marked_text: Option<String>,
font_family: SharedString,
font_size: Pixels,
line_height: Pixels,
cell_width: Pixels,
) -> Self {
Self {
pub(crate) fn new(config: TerminalElementConfig) -> Self {
let TerminalElementConfig {
view,
focus_handle,
pane_focused,
snapshot,
marked_text,
font_family,
font_size,
line_height,
cell_width,
tab_id,
search_highlights,
} = config;
Self {
view,
focus_handle,
pane_focused,
snapshot,
marked_text,
font_family,
font_size,
line_height,
cell_width,
tab_id,
search_highlights,
}
}
@@ -279,21 +339,16 @@ impl TerminalElement {
}
}
fn cell_run_style(&self, cell: &alacritty_terminal::term::cell::Cell, cx: &App) -> TextRun {
let mut fg = color_to_hsla(cell.fg, true, cx);
let mut bg = color_to_hsla(cell.bg, false, cx);
if cell.flags.contains(Flags::INVERSE) {
std::mem::swap(&mut fg, &mut bg);
}
if cell.flags.contains(Flags::DIM) {
fg.a *= 0.7;
}
fn cell_run_style(
&self,
cell: &alacritty_terminal::term::cell::Cell,
foreground: Hsla,
) -> TextRun {
let underline = cell
.flags
.intersects(Flags::ALL_UNDERLINES)
.then(|| UnderlineStyle {
color: Some(fg),
color: Some(foreground),
thickness: px(1.0),
wavy: cell.flags.contains(Flags::UNDERCURL),
});
@@ -301,7 +356,7 @@ impl TerminalElement {
.flags
.contains(Flags::STRIKEOUT)
.then(|| StrikethroughStyle {
color: Some(fg),
color: Some(foreground),
thickness: px(1.0),
});
@@ -318,7 +373,7 @@ impl TerminalElement {
TextRun {
len: cell.c.len_utf8(),
color: fg,
color: foreground,
background_color: None,
font: Font {
family: self.font_family.clone(),
@@ -331,9 +386,22 @@ impl TerminalElement {
}
}
fn layout_grid(&self, cx: &App) -> (Vec<LayoutRect>, Vec<BatchedTextRun>) {
fn layout_grid(
&self,
cx: &App,
) -> (
Vec<LayoutRect>,
Vec<BatchedTextRun>,
Vec<LayoutCustomBlock>,
Vec<LayoutUnderline>,
) {
let view_read = self.view.read(cx);
let hovered_url = view_read.hovered_url.clone();
let mut rects = Vec::new();
let mut runs = Vec::new();
let mut custom_blocks = Vec::new();
let mut underlines = Vec::new();
let mut current_run: Option<BatchedTextRun> = None;
for render_cell in &self.snapshot.cells {
@@ -347,20 +415,54 @@ impl TerminalElement {
let selected = self.snapshot.selection.is_some_and(|selection| {
selection_contains(selection, render_cell.row, render_cell.col)
});
let bg = color_to_hsla(cell.bg, false, cx);
if selected || !is_default_bg(cell.bg) {
let cell_position = (render_cell.row, render_cell.col);
let keyword_highlight = self.snapshot.highlights.get(&cell_position).copied();
let search_highlight = self
.search_highlights
.as_ref()
.and_then(|highlights| highlights.get(&cell_position))
.copied();
let (foreground, background) = resolve_cell_colors(
color_to_hsla(cell.fg, true, cx),
color_to_hsla(cell.bg, false, cx),
keyword_highlight,
cell.flags,
);
let cell_span = if cell.flags.contains(Flags::WIDE_CHAR) {
2
} else {
1
};
if selected {
rects.push(LayoutRect {
row: render_cell.row,
col: render_cell.col,
cells: 1,
color: if selected {
cx.theme().selection
} else if cell.flags.contains(Flags::INVERSE) {
color_to_hsla(cell.fg, true, cx)
} else {
bg
},
cells: cell_span,
color: cx.theme().selection,
});
} else {
let has_highlight_background =
keyword_highlight.is_some_and(|style| style.background.is_some());
if !is_default_bg(cell.bg)
|| cell.flags.contains(Flags::INVERSE)
|| has_highlight_background
{
rects.push(LayoutRect {
row: render_cell.row,
col: render_cell.col,
cells: cell_span,
color: background,
});
}
if let Some(search_background) = search_highlight {
rects.push(LayoutRect {
row: render_cell.row,
col: render_cell.col,
cells: cell_span,
color: search_background,
});
}
}
if is_blank(cell) {
@@ -370,7 +472,49 @@ impl TerminalElement {
continue;
}
let style = self.cell_run_style(cell, cx);
let style = self.cell_run_style(cell, foreground);
// Apply hover underline if mouse is hovering over this URL
if let Some(hu) = &hovered_url {
if hu.tab_id == self.tab_id
&& hu
.cells
.contains(&(render_cell.row as usize, render_cell.col as usize))
{
underlines.push(LayoutUnderline {
row: render_cell.row,
col: render_cell.col,
cells: if cell.flags.contains(Flags::WIDE_CHAR) {
2
} else {
1
},
color: style.color,
});
}
}
// Box Drawing & Block Elements interception
let is_custom_block = is_custom_block_supported(cell.c);
if is_custom_block {
if let Some(run) = current_run.take() {
runs.push(run);
}
custom_blocks.push(LayoutCustomBlock {
c: cell.c,
row: render_cell.row,
col: render_cell.col,
cells: if cell.flags.contains(Flags::WIDE_CHAR) {
2
} else {
1
},
color: style.color,
});
continue;
}
if let Some(run) = current_run.as_mut() {
if run.can_append(&style, render_cell.row, render_cell.col) {
run.append(cell.c, cell.zerowidth());
@@ -402,19 +546,62 @@ impl TerminalElement {
runs.push(run);
}
(merge_rects(rects), runs)
(
merge_rects(rects),
runs,
custom_blocks,
merge_underlines(underlines),
)
}
fn cursor_layout(&self, cx: &App) -> Option<CursorLayout> {
self.snapshot.cursor.map(|cursor| CursorLayout {
row: cursor.row,
col: cursor.col,
shape: cursor.shape,
color: cx.theme().primary,
fn cursor_layout(&self, window: &Window, cx: &App) -> Option<CursorLayout> {
if !should_render_cursor(
window.is_window_active(),
self.focus_handle.is_focused(window),
self.pane_focused,
) {
return None;
}
use crate::session::config::CursorStyle;
let cursor_style = self.view.read(cx).cursor_style;
let show_cursor = match cursor_style {
CursorStyle::Blink | CursorStyle::BeamBlink => {
if let Ok(duration) =
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
{
(duration.as_millis() / 600) % 2 == 0
} else {
true
}
}
_ => true,
};
self.snapshot.cursor.map(|cursor| {
let mut shape = match cursor_style {
CursorStyle::Default => cursor.shape,
CursorStyle::Blink => CursorShape::Block,
CursorStyle::Beam => CursorShape::Beam,
CursorStyle::BeamBlink => CursorShape::Beam,
};
if !show_cursor {
shape = CursorShape::Hidden;
}
CursorLayout {
row: cursor.row,
col: cursor.col,
shape,
color: cx.theme().primary,
}
})
}
}
fn should_render_cursor(window_active: bool, terminal_focused: bool, pane_focused: bool) -> bool {
window_active && terminal_focused && pane_focused
}
impl IntoElement for TerminalElement {
type Element = Self;
@@ -454,11 +641,31 @@ impl Element for TerminalElement {
_inspector_id: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
_window: &mut Window,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
let _ = self.base_text_style(cx);
let (rects, runs) = self.layout_grid(cx);
// Local window animations emit many intermediate sizes. The view
// coalesces those requests before resizing its terminal grid and PTY.
let view = self.view.clone();
let tab_id = self.tab_id.clone();
let cols = (bounds.size.width.as_f32() / self.cell_width.as_f32())
.floor()
.max(1.0) as u16;
let rows = (bounds.size.height.as_f32() / self.line_height.as_f32())
.floor()
.max(1.0) as u16;
let resized_snapshot = view.update(cx, |this, cx| {
this.terminal_bounds.insert(tab_id.clone(), bounds);
this.resize_terminal_for_layout(&tab_id, cols, rows, cx)
});
if let Some(snapshot) = resized_snapshot {
self.snapshot = snapshot;
}
let (rects, runs, custom_blocks, underlines) = self.layout_grid(cx);
PrepaintState {
bounds,
metrics: TerminalMetrics {
@@ -467,7 +674,9 @@ impl Element for TerminalElement {
},
rects,
runs,
cursor: self.cursor_layout(cx),
custom_blocks,
cursor: self.cursor_layout(window, cx),
underlines,
}
}
@@ -481,19 +690,50 @@ impl Element for TerminalElement {
window: &mut Window,
cx: &mut App,
) {
// Compute a vertical offset to center the text grid vertically,
// distributing the leftover pixel remainder evenly to the top and bottom.
let grid_height = prepaint.metrics.line_height
* (prepaint.bounds.size.height.as_f32() / prepaint.metrics.line_height.as_f32())
.floor();
let y_offset = ((prepaint.bounds.size.height - grid_height) / 2.0).max(px(0.0));
let draw_origin = point(
prepaint.bounds.origin.x,
prepaint.bounds.origin.y + y_offset,
);
for rect in &prepaint.rects {
rect.paint(prepaint.bounds.origin, prepaint.metrics, window);
rect.paint(draw_origin, prepaint.metrics, window);
}
for run in &prepaint.runs {
run.paint(prepaint.bounds.origin, prepaint.metrics, window, cx);
run.paint(draw_origin, prepaint.metrics, window, cx);
}
for u in &prepaint.underlines {
u.paint(draw_origin, prepaint.metrics, window);
}
for block in &prepaint.custom_blocks {
let x =
draw_origin.x.as_f32() + block.col as f32 * prepaint.metrics.cell_width.as_f32();
let y =
draw_origin.y.as_f32() + block.row as f32 * prepaint.metrics.line_height.as_f32();
paint_custom_block(
window,
block.c,
x,
y,
prepaint.metrics.cell_width.as_f32() * block.cells as f32,
prepaint.metrics.line_height.as_f32(),
block.color,
);
}
window.handle_input(
&self.focus_handle,
TerminalInputHandler {
view: self.view.clone(),
element_bounds: prepaint.bounds,
element_bounds: Bounds::new(draw_origin, prepaint.bounds.size),
cell_width: prepaint.metrics.cell_width.as_f32(),
line_height: prepaint.metrics.line_height.as_f32(),
},
@@ -503,8 +743,8 @@ impl Element for TerminalElement {
if let Some(marked_text) = self.marked_text.as_ref().filter(|text| !text.is_empty()) {
if let Some(cursor) = prepaint.cursor {
let pos = point(
prepaint.bounds.origin.x + prepaint.metrics.cell_width * cursor.col as f32,
prepaint.bounds.origin.y + prepaint.metrics.line_height * cursor.row as f32,
draw_origin.x + prepaint.metrics.cell_width * cursor.col as f32,
draw_origin.y + prepaint.metrics.line_height * cursor.row as f32,
);
let mut base_style = self.base_text_style(cx);
base_style.underline = Some(UnderlineStyle {
@@ -551,8 +791,8 @@ impl Element for TerminalElement {
{
return;
}
let x = prepaint.bounds.origin.x + prepaint.metrics.cell_width * cursor.col as f32;
let y = prepaint.bounds.origin.y + prepaint.metrics.line_height * cursor.row as f32;
let x = draw_origin.x + prepaint.metrics.cell_width * cursor.col as f32;
let y = draw_origin.y + prepaint.metrics.line_height * cursor.row as f32;
match cursor.shape {
CursorShape::Hidden => {}
CursorShape::Beam => {
@@ -612,6 +852,23 @@ fn merge_rects(mut rects: Vec<LayoutRect>) -> Vec<LayoutRect> {
merged
}
fn merge_underlines(mut underlines: Vec<LayoutUnderline>) -> Vec<LayoutUnderline> {
underlines.sort_by_key(|u| (u.row, u.col));
let mut merged: Vec<LayoutUnderline> = Vec::with_capacity(underlines.len());
for u in underlines {
if let Some(last) = merged.last_mut() {
if last.row == u.row && last.color == u.color && last.col + last.cells as i32 == u.col {
last.cells += u.cells;
continue;
}
}
merged.push(u);
}
merged
}
fn selection_contains(selection: ViewportSelection, row: i32, col: i32) -> bool {
let row = row.max(0) as usize;
let col = col.max(0) as usize;
@@ -629,6 +886,31 @@ fn selection_contains(selection: ViewportSelection, row: i32, col: i32) -> bool
after_start && before_end
}
fn resolve_cell_colors(
mut foreground: Hsla,
mut background: Hsla,
highlight: Option<HighlightStyle>,
flags: Flags,
) -> (Hsla, Hsla) {
if let Some(highlight) = highlight {
if let Some(highlight_foreground) = highlight.foreground {
foreground = highlight_foreground;
}
if let Some(highlight_background) = highlight.background {
background = highlight_background;
}
}
if flags.contains(Flags::INVERSE) {
std::mem::swap(&mut foreground, &mut background);
}
if flags.contains(Flags::DIM) {
foreground.a *= 0.7;
}
(foreground, background)
}
fn is_blank(cell: &alacritty_terminal::term::cell::Cell) -> bool {
cell.c == ' '
&& cell.zerowidth().is_none()
@@ -720,3 +1002,65 @@ fn named_color(named: NamedColor, _foreground: bool, cx: &App) -> Hsla {
NamedColor::DimWhite => Hsla::from(rgb(0xe5e9f0)),
}
}
#[cfg(test)]
mod tests {
use super::{resolve_cell_colors, should_render_cursor};
use crate::terminal::highlight::HighlightStyle;
use alacritty_terminal::term::cell::Flags;
use gpui::Hsla;
fn color(h: f32, s: f32, l: f32) -> Hsla {
Hsla { h, s, l, a: 1.0 }
}
#[test]
fn renders_cursor_only_for_focused_pane_in_focused_terminal_window() {
assert!(should_render_cursor(true, true, true));
assert!(!should_render_cursor(false, true, true));
assert!(!should_render_cursor(true, false, true));
assert!(!should_render_cursor(true, true, false));
}
#[test]
fn applies_highlight_channels_before_inverse_terminal_style() {
let original_foreground = color(0.0, 0.0, 0.8);
let original_background = color(0.0, 0.0, 0.1);
let highlight_foreground = color(0.1, 0.8, 0.5);
let highlight_background = color(0.6, 0.8, 0.4);
let (foreground, background) = resolve_cell_colors(
original_foreground,
original_background,
Some(HighlightStyle {
foreground: Some(highlight_foreground),
background: Some(highlight_background),
}),
Flags::INVERSE,
);
assert_eq!(foreground, highlight_background);
assert_eq!(background, highlight_foreground);
}
#[test]
fn foreground_highlight_preserves_background_and_dim_style() {
let original_foreground = color(0.0, 0.0, 0.8);
let original_background = color(0.0, 0.0, 0.1);
let highlight_foreground = color(0.1, 0.8, 0.5);
let (foreground, background) = resolve_cell_colors(
original_foreground,
original_background,
Some(HighlightStyle {
foreground: Some(highlight_foreground),
background: None,
}),
Flags::DIM,
);
assert_eq!(foreground.alpha(1.0), highlight_foreground);
assert!((foreground.a - 0.7).abs() < f32::EPSILON);
assert_eq!(background, original_background);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2845
View File
File diff suppressed because it is too large Load Diff
+218
View File
@@ -0,0 +1,218 @@
use std::borrow::Cow;
use encoding_rs::{
BIG5, CoderResult, EUC_KR, Encoding, GB18030, GBK, SHIFT_JIS, UTF_8, UTF_16BE, UTF_16LE,
WINDOWS_1252,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TextEncoding {
#[default]
Utf8,
Gb18030,
Gbk,
Big5,
ShiftJis,
EucKr,
Windows1252,
Utf16Le,
Utf16Be,
}
pub(crate) const TERMINAL_ENCODINGS: &[TextEncoding] = &[
TextEncoding::Utf8,
TextEncoding::Gb18030,
TextEncoding::Gbk,
TextEncoding::Big5,
TextEncoding::ShiftJis,
TextEncoding::EucKr,
TextEncoding::Windows1252,
];
pub(crate) const FILE_ENCODINGS: &[TextEncoding] = &[
TextEncoding::Utf8,
TextEncoding::Utf16Le,
TextEncoding::Utf16Be,
TextEncoding::Gb18030,
TextEncoding::Gbk,
TextEncoding::Big5,
TextEncoding::ShiftJis,
TextEncoding::EucKr,
TextEncoding::Windows1252,
];
impl TextEncoding {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Utf8 => "UTF-8",
Self::Gb18030 => "GB18030",
Self::Gbk => "GBK",
Self::Big5 => "Big5",
Self::ShiftJis => "Shift_JIS",
Self::EucKr => "EUC-KR",
Self::Windows1252 => "Windows-1252",
Self::Utf16Le => "UTF-16 LE",
Self::Utf16Be => "UTF-16 BE",
}
}
pub(crate) fn encoding(self) -> &'static Encoding {
match self {
Self::Utf8 => UTF_8,
Self::Gb18030 => GB18030,
Self::Gbk => GBK,
Self::Big5 => BIG5,
Self::ShiftJis => SHIFT_JIS,
Self::EucKr => EUC_KR,
Self::Windows1252 => WINDOWS_1252,
Self::Utf16Le => UTF_16LE,
Self::Utf16Be => UTF_16BE,
}
}
pub(crate) fn detect_bom(bytes: &[u8]) -> Option<Self> {
if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
Some(Self::Utf8)
} else if bytes.starts_with(&[0xFF, 0xFE]) {
Some(Self::Utf16Le)
} else if bytes.starts_with(&[0xFE, 0xFF]) {
Some(Self::Utf16Be)
} else {
None
}
}
pub(crate) fn decode_file(self, bytes: &[u8]) -> (String, bool, bool) {
let bom_len = self.matching_bom_len(bytes);
let (text, had_errors) = self
.encoding()
.decode_without_bom_handling(&bytes[bom_len..]);
(text.into_owned(), had_errors, bom_len > 0)
}
pub(crate) fn encode_file(self, text: &str, with_bom: bool) -> (Vec<u8>, bool) {
if matches!(self, Self::Utf16Le | Self::Utf16Be) {
let mut bytes = Vec::with_capacity(text.len().saturating_mul(2).saturating_add(2));
if with_bom {
bytes.extend_from_slice(if self == Self::Utf16Le {
&[0xFF, 0xFE]
} else {
&[0xFE, 0xFF]
});
}
for code_unit in text.encode_utf16() {
let encoded = if self == Self::Utf16Le {
code_unit.to_le_bytes()
} else {
code_unit.to_be_bytes()
};
bytes.extend_from_slice(&encoded);
}
return (bytes, false);
}
let (encoded, _, had_errors) = self.encoding().encode(text);
let mut bytes = Vec::with_capacity(encoded.len() + 3);
if with_bom {
match self {
Self::Utf8 => bytes.extend_from_slice(&[0xEF, 0xBB, 0xBF]),
Self::Utf16Le => bytes.extend_from_slice(&[0xFF, 0xFE]),
Self::Utf16Be => bytes.extend_from_slice(&[0xFE, 0xFF]),
_ => {}
}
}
bytes.extend_from_slice(&encoded);
(bytes, had_errors)
}
pub(crate) fn default_bom(self) -> bool {
matches!(self, Self::Utf16Le | Self::Utf16Be)
}
pub(crate) fn encode_terminal_input<'a>(self, bytes: &'a [u8]) -> Cow<'a, [u8]> {
if self == Self::Utf8 {
return Cow::Borrowed(bytes);
}
let Ok(text) = std::str::from_utf8(bytes) else {
return Cow::Borrowed(bytes);
};
let (encoded, _, _) = self.encoding().encode(text);
encoded
}
fn matching_bom_len(self, bytes: &[u8]) -> usize {
match self {
Self::Utf8 if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) => 3,
Self::Utf16Le if bytes.starts_with(&[0xFF, 0xFE]) => 2,
Self::Utf16Be if bytes.starts_with(&[0xFE, 0xFF]) => 2,
_ => 0,
}
}
}
pub(crate) struct StreamingDecoder {
decoder: encoding_rs::Decoder,
}
impl StreamingDecoder {
pub(crate) fn new(encoding: TextEncoding) -> Self {
Self {
decoder: encoding.encoding().new_decoder_without_bom_handling(),
}
}
pub(crate) fn decode(&mut self, bytes: &[u8]) -> Vec<u8> {
let initial_capacity = self
.decoder
.max_utf8_buffer_length(bytes.len())
.unwrap_or_else(|| bytes.len().saturating_mul(4).saturating_add(16));
let mut output = String::with_capacity(initial_capacity);
let mut remaining = bytes;
loop {
let (result, read, _) = self.decoder.decode_to_string(remaining, &mut output, false);
remaining = &remaining[read..];
match result {
CoderResult::InputEmpty => break,
CoderResult::OutputFull => {
let additional = self
.decoder
.max_utf8_buffer_length(remaining.len())
.unwrap_or_else(|| remaining.len().saturating_mul(4).saturating_add(16))
.max(16);
output.reserve(additional);
}
}
}
output.into_bytes()
}
}
#[cfg(test)]
mod tests {
use super::{StreamingDecoder, TextEncoding};
#[test]
fn streaming_decoder_preserves_split_multibyte_characters() {
let (encoded, _) = TextEncoding::Gbk.encode_file("中文", false);
let mut decoder = StreamingDecoder::new(TextEncoding::Gbk);
let mut decoded = decoder.decode(&encoded[..1]);
decoded.extend(decoder.decode(&encoded[1..]));
assert_eq!(String::from_utf8(decoded).unwrap(), "中文");
}
#[test]
fn file_encoding_preserves_matching_bom() {
let original = [0xFF, 0xFE, b'A', 0x00];
let (text, had_errors, has_bom) = TextEncoding::Utf16Le.decode_file(&original);
let (encoded, encode_errors) = TextEncoding::Utf16Le.encode_file(&text, has_bom);
assert!(!had_errors);
assert!(!encode_errors);
assert_eq!(encoded, original);
}
}