Compare commits

...
11 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
17 changed files with 1149 additions and 407 deletions
+118
View File
@@ -21,6 +21,7 @@ on:
options: options:
- all - all
- windows-x64 - windows-x64
- macos-arm64
permissions: permissions:
contents: write # needed to create / update the GitHub Release contents: write # needed to create / update the GitHub Release
@@ -29,6 +30,7 @@ jobs:
build: build:
if: >- if: >-
github.ref != 'refs/heads/build/windows-x64' && github.ref != 'refs/heads/build/windows-x64' &&
github.ref != 'refs/heads/build/macos-arm64' &&
(github.event_name != 'workflow_dispatch' || inputs.platform == 'all') (github.event_name != 'workflow_dispatch' || inputs.platform == 'all')
name: ${{ matrix.name }} name: ${{ matrix.name }}
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
@@ -338,6 +340,122 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 14 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: publish:
name: Publish Release name: Publish Release
needs: build needs: build
Generated
+1 -1
View File
@@ -315,7 +315,7 @@ dependencies = [
[[package]] [[package]]
name = "ashell" name = "ashell"
version = "0.5.0" version = "0.5.2-fix1"
dependencies = [ dependencies = [
"alacritty_terminal", "alacritty_terminal",
"anyhow", "anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ashell" name = "ashell"
version = "0.5.0" version = "0.5.2-fix1"
edition = "2024" edition = "2024"
authors = ["ashell contributors"] authors = ["ashell contributors"]
description = "A GPUI Component based SSH and local terminal client" description = "A GPUI Component based SSH and local terminal client"
+3 -4
View File
@@ -107,10 +107,9 @@ native notification and a flashing bell on the corresponding tab. Unread notific
red badge to the macOS Dock icon or a red overlay to the Windows taskbar icon. Clicking the native 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 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 its unread state. No reminder is shown when ashell is active and the notification originates in the
currently visible tab. The loading indicator beside a tab title prioritizes OSC 133/633 currently visible tab. An unselected tab shows a loading indicator beside its title for two seconds
shell-integration command lifecycle markers. Shells and CLI tools without those markers fall back to after receiving terminal output. Continued output refreshes the indicator, while the selected tab
recent terminal output activity, while an OSC 9 completion notification clears the task's loading does not show it.
state immediately.
You can test a generic OSC 9 notification with: You can test a generic OSC 9 notification with:
+2 -3
View File
@@ -106,9 +106,8 @@ OSC 9 携带通知正文;OSC 777 和 OSC 99 可以同时携带标题与正文
符合显示时机的通知来自后台或非当前可见标签时,ashell 会显示系统通知,标签按钮以 符合显示时机的通知来自后台或非当前可见标签时,ashell 会显示系统通知,标签按钮以
闪动铃铛标记未读状态,并在 macOS Dock 或 Windows 任务栏图标上显示红色徽标。点击 闪动铃铛标记未读状态,并在 macOS Dock 或 Windows 任务栏图标上显示红色徽标。点击
系统通知会激活 ashell 并切换到对应的终端标签,查看对应标签后自动清除未读状态。 系统通知会激活 ashell 并切换到对应的终端标签,查看对应标签后自动清除未读状态。
如果应用已激活且通知来自当前可见标签,则不重复提醒。标签标题左侧的 Loading 动画 如果应用已激活且通知来自当前可见标签,则不重复提醒。未选中的标签在最近 2 秒内收到
优先识别 OSC 133/633 shell integration 的命令开始和结束标记;未提供协议标记时根据 终端输出时,会在标题左侧显示 Loading 动画;持续输出会刷新显示时间,当前标签不会显示。
近期终端输出活动回退显示。
可以分别使用下面的命令测试四种提醒: 可以分别使用下面的命令测试四种提醒:
+4
View File
@@ -262,6 +262,10 @@ scroll: "Scroll ↓"
clone: "Clone" clone: "Clone"
about_feedback_hint: "If you encounter a problem or have a suggestion, please report it on our GitHub project." 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_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" version: "Version"
close: "Close" close: "Close"
menu_about_ashell: "About Ashell" menu_about_ashell: "About Ashell"
+4
View File
@@ -262,6 +262,10 @@ scroll: "滑动查看 ↓"
clone: "克隆" clone: "克隆"
about_feedback_hint: "如果遇到问题或者有什么建议,请前往我们的 GitHub 项目中反馈。" about_feedback_hint: "如果遇到问题或者有什么建议,请前往我们的 GitHub 项目中反馈。"
about_description: "基于 GPUI Component 的 SSH 与本地终端客户端" 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: "版本" version: "版本"
close: "关闭" close: "关闭"
menu_about_ashell: "关于 Ashell" menu_about_ashell: "关于 Ashell"
+4 -3
View File
@@ -632,7 +632,8 @@ impl Ashell {
} }
pub(crate) fn export_connections(&mut self, window: &mut gpui::Window, cx: &mut Context<Self>) { pub(crate) fn export_connections(&mut self, window: &mut gpui::Window, cx: &mut Context<Self>) {
if self.active_dialog.is_some() { let parent_dialog = self.active_dialog;
if parent_dialog.is_some() && parent_dialog != Some(DialogKind::Settings) {
return; return;
} }
@@ -695,7 +696,7 @@ impl Ashell {
let sessions = sessions_for_export.clone(); let sessions = sessions_for_export.clone();
view_for_ok.update(cx, |this, cx| { view_for_ok.update(cx, |this, cx| {
if this.active_dialog == Some(DialogKind::ConnectionExport) { if this.active_dialog == Some(DialogKind::ConnectionExport) {
this.active_dialog = None; this.active_dialog = parent_dialog;
} }
this.start_connection_csv_export(sessions, window, cx); this.start_connection_csv_export(sessions, window, cx);
cx.notify(); cx.notify();
@@ -707,7 +708,7 @@ impl Ashell {
move |_, _, cx| { move |_, _, cx| {
view.update(cx, |this, cx| { view.update(cx, |this, cx| {
if this.active_dialog == Some(DialogKind::ConnectionExport) { if this.active_dialog == Some(DialogKind::ConnectionExport) {
this.active_dialog = None; this.active_dialog = parent_dialog;
} }
cx.notify(); cx.notify();
}); });
+91 -45
View File
@@ -729,50 +729,6 @@ impl Ashell {
), ),
), ),
) )
.child(
div()
.w_full()
.p_2()
.rounded_md()
.border_1()
.border_color(if selected_index == 1 {
_cx.theme().primary
} else {
_cx.theme().border
})
.bg(if selected_index == 1 {
_cx.theme().tab_active
} else {
_cx.theme().muted
})
.cursor_pointer()
.hover(|this| this.bg(_cx.theme().secondary))
.on_mouse_down(
MouseButton::Left,
window.listener_for(&view, |this, _, window, cx| {
this.active_dialog = None;
window.close_dialog(cx);
this.open_new_ssh_dialog(window, cx);
cx.notify();
}),
)
.child(
v_flex()
.gap_1()
.child(
div()
.text_size(ui_rems(1.0))
.font_weight(FontWeight::SEMIBOLD)
.child(t!("new_connection")),
)
.child(
div()
.text_size(ui_rems(0.917))
.text_color(_cx.theme().muted_foreground)
.child(t!("create_or_edit_ssh_session")),
),
),
)
.child( .child(
div() div()
.relative() .relative()
@@ -790,7 +746,7 @@ impl Ashell {
|(ix, session)| { |(ix, session)| {
let connect_id = session.id.clone(); let connect_id = session.id.clone();
let is_selected = let is_selected =
selected_index == ix + 2; selected_index == ix + 1;
let name = session.name.clone(); let name = session.name.clone();
let detail = if session.protocol let detail = if session.protocol
== "serial" == "serial"
@@ -3285,6 +3241,51 @@ impl Ashell {
); );
} }
}), }),
)
.child(
v_flex()
.w_full()
.mt_2()
.p_3()
.gap_2()
.rounded_md()
.bg(cx.theme().secondary.opacity(0.4))
.border_1()
.border_color(cx.theme().border)
.child(
div()
.text_size(ui_rems(0.85))
.font_weight(FontWeight::BOLD)
.text_color(cx.theme().foreground)
.child(t!("about_tips_title")),
)
.child(
v_flex()
.gap_2()
.text_size(ui_rems(0.8))
.text_color(cx.theme().muted_foreground)
.child(
h_flex()
.gap_2()
.items_start()
.child(div().flex_none().child("•"))
.child(div().flex_1().min_w(px(0.)).child(t!("about_tip_backspace"))),
)
.child(
h_flex()
.gap_2()
.items_start()
.child(div().flex_none().child("•"))
.child(div().flex_1().min_w(px(0.)).child(t!("about_tip_close_shortcuts"))),
)
.child(
h_flex()
.gap_2()
.items_start()
.child(div().flex_none().child("•"))
.child(div().flex_1().min_w(px(0.)).child(t!("about_tip_conflict_shortcuts"))),
),
),
), ),
) )
}) })
@@ -4429,6 +4430,51 @@ impl Ashell {
let _ = open::that("https://github.com/rust-kotlin/ashell"); let _ = open::that("https://github.com/rust-kotlin/ashell");
}), }),
) )
.child(
v_flex()
.w_full()
.mt_3()
.p_3()
.gap_2()
.rounded_md()
.bg(cx.theme().secondary.opacity(0.4))
.border_1()
.border_color(cx.theme().border)
.child(
div()
.text_size(ui_rems(0.85))
.font_weight(FontWeight::BOLD)
.text_color(cx.theme().foreground)
.child(t!("about_tips_title")),
)
.child(
v_flex()
.gap_2()
.text_size(ui_rems(0.8))
.text_color(cx.theme().muted_foreground)
.child(
h_flex()
.gap_2()
.items_start()
.child(div().flex_none().child("•"))
.child(div().flex_1().min_w(px(0.)).child(t!("about_tip_backspace"))),
)
.child(
h_flex()
.gap_2()
.items_start()
.child(div().flex_none().child("•"))
.child(div().flex_1().min_w(px(0.)).child(t!("about_tip_close_shortcuts"))),
)
.child(
h_flex()
.gap_2()
.items_start()
.child(div().flex_none().child("•"))
.child(div().flex_1().min_w(px(0.)).child(t!("about_tip_conflict_shortcuts"))),
),
),
)
})) }))
) )
) )
+26 -2
View File
@@ -421,6 +421,7 @@ pub(crate) struct Ashell {
pub(crate) search_bar_bounds: Option<Bounds<Pixels>>, pub(crate) search_bar_bounds: Option<Bounds<Pixels>>,
pub(crate) system_tab_id: Option<String>, pub(crate) system_tab_id: Option<String>,
pub(crate) last_applied_system_tab_id: Option<String>,
pub(crate) sftp_handles: std::collections::HashMap<String, crate::sftp::SftpHandle>, pub(crate) sftp_handles: std::collections::HashMap<String, crate::sftp::SftpHandle>,
pub(crate) remote_sample_in_flight: bool, pub(crate) remote_sample_in_flight: bool,
@@ -449,7 +450,6 @@ pub(crate) struct HoveredUrl {
#[derive(Clone)] #[derive(Clone)]
pub(crate) enum SelectorEntry { pub(crate) enum SelectorEntry {
Local, Local,
NewSsh,
Saved(String), Saved(String),
} }
@@ -944,6 +944,7 @@ impl Ashell {
search_bar_bounds: None, search_bar_bounds: None,
system_tab_id: None, system_tab_id: None,
last_applied_system_tab_id: None,
sftp_handles: std::collections::HashMap::new(), sftp_handles: std::collections::HashMap::new(),
remote_sample_in_flight: false, remote_sample_in_flight: false,
@@ -1283,7 +1284,11 @@ impl Ashell {
} }
fn on_window_activation_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn on_window_activation_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let was_active = self.window_active;
self.sync_window_activation(window); self.sync_window_activation(window);
if !was_active && self.window_active {
window.activate_window();
}
cx.notify(); cx.notify();
} }
@@ -1390,6 +1395,7 @@ impl Ashell {
tab.disconnected_reason = None; tab.disconnected_reason = None;
} }
self.sync_sftp_path_from_terminal_title(&tab_id, cx); self.sync_sftp_path_from_terminal_title(&tab_id, cx);
self.sync_sftp_to_active_tab();
self.sync_system_tab_to_active_group(); self.sync_system_tab_to_active_group();
if self.system_tab_id.as_deref() == Some(tab_id.as_str()) { if self.system_tab_id.as_deref() == Some(tab_id.as_str()) {
self.system_status = None; self.system_status = None;
@@ -1488,6 +1494,12 @@ impl Ashell {
if self.is_connected_system_tab(&tab_id) { if self.is_connected_system_tab(&tab_id) {
self.remote_sample_in_flight = false; self.remote_sample_in_flight = false;
self.system_status = None; self.system_status = None;
if self.last_applied_system_tab_id.as_deref() != Some(&tab_id) {
self.last_applied_system_tab_id = Some(tab_id.clone());
self.cpu_history.clear();
self.net_rx_history.clear();
self.net_tx_history.clear();
}
self.apply_system_snapshot(snapshot); self.apply_system_snapshot(snapshot);
} }
} }
@@ -1496,6 +1508,11 @@ impl Ashell {
self.remote_sample_in_flight = false; self.remote_sample_in_flight = false;
self.system_status = Some(reason.clone().into()); self.system_status = Some(reason.clone().into());
self.status = reason.into(); self.status = reason.into();
self.last_applied_system_tab_id = Some(tab_id);
self.system = SystemSnapshot::default();
self.cpu_history.clear();
self.net_rx_history.clear();
self.net_tx_history.clear();
} }
} }
BackendEvent::RemoteProcesses { tab_id, processes } => { BackendEvent::RemoteProcesses { tab_id, processes } => {
@@ -1565,7 +1582,7 @@ impl Ashell {
continue; continue;
} }
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) { if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.clear_command_activity(); tab.clear_terminal_activity();
tab.connected = false; tab.connected = false;
tab.status = reason.clone(); tab.status = reason.clone();
tab.disconnected_reason = Some(reason.clone()); tab.disconnected_reason = Some(reason.clone());
@@ -1734,6 +1751,12 @@ impl Ashell {
} }
return false; return false;
} }
if self.last_applied_system_tab_id.is_some() {
self.last_applied_system_tab_id = None;
self.cpu_history.clear();
self.net_rx_history.clear();
self.net_tx_history.clear();
}
let snapshot = self.system_sampler.sample(); let snapshot = self.system_sampler.sample();
self.apply_system_snapshot(snapshot); self.apply_system_snapshot(snapshot);
return true; return true;
@@ -1760,6 +1783,7 @@ impl Ashell {
self.cpu_history.clear(); self.cpu_history.clear();
self.net_rx_history.clear(); self.net_rx_history.clear();
self.net_tx_history.clear(); self.net_tx_history.clear();
self.last_applied_system_tab_id = None;
self.remote_sample_in_flight = false; self.remote_sample_in_flight = false;
self.remote_processes_in_flight = false; self.remote_processes_in_flight = false;
self.remote_processes.clear(); self.remote_processes.clear();
+270 -117
View File
@@ -1884,10 +1884,26 @@ impl Ashell {
}; };
let mem_pct = self.system.mem_percent; let mem_pct = self.system.mem_percent;
let swap_pct = self.system.swap_percent; let swap_pct = self.system.swap_percent;
let mem_detail = self.system.mem_detail.clone(); let mem_detail = if self.system.mem_detail.is_empty() {
let swap_detail = self.system.swap_detail.clone(); "-- / --".to_string()
let net_rx = self.system.net_rx.clone(); } else {
let net_tx = self.system.net_tx.clone(); self.system.mem_detail.clone()
};
let swap_detail = if self.system.swap_detail.is_empty() {
"-- / --".to_string()
} else {
self.system.swap_detail.clone()
};
let net_rx = if self.system.net_rx.is_empty() {
"--".to_string()
} else {
self.system.net_rx.clone()
};
let net_tx = if self.system.net_tx.is_empty() {
"--".to_string()
} else {
self.system.net_tx.clone()
};
let (disk_used, disk_total) = self.system.disks.iter().fold((0u64, 0u64), |(u, t), d| { let (disk_used, disk_total) = self.system.disks.iter().fold((0u64, 0u64), |(u, t), d| {
(u + (d.total_bytes - d.available_bytes), t + d.total_bytes) (u + (d.total_bytes - d.available_bytes), t + d.total_bytes)
@@ -2208,7 +2224,11 @@ impl Ashell {
div() div()
.text_size(ui_rems(0.833)) .text_size(ui_rems(0.833))
.text_color(muted_fg) .text_color(muted_fg)
.child(format!("{:.0}%", disk_pct)), .child(if disk_total > 0 {
format!("{:.0}%", disk_pct)
} else {
"--".to_string()
}),
), ),
) )
.child( .child(
@@ -2222,40 +2242,72 @@ impl Ashell {
.track_scroll(&self.disk_scroll_handle) .track_scroll(&self.disk_scroll_handle)
.overflow_y_scroll() .overflow_y_scroll()
.size_full() .size_full()
.children(disks.iter().map(|disk| { .children(if disks.is_empty() {
let pct = if disk.total_bytes > 0 { vec![
(disk.total_bytes - disk.available_bytes) as f64 h_flex()
/ disk.total_bytes as f64 .w_full()
* 100.0 .items_center()
} else { .gap_1()
0.0 .child(
}; div()
let mount_short = disk.mount.clone(); .w(px(20.))
let mount_id = format!("disk-{}", mount_short); .h(px(8.))
h_flex() .rounded_sm()
.w_full() .bg(cx.theme().skeleton),
.items_center() )
.gap_1() .child(
.child( div()
div() .flex_1()
.text_size(ui_rems(0.667)) .h(px(4.))
.text_color(muted_fg) .rounded_full()
.child(mount_short), .bg(cx.theme().skeleton.opacity(0.4)),
) )
.child( .child(
Progress::new(mount_id) div()
.value(pct as f32) .w(px(22.))
.color(disk_color) .h(px(8.))
.with_size(px(4.)) .rounded_sm()
.flex_1(), .bg(cx.theme().skeleton.opacity(0.7)),
) )
.child( .into_any_element(),
div() ]
.text_size(ui_rems(0.667)) } else {
.text_color(muted_fg) disks.iter().map(|disk| {
.child(format!("{:.0}%", pct)), let pct = if disk.total_bytes > 0 {
) (disk.total_bytes - disk.available_bytes) as f64
})), / disk.total_bytes as f64
* 100.0
} else {
0.0
};
let mount_short = disk.mount.clone();
let mount_id = format!("disk-{}", mount_short);
h_flex()
.w_full()
.items_center()
.gap_1()
.child(
div()
.text_size(ui_rems(0.667))
.text_color(muted_fg)
.child(mount_short),
)
.child(
Progress::new(mount_id)
.value(pct as f32)
.color(disk_color)
.with_size(px(4.))
.flex_1(),
)
.child(
div()
.text_size(ui_rems(0.667))
.text_color(muted_fg)
.child(format!("{:.0}%", pct)),
)
.into_any_element()
}).collect()
}),
) )
.child( .child(
div() div()
@@ -2313,7 +2365,32 @@ impl Ashell {
let disk_color = cx.theme().chart_5; let disk_color = cx.theme().chart_5;
let net_color = cx.theme().chart_4; let net_color = cx.theme().chart_4;
let muted_fg = cx.theme().muted_foreground; let muted_fg = cx.theme().muted_foreground;
let mem_detail = if self.system.mem_detail.is_empty() {
"-- / --".to_string()
} else {
self.system.mem_detail.clone()
};
let swap_detail = if self.system.swap_detail.is_empty() {
"-- / --".to_string()
} else {
self.system.swap_detail.clone()
};
let net_rx = if self.system.net_rx.is_empty() {
"--".to_string()
} else {
self.system.net_rx.clone()
};
let net_tx = if self.system.net_tx.is_empty() {
"--".to_string()
} else {
self.system.net_tx.clone()
};
let active_is_ssh = matches!(self.active_kind(), Some(TabKind::Ssh)); let active_is_ssh = matches!(self.active_kind(), Some(TabKind::Ssh));
let is_connecting_ssh = self
.active_tab
.as_ref()
.and_then(|active_id| self.tabs.iter().find(|tab| tab.id == *active_id))
.is_some_and(|tab| tab.kind == TabKind::Ssh && !tab.connected && tab.disconnected_reason.is_none());
let (monitor_title, monitor_detail) = self let (monitor_title, monitor_detail) = self
.active_tab .active_tab
.as_ref() .as_ref()
@@ -2323,6 +2400,8 @@ impl Ashell {
.unwrap_or_else(|| (t!("system_info").to_string(), t!("live").to_string())); .unwrap_or_else(|| (t!("system_info").to_string(), t!("live").to_string()));
let status_color = if interactive { let status_color = if interactive {
cx.theme().success cx.theme().success
} else if is_connecting_ssh {
cx.theme().warning
} else if active_is_ssh { } else if active_is_ssh {
cx.theme().danger cx.theme().danger
} else { } else {
@@ -2448,7 +2527,7 @@ impl Ashell {
div() div()
.text_size(ui_rems(0.85)) .text_size(ui_rems(0.85))
.text_color(muted_fg) .text_color(muted_fg)
.child(self.system.mem_detail.clone()), .child(mem_detail),
), ),
) )
.child( .child(
@@ -2475,7 +2554,7 @@ impl Ashell {
div() div()
.text_size(ui_rems(0.85)) .text_size(ui_rems(0.85))
.text_color(muted_fg) .text_color(muted_fg)
.child(self.system.swap_detail.clone()), .child(swap_detail),
), ),
) )
.child( .child(
@@ -2514,49 +2593,146 @@ impl Ashell {
div() div()
.relative() .relative()
.w_full() .w_full()
.h(px(90.))
.child( .child(
v_flex() v_flex()
.id("sidebar-disk-scroll") .id("sidebar-disk-scroll")
.track_scroll(&self.disk_scroll_handle) .track_scroll(&self.disk_scroll_handle)
.overflow_y_scroll() .overflow_y_scroll()
.max_h(px(90.)) .size_full()
.gap_2() .gap_2()
.children(self.system.disks.iter().map(|disk| { .children(if self.system.disks.is_empty() {
let pct = if disk.total_bytes > 0 { vec![
(disk.total_bytes - disk.available_bytes) as f64 v_flex()
/ disk.total_bytes as f64 .gap_0p5()
* 100.0 .child(
} else { h_flex()
0.0 .justify_between()
}; .items_center()
let mount_short = disk.mount.clone(); .h(px(14.))
let mount_id = format!("sidebar-disk-{}", mount_short); .child(
v_flex() div()
.gap_0p5() .w(px(44.))
.child( .h(px(10.))
h_flex() .rounded_sm()
.justify_between() .bg(cx.theme().skeleton),
.child( )
div() .child(
.text_size(ui_rems(0.75)) div()
.text_color(muted_fg) .w(px(28.))
.child(mount_short), .h(px(10.))
) .rounded_sm()
.child( .bg(cx.theme().skeleton.opacity(0.7)),
div() ),
.text_size(ui_rems(0.75)) )
.text_color(muted_fg) .child(
.child(format!("{:.1}%", pct)), div()
), .w_full()
) .h(px(4.))
.child( .rounded_full()
Progress::new(mount_id) .bg(cx.theme().skeleton.opacity(0.4)),
.value(pct as f32) )
.color(disk_color) .into_any_element(),
.with_size(px(4.)) v_flex()
.w_full(), .gap_0p5()
) .child(
})), h_flex()
.justify_between()
.items_center()
.h(px(14.))
.child(
div()
.w(px(64.))
.h(px(10.))
.rounded_sm()
.bg(cx.theme().skeleton),
)
.child(
div()
.w(px(32.))
.h(px(10.))
.rounded_sm()
.bg(cx.theme().skeleton.opacity(0.7)),
),
)
.child(
div()
.w_full()
.h(px(4.))
.rounded_full()
.bg(cx.theme().skeleton.opacity(0.4)),
)
.into_any_element(),
v_flex()
.gap_0p5()
.child(
h_flex()
.justify_between()
.items_center()
.h(px(14.))
.child(
div()
.w(px(36.))
.h(px(10.))
.rounded_sm()
.bg(cx.theme().skeleton),
)
.child(
div()
.w(px(26.))
.h(px(10.))
.rounded_sm()
.bg(cx.theme().skeleton.opacity(0.7)),
),
)
.child(
div()
.w_full()
.h(px(4.))
.rounded_full()
.bg(cx.theme().skeleton.opacity(0.4)),
)
.into_any_element(),
]
} else {
self.system.disks.iter().map(|disk| {
let pct = if disk.total_bytes > 0 {
(disk.total_bytes - disk.available_bytes) as f64
/ disk.total_bytes as f64
* 100.0
} else {
0.0
};
let mount_short = disk.mount.clone();
let mount_id = format!("sidebar-disk-{}", mount_short);
v_flex()
.gap_0p5()
.child(
h_flex()
.justify_between()
.child(
div()
.text_size(ui_rems(0.75))
.text_color(muted_fg)
.child(mount_short),
)
.child(
div()
.text_size(ui_rems(0.75))
.text_color(muted_fg)
.child(format!("{:.1}%", pct)),
),
)
.child(
Progress::new(mount_id)
.value(pct as f32)
.color(disk_color)
.with_size(px(4.))
.w_full(),
)
.into_any_element()
}).collect()
}),
) )
.child( .child(
div() div()
@@ -2621,7 +2797,7 @@ impl Ashell {
.child( .child(
div() div()
.text_size(ui_rems(0.75)) .text_size(ui_rems(0.75))
.child(self.system.net_rx.clone()), .child(net_rx),
), ),
) )
.child( .child(
@@ -2639,7 +2815,7 @@ impl Ashell {
.child( .child(
div() div()
.text_size(ui_rems(0.75)) .text_size(ui_rems(0.75))
.child(self.system.net_tx.clone()), .child(net_tx),
), ),
), ),
), ),
@@ -3336,7 +3512,6 @@ impl Ashell {
active_session_id: Option<&str>, active_session_id: Option<&str>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> gpui::AnyElement { ) -> gpui::AnyElement {
let connect_id = session.id.clone();
let edit_id = session.id.clone(); let edit_id = session.id.clone();
let delete_id = session.id.clone(); let delete_id = session.id.clone();
let management_mode = self.connection_management_mode; let management_mode = self.connection_management_mode;
@@ -3347,8 +3522,6 @@ impl Ashell {
let selection_id = session.id.clone(); let selection_id = session.id.clone();
let row_selection_id = selection_id.clone(); let row_selection_id = selection_id.clone();
let row_id = ElementId::Name(format!("saved-connect-{}", session.id).into()); let row_id = ElementId::Name(format!("saved-connect-{}", session.id).into());
let connect_button_id =
ElementId::Name(format!("connect-saved-session-{}", session.id).into());
div() div()
.id(row_id) .id(row_id)
@@ -3477,33 +3650,7 @@ impl Ashell {
.text_size(ui_rems(0.75)) .text_size(ui_rems(0.75))
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
.child(detail), .child(detail),
) ),
.when(management_mode, |this| {
this.child(
h_flex()
.w(px(24.))
.ml_1()
.flex_none()
.items_center()
.justify_center()
.on_mouse_down(MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
})
.on_mouse_down(MouseButton::Right, |_, _, cx| {
cx.stop_propagation();
})
.child(
pointer_button(connect_button_id)
.ghost()
.small()
.icon(IconName::ExternalLink)
.tooltip(t!("connect").to_string())
.on_click(cx.listener(move |this, _, window, cx| {
this.connect_saved_session(connect_id.clone(), window, cx);
})),
),
)
}),
) )
.into_any_element() .into_any_element()
} }
@@ -4380,12 +4527,13 @@ impl Ashell {
pane_ids.iter().any(|id| { pane_ids.iter().any(|id| {
self.unread_terminal_notifications.contains(id) self.unread_terminal_notifications.contains(id)
}); });
let output_active = pane_ids.iter().any(|id| { let output_active = ix != selected
self.tabs && pane_ids.iter().any(|id| {
.iter() self.tabs
.find(|tab| tab.id == *id) .iter()
.is_some_and(TerminalTab::is_command_active) .find(|tab| tab.id == *id)
}); .is_some_and(TerminalTab::has_recent_output)
});
h_flex() h_flex()
.id(("ashell-tab", ix)) .id(("ashell-tab", ix))
.relative() .relative()
@@ -5339,7 +5487,12 @@ impl Render for Ashell {
} }
} }
let has_ssh_session = self.active_ssh_session().is_some(); let has_ssh_session = self.active_ssh_session().is_some()
|| self
.active_group
.as_ref()
.and_then(|id| self.tab_groups.iter().find(|g| &g.id == id))
.is_some_and(|g| g.sftp.is_some());
let is_monitor_bottom = self.config.monitoring_position() == "Bottom"; let is_monitor_bottom = self.config.monitoring_position() == "Bottom";
let is_active_ssh_connected = self let is_active_ssh_connected = self
.active_tab .active_tab
+6 -14
View File
@@ -409,9 +409,6 @@ fn default_terminal_font_size() -> f32 {
12.0 12.0
} }
const LEGACY_DEFAULT_TERMINAL_FONT_SIZE: f32 = 18.0;
const LEGACY_DEFAULT_UI_FONT_SIZE: f32 = 16.0;
fn default_ui_font_size() -> f32 { fn default_ui_font_size() -> f32 {
14.0 14.0
} }
@@ -1167,9 +1164,7 @@ impl ConfigStore {
} }
pub fn terminal_font_size(&self) -> f32 { pub fn terminal_font_size(&self) -> f32 {
if self.cache.terminal_font_size <= 0.0 if self.cache.terminal_font_size <= 0.0 {
|| (self.cache.terminal_font_size - LEGACY_DEFAULT_TERMINAL_FONT_SIZE).abs() < 0.01
{
default_terminal_font_size() default_terminal_font_size()
} else { } else {
self.cache.terminal_font_size self.cache.terminal_font_size
@@ -1287,10 +1282,7 @@ impl ConfigStore {
} }
pub fn ui_font_size(&self) -> f32 { pub fn ui_font_size(&self) -> f32 {
if self.cache.ui_font_size <= 0.0 if self.cache.ui_font_size <= 0.0 {
|| (self.cache.ui_font_size - LEGACY_DEFAULT_UI_FONT_SIZE).abs() < 0.01
{
// Migrate the previous application default to the current compact default.
default_ui_font_size() default_ui_font_size()
} else { } else {
self.cache.ui_font_size self.cache.ui_font_size
@@ -1942,17 +1934,17 @@ mod tests {
} }
#[test] #[test]
fn font_sizes_migrate_the_previous_defaults() { fn font_sizes_preserve_user_configurations() {
let mut store = ConfigStore::in_memory(); let mut store = ConfigStore::in_memory();
assert_eq!(store.terminal_font_size(), 12.0); assert_eq!(store.terminal_font_size(), 12.0);
assert_eq!(store.ui_font_size(), 14.0); assert_eq!(store.ui_font_size(), 14.0);
store.set_terminal_font_size(18.0); store.set_terminal_font_size(18.0);
assert_eq!(store.terminal_font_size(), 12.0); assert_eq!(store.terminal_font_size(), 18.0);
store.set_ui_font_size(14.0); store.set_ui_font_size(16.0);
assert_eq!(store.ui_font_size(), 14.0); assert_eq!(store.ui_font_size(), 16.0);
store.set_ui_font_size(20.0); store.set_ui_font_size(20.0);
assert_eq!(store.ui_font_size(), 20.0); assert_eq!(store.ui_font_size(), 20.0);
+150 -30
View File
@@ -1267,7 +1267,7 @@ impl Ashell {
} }
pub(crate) fn selector_entries(&self) -> Vec<SelectorEntry> { pub(crate) fn selector_entries(&self) -> Vec<SelectorEntry> {
let mut entries = vec![SelectorEntry::Local, SelectorEntry::NewSsh]; let mut entries = vec![SelectorEntry::Local];
entries.extend( entries.extend(
self.config self.config
.sessions() .sessions()
@@ -1278,11 +1278,7 @@ impl Ashell {
} }
pub(crate) fn default_selector_index(&self) -> usize { pub(crate) fn default_selector_index(&self) -> usize {
if self.config.sessions().is_empty() { 0
0
} else {
2
}
} }
pub(crate) fn move_selector_selection(&mut self, delta: i32, cx: &mut Context<Self>) { pub(crate) fn move_selector_selection(&mut self, delta: i32, cx: &mut Context<Self>) {
@@ -1294,8 +1290,8 @@ impl Ashell {
let next = (current + delta).clamp(0, entries.len() as i32 - 1) as usize; let next = (current + delta).clamp(0, entries.len() as i32 - 1) as usize;
if next != self.selector_selection { if next != self.selector_selection {
self.selector_selection = next; self.selector_selection = next;
if next >= 2 { if next >= 1 {
self.selector_scroll_handle.scroll_to_item(next - 2); self.selector_scroll_handle.scroll_to_item(next - 1);
} }
cx.notify(); cx.notify();
} }
@@ -1317,10 +1313,6 @@ impl Ashell {
self.open_local(cx); self.open_local(cx);
window.close_dialog(cx); window.close_dialog(cx);
} }
SelectorEntry::NewSsh => {
window.close_dialog(cx);
self.open_new_ssh_dialog(window, cx);
}
SelectorEntry::Saved(session_id) => { SelectorEntry::Saved(session_id) => {
self.connect_saved_session(session_id, window, cx); self.connect_saved_session(session_id, window, cx);
window.close_dialog(cx); window.close_dialog(cx);
@@ -2111,12 +2103,28 @@ impl Ashell {
} }
pub(crate) fn active_ssh_session(&self) -> Option<(String, Session)> { pub(crate) fn active_ssh_session(&self) -> Option<(String, Session)> {
let active_id = self.active_tab.as_ref()?; if let Some(active_id) = self.active_tab.as_ref() {
let tab = self.tabs.iter().find(|tab| &tab.id == active_id)?; if let Some(tab) = self.tabs.iter().find(|tab| &tab.id == active_id) {
if !tab.connected { if tab.kind == TabKind::Ssh {
return None; if let Some(session) = tab.session.clone() {
return Some((tab.id.clone(), session));
}
}
}
} }
Some((tab.id.clone(), tab.session.clone()?)) if let Some(group_id) = self.active_group.as_ref() {
if let Some(group) = self.tab_groups.iter().find(|g| &g.id == group_id) {
let pane_tab_ids = group.pane_root.tab_ids();
for tab in &self.tabs {
if pane_tab_ids.contains(&tab.id.as_str()) && tab.kind == TabKind::Ssh {
if let Some(session) = tab.session.clone() {
return Some((tab.id.clone(), session));
}
}
}
}
}
None
} }
pub(crate) fn active_session_id(&self) -> Option<&str> { pub(crate) fn active_session_id(&self) -> Option<&str> {
@@ -2293,9 +2301,6 @@ impl Ashell {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
if self.focused_pane_path.is_empty() {
return;
}
let previous_active_tab = self.active_tab.clone(); let previous_active_tab = self.active_tab.clone();
let mut active_tab_changed = false; let mut active_tab_changed = false;
let path = self.focused_pane_path.clone(); let path = self.focused_pane_path.clone();
@@ -2319,6 +2324,28 @@ impl Ashell {
} }
} }
cx.notify(); cx.notify();
} else if (direction == "left" || direction == "right") && self.tab_groups.len() > 1 {
if let Some(current_group_id) = self.active_group.clone() {
if let Some(current_idx) = self
.tab_groups
.iter()
.position(|g| g.id == current_group_id)
{
let total_groups = self.tab_groups.len();
let target_idx = if direction == "left" {
if current_idx > 0 {
current_idx - 1
} else {
total_groups - 1
}
} else {
(current_idx + 1) % total_groups
};
let target_group_id = self.tab_groups[target_idx].id.clone();
self.activate_group(target_group_id, window, cx);
return;
}
}
} }
if active_tab_changed { if active_tab_changed {
self.update_terminal_focus(previous_active_tab.as_deref()); self.update_terminal_focus(previous_active_tab.as_deref());
@@ -2446,7 +2473,13 @@ impl Ashell {
} }
} }
// Load new group state // Load new group state
if let Some(group) = self.tab_groups.iter().find(|g| g.id == group_id) { if let Some((index, group)) = self
.tab_groups
.iter()
.enumerate()
.find(|(_, g)| g.id == group_id)
{
self.tabs_scroll_handle.scroll_to_item(index);
self.pane_root = group.pane_root.clone(); self.pane_root = group.pane_root.clone();
self.active_group = Some(group_id); self.active_group = Some(group_id);
if let Some(first_id) = group.pane_root.first_tab_id() { if let Some(first_id) = group.pane_root.first_tab_id() {
@@ -2511,7 +2544,7 @@ impl Ashell {
let Some(group_id) = self.active_group.clone() else { let Some(group_id) = self.active_group.clone() else {
return; return;
}; };
let target = self.active_tab.as_ref().and_then(|active_id| { let mut target = self.active_tab.as_ref().and_then(|active_id| {
self.tabs self.tabs
.iter() .iter()
.find(|tab| { .find(|tab| {
@@ -2519,12 +2552,49 @@ impl Ashell {
}) })
.and_then(|tab| tab.session.clone().map(|session| (tab.id.clone(), session))) .and_then(|tab| tab.session.clone().map(|session| (tab.id.clone(), session)))
}); });
let target_tab_id = target.as_ref().map(|(tab_id, _)| tab_id.as_str());
if target.is_none() {
if let Some(group) = self.tab_groups.iter().find(|g| g.id == group_id) {
let pane_tab_ids = group.pane_root.tab_ids();
target = self
.tabs
.iter()
.find(|tab| {
pane_tab_ids.contains(&tab.id.as_str())
&& tab.kind == TabKind::Ssh
&& (tab.connected || force)
})
.and_then(|tab| tab.session.clone().map(|session| (tab.id.clone(), session)));
}
}
let current_tab_id = self let current_tab_id = self
.tab_groups .tab_groups
.iter() .iter()
.find(|group| group.id == group_id) .find(|group| group.id == group_id)
.and_then(|group| group.sftp_tab_id.clone()); .and_then(|group| group.sftp_tab_id.clone());
let group_has_ssh = self
.tab_groups
.iter()
.find(|g| g.id == group_id)
.map(|g| {
let pane_tab_ids = g.pane_root.tab_ids();
self.tabs
.iter()
.any(|t| pane_tab_ids.contains(&t.id.as_str()) && t.kind == TabKind::Ssh)
})
.unwrap_or(false);
if target.is_none()
&& !force
&& group_has_ssh
&& (self.sftp_handles.contains_key(&group_id) || current_tab_id.is_some())
{
return;
}
let target_tab_id = target.as_ref().map(|(tab_id, _)| tab_id.as_str());
let current_session_id = current_tab_id.as_ref().and_then(|tab_id| { let current_session_id = current_tab_id.as_ref().and_then(|tab_id| {
self.tabs self.tabs
.iter() .iter()
@@ -2535,7 +2605,7 @@ impl Ashell {
let target_session_id = target.as_ref().map(|(_, session)| session.id.as_str()); let target_session_id = target.as_ref().map(|(_, session)| session.id.as_str());
if !force { if !force {
if current_tab_id.as_deref() == target_tab_id { if current_tab_id.as_deref() == target_tab_id && target_tab_id.is_some() {
return; return;
} }
if current_session_id.as_deref() == target_session_id if current_session_id.as_deref() == target_session_id
@@ -2580,12 +2650,51 @@ impl Ashell {
} }
pub(crate) fn sync_system_tab_to_active_group(&mut self) { pub(crate) fn sync_system_tab_to_active_group(&mut self) {
let active_ssh_tab = self.active_tab.as_ref().and_then(|id| { let active_group = self.active_group.as_ref().and_then(|group_id| {
self.tabs self.tab_groups.iter().find(|group| &group.id == group_id)
.iter()
.find(|tab| tab.id == *id && tab.kind == TabKind::Ssh)
}); });
let new_id = active_ssh_tab.map(|tab| tab.id.clone());
if let Some(current_sys_id) = &self.system_tab_id {
if let Some(group) = active_group {
let pane_tab_ids = group.pane_root.tab_ids();
if pane_tab_ids.contains(&current_sys_id.as_str()) {
if self
.tabs
.iter()
.any(|t| &t.id == current_sys_id && t.kind == TabKind::Ssh && t.connected)
{
return;
}
}
}
}
let new_id = self
.active_tab
.as_ref()
.and_then(|id| {
self.tabs
.iter()
.find(|tab| tab.id == *id && tab.kind == TabKind::Ssh)
.map(|tab| tab.id.clone())
})
.or_else(|| {
active_group.and_then(|group| {
let pane_tab_ids = group.pane_root.tab_ids();
self.tabs
.iter()
.find(|tab| {
pane_tab_ids.contains(&tab.id.as_str())
&& tab.kind == TabKind::Ssh
&& tab.connected
})
.map(|tab| tab.id.clone())
})
});
let active_ssh_tab = new_id
.as_ref()
.and_then(|id| self.tabs.iter().find(|tab| &tab.id == id && tab.kind == TabKind::Ssh));
let active_ssh_status = active_ssh_tab.and_then(|tab| { let active_ssh_status = active_ssh_tab.and_then(|tab| {
(!tab.connected).then(|| { (!tab.connected).then(|| {
tab.disconnected_reason tab.disconnected_reason
@@ -2596,13 +2705,24 @@ impl Ashell {
if self.system_tab_id != new_id { if self.system_tab_id != new_id {
self.system_tab_id = new_id; self.system_tab_id = new_id;
self.reset_system_monitor_state(); self.remote_sample_in_flight = false;
self.remote_processes_in_flight = false;
self.remote_ports_in_flight = false;
if let Some(status) = active_ssh_status { if let Some(status) = active_ssh_status {
self.system_status = Some(status.clone().into()); self.system_status = Some(status.clone().into());
self.remote_process_status = Some(status.into()); self.remote_process_status = Some(status.into());
self.system = crate::system::SystemSnapshot::default();
self.cpu_history.clear();
self.net_rx_history.clear();
self.net_tx_history.clear();
self.remote_processes.clear();
self.remote_ports.clear();
self.last_applied_system_tab_id = self.system_tab_id.clone();
} else { } else {
self.system_status = None; self.system_status = None;
} }
self.request_active_system_snapshot(); self.request_active_system_snapshot();
if self.active_dialog == Some(crate::app::DialogKind::Processes) { if self.active_dialog == Some(crate::app::DialogKind::Processes) {
self.request_active_process_snapshot(); self.request_active_process_snapshot();
+129 -40
View File
@@ -12,6 +12,7 @@ use gpui_component::ActiveTheme as _;
use crate::Ashell; use crate::Ashell;
use crate::terminal::custom_blocks::{is_custom_block_supported, paint_custom_block}; use crate::terminal::custom_blocks::{is_custom_block_supported, paint_custom_block};
use crate::terminal::highlight::HighlightStyle;
use crate::terminal::{RenderSnapshot, ViewportSelection}; use crate::terminal::{RenderSnapshot, ViewportSelection};
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -338,21 +339,16 @@ impl TerminalElement {
} }
} }
fn cell_run_style(&self, cell: &alacritty_terminal::term::cell::Cell, cx: &App) -> TextRun { fn cell_run_style(
let mut fg = color_to_hsla(cell.fg, true, cx); &self,
let mut bg = color_to_hsla(cell.bg, false, cx); cell: &alacritty_terminal::term::cell::Cell,
if cell.flags.contains(Flags::INVERSE) { foreground: Hsla,
std::mem::swap(&mut fg, &mut bg); ) -> TextRun {
}
if cell.flags.contains(Flags::DIM) {
fg.a *= 0.7;
}
let underline = cell let underline = cell
.flags .flags
.intersects(Flags::ALL_UNDERLINES) .intersects(Flags::ALL_UNDERLINES)
.then(|| UnderlineStyle { .then(|| UnderlineStyle {
color: Some(fg), color: Some(foreground),
thickness: px(1.0), thickness: px(1.0),
wavy: cell.flags.contains(Flags::UNDERCURL), wavy: cell.flags.contains(Flags::UNDERCURL),
}); });
@@ -360,7 +356,7 @@ impl TerminalElement {
.flags .flags
.contains(Flags::STRIKEOUT) .contains(Flags::STRIKEOUT)
.then(|| StrikethroughStyle { .then(|| StrikethroughStyle {
color: Some(fg), color: Some(foreground),
thickness: px(1.0), thickness: px(1.0),
}); });
@@ -377,7 +373,7 @@ impl TerminalElement {
TextRun { TextRun {
len: cell.c.len_utf8(), len: cell.c.len_utf8(),
color: fg, color: foreground,
background_color: None, background_color: None,
font: Font { font: Font {
family: self.font_family.clone(), family: self.font_family.clone(),
@@ -408,12 +404,6 @@ impl TerminalElement {
let mut underlines = Vec::new(); let mut underlines = Vec::new();
let mut current_run: Option<BatchedTextRun> = None; let mut current_run: Option<BatchedTextRun> = None;
// Retrieve cached keyword highlights and merge with search highlights
let mut highlights = self.snapshot.highlights.clone();
if let Some(sm) = self.search_highlights.as_ref() {
highlights.extend(sm.iter().map(|(k, v)| (*k, *v)));
}
for render_cell in &self.snapshot.cells { for render_cell in &self.snapshot.cells {
let cell = &render_cell.cell; let cell = &render_cell.cell;
if cell.flags.intersects( if cell.flags.intersects(
@@ -425,24 +415,54 @@ impl TerminalElement {
let selected = self.snapshot.selection.is_some_and(|selection| { let selected = self.snapshot.selection.is_some_and(|selection| {
selection_contains(selection, render_cell.row, render_cell.col) selection_contains(selection, render_cell.row, render_cell.col)
}); });
let bg = color_to_hsla(cell.bg, false, cx); let cell_position = (render_cell.row, render_cell.col);
if selected || !is_default_bg(cell.bg) || cell.flags.contains(Flags::INVERSE) { 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 { rects.push(LayoutRect {
row: render_cell.row, row: render_cell.row,
col: render_cell.col, col: render_cell.col,
cells: if cell.flags.contains(Flags::WIDE_CHAR) { cells: cell_span,
2 color: cx.theme().selection,
} else {
1
},
color: if selected {
cx.theme().selection
} else if cell.flags.contains(Flags::INVERSE) {
color_to_hsla(cell.fg, true, cx)
} else {
bg
},
}); });
} 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) { if is_blank(cell) {
@@ -452,12 +472,7 @@ impl TerminalElement {
continue; continue;
} }
let mut style = self.cell_run_style(cell, cx); let style = self.cell_run_style(cell, foreground);
// Apply keyword highlight color if this cell was matched.
if let Some(&hl_color) = highlights.get(&(render_cell.row, render_cell.col)) {
style.color = hl_color;
}
// Apply hover underline if mouse is hovering over this URL // Apply hover underline if mouse is hovering over this URL
if let Some(hu) = &hovered_url { if let Some(hu) = &hovered_url {
@@ -871,6 +886,31 @@ fn selection_contains(selection: ViewportSelection, row: i32, col: i32) -> bool
after_start && before_end 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 { fn is_blank(cell: &alacritty_terminal::term::cell::Cell) -> bool {
cell.c == ' ' cell.c == ' '
&& cell.zerowidth().is_none() && cell.zerowidth().is_none()
@@ -965,7 +1005,14 @@ fn named_color(named: NamedColor, _foreground: bool, cx: &App) -> Hsla {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::should_render_cursor; 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] #[test]
fn renders_cursor_only_for_focused_pane_in_focused_terminal_window() { fn renders_cursor_only_for_focused_pane_in_focused_terminal_window() {
@@ -974,4 +1021,46 @@ mod tests {
assert!(!should_render_cursor(true, false, true)); assert!(!should_render_cursor(true, false, true));
assert!(!should_render_cursor(true, true, false)); 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);
}
} }
+154 -82
View File
@@ -1,41 +1,19 @@
use crate::terminal::RenderCell; use crate::terminal::RenderCell;
use gpui::Hsla; use gpui::{Hsla, Rgba};
use std::collections::HashMap; use std::collections::HashMap;
trait HslaExt { #[derive(Debug, Clone, Copy, Default, PartialEq)]
fn into_rgba_like(self, r: u8, g: u8, b: u8) -> Self; pub struct HighlightStyle {
pub foreground: Option<Hsla>,
pub background: Option<Hsla>,
} }
impl HslaExt for Hsla { impl HighlightStyle {
fn into_rgba_like(self, r: u8, g: u8, b: u8) -> Self { fn from_foreground(color: Hsla) -> Self {
let rf = r as f32 / 255.0; Self {
let gf = g as f32 / 255.0; foreground: Some(color),
let bf = b as f32 / 255.0; background: None,
let max = rf.max(gf).max(bf);
let min = rf.min(gf).min(bf);
let l = (max + min) / 2.0;
if max == min {
return Hsla {
h: 0.0,
s: 0.0,
l,
a: 1.0,
};
} }
let d = max - min;
let s = if l > 0.5 {
d / (2.0 - max - min)
} else {
d / (max + min)
};
let h = if max == rf {
((gf - bf) / d + if gf < bf { 6.0 } else { 0.0 }) / 6.0
} else if max == gf {
((bf - rf) / d + 2.0) / 6.0
} else {
((rf - gf) / d + 4.0) / 6.0
};
Hsla { h, s, l, a: 1.0 }
} }
} }
@@ -97,13 +75,12 @@ struct HighlightColors {
} }
fn hsla(r: u8, g: u8, b: u8) -> Hsla { fn hsla(r: u8, g: u8, b: u8) -> Hsla {
Hsla { Hsla::from(Rgba {
h: 0.0, r: r as f32 / 255.0,
s: 0.0, g: g as f32 / 255.0,
l: 0.0, b: b as f32 / 255.0,
a: 1.0, a: 1.0,
} })
.into_rgba_like(r, g, b)
} }
fn highlight_colors() -> HighlightColors { fn highlight_colors() -> HighlightColors {
@@ -168,7 +145,7 @@ fn highlight_colors() -> HighlightColors {
struct KeywordMatch { struct KeywordMatch {
start_col: i32, start_col: i32,
end_col: i32, end_col: i32,
color: Hsla, style: HighlightStyle,
priority: usize, priority: usize,
} }
@@ -184,7 +161,7 @@ impl KeywordMatch {
#[derive(Default)] #[derive(Default)]
struct KeywordHighlights { struct KeywordHighlights {
colors: HashMap<(i32, i32), Hsla>, styles: HashMap<(i32, i32), HighlightStyle>,
pending: Vec<KeywordMatch>, pending: Vec<KeywordMatch>,
next_priority: usize, next_priority: usize,
} }
@@ -214,7 +191,7 @@ impl KeywordHighlights {
for candidate in selected { for candidate in selected {
for col in candidate.start_col..=candidate.end_col { for col in candidate.start_col..=candidate.end_col {
self.colors.insert((row_i32, col), candidate.color); self.styles.insert((row_i32, col), candidate.style);
} }
} }
} }
@@ -262,7 +239,7 @@ fn highlight_keywords(
map.pending.push(KeywordMatch { map.pending.push(KeywordMatch {
start_col, start_col,
end_col, end_col,
color, style: HighlightStyle::from_foreground(color),
priority, priority,
}); });
start = abs + kw.len(); start = abs + kw.len();
@@ -276,7 +253,7 @@ fn highlight_keywords(
/// Highlight HTTP status codes (200, 301, 404, 500, etc.) /// Highlight HTTP status codes (200, 301, 404, 500, etc.)
/// Only matches specific common HTTP codes, not all 3-digit numbers. /// Only matches specific common HTTP codes, not all 3-digit numbers.
fn highlight_http_codes( fn highlight_http_codes(
map: &mut HashMap<(i32, i32), Hsla>, map: &mut HashMap<(i32, i32), HighlightStyle>,
text: &str, text: &str,
byte_to_col: &[i32], byte_to_col: &[i32],
row_i32: i32, row_i32: i32,
@@ -352,12 +329,13 @@ fn highlight_http_codes(
let start_col = byte_to_col[i]; let start_col = byte_to_col[i];
let end_col = byte_to_col[(i + 2).min(byte_to_col.len() - 1)]; let end_col = byte_to_col[(i + 2).min(byte_to_col.len() - 1)];
for c in start_col..=end_col { for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(color); map.entry((row_i32, c))
.or_insert_with(|| HighlightStyle::from_foreground(color));
} }
} }
} }
pub fn highlight_cells(cells: &[RenderCell], rows: usize) -> HashMap<(i32, i32), Hsla> { pub fn highlight_cells(cells: &[RenderCell], rows: usize) -> HashMap<(i32, i32), HighlightStyle> {
let colors = highlight_colors(); let colors = highlight_colors();
let mut row_chars: Vec<Vec<(i32, char)>> = vec![Vec::with_capacity(128); rows]; let mut row_chars: Vec<Vec<(i32, char)>> = vec![Vec::with_capacity(128); rows];
@@ -365,7 +343,9 @@ pub fn highlight_cells(cells: &[RenderCell], rows: usize) -> HashMap<(i32, i32),
if rc.row < 0 || (rc.row as usize) >= rows { if rc.row < 0 || (rc.row as usize) >= rows {
continue; continue;
} }
row_chars[rc.row as usize].push((rc.col, rc.cell.c)); if let Some(character) = rendered_text_character(&rc.cell) {
row_chars[rc.row as usize].push((rc.col, character));
}
} }
for row in row_chars.iter_mut() { for row in row_chars.iter_mut() {
row.sort_by_key(|&(col, _)| col); row.sort_by_key(|&(col, _)| col);
@@ -852,7 +832,7 @@ pub fn highlight_cells(cells: &[RenderCell], rows: usize) -> HashMap<(i32, i32),
map.apply_row(row_i32); map.apply_row(row_i32);
// ── 30. HTTP status codes ────────────────────────────── // ── 30. HTTP status codes ──────────────────────────────
highlight_http_codes(&mut map.colors, text, &byte_to_col, row_i32, &colors); highlight_http_codes(&mut map.styles, text, &byte_to_col, row_i32, &colors);
// ── 31. IP addresses ─────────────────────────────────── // ── 31. IP addresses ───────────────────────────────────
for m in find_ip_addresses(text) { for m in find_ip_addresses(text) {
@@ -860,7 +840,9 @@ pub fn highlight_cells(cells: &[RenderCell], rows: usize) -> HashMap<(i32, i32),
let start_col = byte_to_col[m]; let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + ip_len - 1).min(byte_to_col.len() - 1)]; let end_col = byte_to_col[(m + ip_len - 1).min(byte_to_col.len() - 1)];
for c in start_col..=end_col { for c in start_col..=end_col {
map.colors.entry((row_i32, c)).or_insert(colors.network); map.styles
.entry((row_i32, c))
.or_insert_with(|| HighlightStyle::from_foreground(colors.network));
} }
} }
@@ -870,7 +852,9 @@ pub fn highlight_cells(cells: &[RenderCell], rows: usize) -> HashMap<(i32, i32),
let start_col = byte_to_col[m]; let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + port_len - 1).min(byte_to_col.len() - 1)]; let end_col = byte_to_col[(m + port_len - 1).min(byte_to_col.len() - 1)];
for c in start_col..=end_col { for c in start_col..=end_col {
map.colors.entry((row_i32, c)).or_insert(colors.port); map.styles
.entry((row_i32, c))
.or_insert_with(|| HighlightStyle::from_foreground(colors.port));
} }
} }
} }
@@ -884,13 +868,15 @@ pub fn highlight_cells(cells: &[RenderCell], rows: usize) -> HashMap<(i32, i32),
let idx = m + i; let idx = m + i;
if idx < line.byte_to_cell.len() { if idx < line.byte_to_cell.len() {
let (r, c) = line.byte_to_cell[idx]; let (r, c) = line.byte_to_cell[idx];
map.colors.entry((r as i32, c as i32)).or_insert(colors.url); map.styles
.entry((r as i32, c as i32))
.or_insert_with(|| HighlightStyle::from_foreground(colors.url));
} }
} }
} }
} }
map.colors map.styles
} }
fn find_ip_len(text: &str) -> usize { fn find_ip_len(text: &str) -> usize {
@@ -967,32 +953,69 @@ fn find_urls(text: &str) -> Vec<usize> {
} }
fn find_url_len(text: &str) -> usize { fn find_url_len(text: &str) -> usize {
let end = text let mut end = text.len();
.find(|c: char| c.is_ascii_whitespace()) let mut expected_closings = Vec::new();
.unwrap_or(text.len()); for (index, character) in text.char_indices() {
let mut url = &text[..end]; if is_url_terminator(character) {
end = index;
loop {
let Some(&closing) = url.as_bytes().last() else {
break;
};
let opening = match closing {
b')' => b'(',
b']' => b'[',
b'}' => b'{',
_ => break,
};
let opening_count = url.bytes().filter(|byte| *byte == opening).count();
let closing_count = url.bytes().filter(|byte| *byte == closing).count();
if closing_count <= opening_count {
break; break;
} }
url = &url[..url.len() - 1];
match character {
'(' => expected_closings.push(')'),
'[' => expected_closings.push(']'),
'{' => expected_closings.push('}'),
')' | ']' | '}' => {
if expected_closings.last() == Some(&character) {
expected_closings.pop();
} else {
end = index;
break;
}
}
_ => {}
}
}
let mut url = &text[..end];
while let Some(character) = url.chars().next_back() {
if !matches!(character, ',' | '.' | ';' | ':' | '!' | '?') {
break;
}
url = &url[..url.len() - character.len_utf8()];
} }
url.len() url.len()
} }
fn is_url_terminator(character: char) -> bool {
character.is_whitespace()
|| matches!(
character,
'"' | '\''
| '`'
| '<'
| '>'
| ','
| '。'
| ';'
| ':'
| '!'
| '?'
| '、'
| '('
| ')'
| '【'
| '】'
| '《'
| '》'
| '“'
| '”'
| '‘'
| '’'
)
}
fn find_ports(text: &str) -> Vec<usize> { fn find_ports(text: &str) -> Vec<usize> {
let mut positions = Vec::new(); let mut positions = Vec::new();
let bytes = text.as_bytes(); let bytes = text.as_bytes();
@@ -1090,10 +1113,12 @@ pub fn build_logical_lines<'a>(cells: &'a [RenderCell], rows: usize) -> Vec<Logi
}); });
for rc in row_cells { for rc in row_cells {
line.text.push(rc.cell.c); if let Some(character) = rendered_text_character(&rc.cell) {
let end_len = line.text.len(); line.text.push(character);
while line.byte_to_cell.len() < end_len { let end_len = line.text.len();
line.byte_to_cell.push((rc.row as usize, rc.col as usize)); while line.byte_to_cell.len() < end_len {
line.byte_to_cell.push((rc.row as usize, rc.col as usize));
}
} }
line.row_cells.push(rc); line.row_cells.push(rc);
} }
@@ -1108,6 +1133,21 @@ pub fn build_logical_lines<'a>(cells: &'a [RenderCell], rows: usize) -> Vec<Logi
logical_lines logical_lines
} }
fn rendered_text_character(cell: &alacritty_terminal::term::cell::Cell) -> Option<char> {
use alacritty_terminal::term::cell::Flags;
if cell
.flags
.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
{
None
} else if cell.flags.contains(Flags::HIDDEN) {
Some(' ')
} else {
Some(cell.c)
}
}
pub fn find_url_at_cell( pub fn find_url_at_cell(
cells: &[RenderCell], cells: &[RenderCell],
rows: usize, rows: usize,
@@ -1142,7 +1182,7 @@ pub fn find_url_at_cell(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{KeywordHighlights, KeywordMatch, find_url_len, hsla}; use super::{HighlightStyle, KeywordHighlights, KeywordMatch, find_url_len, hsla};
fn detected_url(text: &str) -> &str { fn detected_url(text: &str) -> &str {
&text[..find_url_len(text)] &text[..find_url_len(text)]
@@ -1172,6 +1212,28 @@ mod tests {
); );
} }
#[test]
fn stops_before_text_after_an_unmatched_closing_delimiter() {
assert_eq!(
detected_url(
"https://github.com/gnachman/iTerm2/blob/master/sources/PTYSession/PTYSession.m#L6371-L6380)、PTYTab.m"
),
"https://github.com/gnachman/iTerm2/blob/master/sources/PTYSession/PTYSession.m#L6371-L6380"
);
}
#[test]
fn excludes_chinese_and_ascii_trailing_punctuation() {
assert_eq!(
detected_url("https://example.com/path、后续文字"),
"https://example.com/path"
);
assert_eq!(
detected_url("https://example.com/path, next"),
"https://example.com/path"
);
}
#[test] #[test]
fn overlapping_keywords_keep_the_longest_match_color() { fn overlapping_keywords_keep_the_longest_match_color() {
let long_color = hsla(10, 20, 30); let long_color = hsla(10, 20, 30);
@@ -1181,21 +1243,26 @@ mod tests {
KeywordMatch { KeywordMatch {
start_col: 3, start_col: 3,
end_col: 11, end_col: 11,
color: short_color, style: HighlightStyle::from_foreground(short_color),
priority: 0, priority: 0,
}, },
KeywordMatch { KeywordMatch {
start_col: 0, start_col: 0,
end_col: 11, end_col: 11,
color: long_color, style: HighlightStyle::from_foreground(long_color),
priority: 1, priority: 1,
}, },
]; ];
highlights.apply_row(0); highlights.apply_row(0);
assert_eq!(highlights.colors.len(), 12); assert_eq!(highlights.styles.len(), 12);
assert!(highlights.colors.values().all(|color| *color == long_color)); assert!(
highlights
.styles
.values()
.all(|style| style.foreground == Some(long_color) && style.background.is_none())
);
} }
#[test] #[test]
@@ -1208,8 +1275,13 @@ mod tests {
super::highlight_keywords(&mut highlights, text, &byte_to_col, 0, &["BOOT"], color); super::highlight_keywords(&mut highlights, text, &byte_to_col, 0, &["BOOT"], color);
highlights.apply_row(0); highlights.apply_row(0);
assert_eq!(highlights.colors.len(), 4); assert_eq!(highlights.styles.len(), 4);
assert!((0..9).all(|col| !highlights.colors.contains_key(&(0, col)))); assert!((0..9).all(|col| !highlights.styles.contains_key(&(0, col))));
assert!((10..14).all(|col| highlights.colors.get(&(0, col)) == Some(&color))); assert!((10..14).all(|col| {
highlights
.styles
.get(&(0, col))
.is_some_and(|style| style.foreground == Some(color) && style.background.is_none())
}));
} }
} }
+101 -26
View File
@@ -209,7 +209,7 @@ impl Ashell {
} }
tab.clear_selection(); tab.clear_selection();
tab.record_terminal_input(&bytes); tab.prepare_for_terminal_input();
let encoded = tab.encode_input(&bytes); let encoded = tab.encode_input(&bytes);
tab.send_backend(BackendCommand::Input(encoded)); tab.send_backend(BackendCommand::Input(encoded));
window.prevent_default(); window.prevent_default();
@@ -325,7 +325,7 @@ impl Ashell {
} }
tab.clear_selection(); tab.clear_selection();
self.terminal_marked_text = None; self.terminal_marked_text = None;
tab.record_terminal_input(&bytes); tab.prepare_for_terminal_input();
let encoded = tab.encode_input(&bytes); let encoded = tab.encode_input(&bytes);
tab.send_backend(BackendCommand::Input(encoded)); tab.send_backend(BackendCommand::Input(encoded));
window.invalidate_character_coordinates(); window.invalidate_character_coordinates();
@@ -345,7 +345,7 @@ impl Ashell {
}; };
( (
session.id.clone(), session.id.clone(),
tab.cursor_state().map(|cursor| (cursor.row, cursor.col)), tab.buffer_cursor_position(),
tab.is_alternate_screen_active(), tab.is_alternate_screen_active(),
) )
}; };
@@ -1284,6 +1284,11 @@ fn terminal_command_text(
start: (usize, usize), start: (usize, usize),
end: Option<(usize, usize)>, end: Option<(usize, usize)>,
) -> Option<String> { ) -> Option<String> {
let start = buffer_position_in_viewport(snapshot, start)?;
let end = match end {
Some(position) => Some(buffer_position_in_viewport(snapshot, position)?),
None => None,
};
let logical_lines = let logical_lines =
crate::terminal::highlight::build_logical_lines(&snapshot.cells, snapshot.rows); crate::terminal::highlight::build_logical_lines(&snapshot.cells, snapshot.rows);
for line in logical_lines { for line in logical_lines {
@@ -1315,42 +1320,38 @@ fn terminal_command_text(
None None
} }
fn buffer_position_in_viewport(
snapshot: &crate::terminal::RenderSnapshot,
position: (usize, usize),
) -> Option<(usize, usize)> {
let viewport_start = snapshot
.history_size
.saturating_sub(snapshot.display_offset);
let row = position.0.checked_sub(viewport_start)?;
(row < snapshot.rows && position.1 < snapshot.cols).then_some((row, position.1))
}
fn command_history_text(rendered: Option<&str>, buffered: &str, input_uncertain: bool) -> String { fn command_history_text(rendered: Option<&str>, buffered: &str, input_uncertain: bool) -> String {
let rendered = rendered.unwrap_or_default().trim();
let buffered = buffered.trim(); let buffered = buffered.trim();
if !input_uncertain && !buffered.is_empty() { if !input_uncertain && !buffered.is_empty() {
return buffered.to_string(); return buffered.to_string();
} }
merge_command_text(rendered, buffered)
}
fn merge_command_text(rendered: Option<&str>, buffered: &str) -> String {
let rendered = rendered.unwrap_or_default().trim();
let buffered = buffered.trim();
if rendered.is_empty() { if rendered.is_empty() {
return buffered.to_string(); return buffered.to_string();
} }
if buffered.is_empty() { if buffered.is_empty() {
return rendered.to_string(); return rendered.to_string();
} }
if rendered.starts_with(buffered) || rendered.ends_with(buffered) { // Completion extends the current token; a new argument after exact raw input is stale content.
return rendered.to_string(); if rendered
} .strip_prefix(buffered)
if buffered.starts_with(rendered) || buffered.ends_with(rendered) { .and_then(|suffix| suffix.chars().next())
.is_some_and(char::is_whitespace)
{
return buffered.to_string(); return buffered.to_string();
} }
rendered.to_string()
let overlap = buffered
.char_indices()
.map(|(index, _)| index)
.chain(std::iter::once(buffered.len()))
.filter(|index| *index > 0 && rendered.ends_with(&buffered[..*index]))
.max()
.unwrap_or(0);
if overlap > 0 {
format!("{rendered}{}", &buffered[overlap..])
} else {
rendered.to_string()
}
} }
#[cfg(test)] #[cfg(test)]
@@ -1581,6 +1582,58 @@ mod tests {
); );
} }
#[test]
fn rejects_whitespace_delimited_screen_suffix_when_input_is_uncertain() {
let command = "sh /site/jimureport/jimureport-restart.sh";
let rendered = format!("{command} /sijiji-r");
assert_eq!(
command_history_text(Some(rendered.as_str()), command, true),
command
);
}
#[test]
fn does_not_append_tab_completion_keystroke_fragments() {
let command = "sh /site/jimureport/jimureport-restart.sh";
let buffered = "sh /sijiji-r";
assert_eq!(command_history_text(Some(command), buffered, true), command);
}
#[test]
fn falls_back_to_raw_input_when_uncertain_screen_text_is_unavailable() {
let command = "sh /site/jimureport/jimureport-restart.sh";
assert_eq!(command_history_text(None, command, true), command);
}
#[test]
fn keeps_screen_completion_that_extends_the_current_argument() {
let buffered = "sh /site/jimu";
let rendered = "sh /site/jimureport/jimureport-restart.sh";
assert_eq!(
command_history_text(Some(rendered), buffered, true),
rendered
);
}
#[test]
fn excludes_hidden_screen_residue_from_rendered_command() {
let command = "sh /site/jimureport/jimureport-restart.sh";
let rendered = format!("$ {command} /sijiji-r");
let mut snapshot = snapshot(&[rendered.as_str()], rendered.len());
for cell in snapshot.cells.iter_mut().skip(2 + command.chars().count()) {
cell.cell.flags.insert(Flags::HIDDEN);
}
assert_eq!(
terminal_command_text(&snapshot, (0, 2), None),
Some(command.to_string())
);
}
#[test] #[test]
fn truncates_rendered_command_at_the_submission_cursor() { fn truncates_rendered_command_at_the_submission_cursor() {
let command = "sh /site/vocano/vocano-restart.sh"; let command = "sh /site/vocano/vocano-restart.sh";
@@ -1592,4 +1645,26 @@ mod tests {
Some(command.to_string()) Some(command.to_string())
); );
} }
#[test]
fn keeps_command_coordinates_stable_when_wrapping_scrolls_the_viewport() {
let first_row = "$ sh /site/jimureport/jimureport-";
let second_row = "restart.sh /sijiji-r";
let mut snapshot = snapshot(&[first_row, second_row], first_row.len());
snapshot.history_size = 3;
snapshot.display_offset = 2;
snapshot
.cells
.iter_mut()
.find(|cell| cell.row == 0 && cell.col == first_row.len() as i32 - 1)
.unwrap()
.cell
.flags
.insert(Flags::WRAPLINE);
assert_eq!(
terminal_command_text(&snapshot, (1, 2), Some((2, "restart.sh".len()))),
Some("sh /site/jimureport/jimureport-restart.sh".to_string())
);
}
} }
+85 -39
View File
@@ -37,7 +37,7 @@ pub enum TabKind {
Serial, Serial,
} }
const TERMINAL_ACTIVITY_GRACE: Duration = Duration::from_millis(750); const TERMINAL_ACTIVITY_GRACE: Duration = Duration::from_secs(2);
const CLICK_CURSOR_PREDICTION_TTL: Duration = Duration::from_millis(750); const CLICK_CURSOR_PREDICTION_TTL: Duration = Duration::from_millis(750);
const MAX_OSC_PAYLOAD_BYTES: usize = 4096; const MAX_OSC_PAYLOAD_BYTES: usize = 4096;
const MAX_NOTIFICATION_TEXT_BYTES: usize = 8192; const MAX_NOTIFICATION_TEXT_BYTES: usize = 8192;
@@ -242,27 +242,39 @@ struct OscTerminalParser {
pending_osc99: HashMap<String, PendingOsc99Notification>, pending_osc99: HashMap<String, PendingOsc99Notification>,
} }
struct OscTerminalScan {
events: Vec<(usize, OscTerminalEvent)>,
has_terminal_output: bool,
}
impl OscTerminalParser { impl OscTerminalParser {
/// Scans decoded terminal output without consuming it from the terminal emulator. /// Scans decoded terminal output without consuming it from the terminal emulator.
#[cfg(test)] #[cfg(test)]
fn advance(&mut self, bytes: &[u8]) -> Vec<OscTerminalEvent> { fn advance(&mut self, bytes: &[u8]) -> Vec<OscTerminalEvent> {
self.advance_with_offsets(bytes) self.advance_with_offsets(bytes)
.events
.into_iter() .into_iter()
.map(|(_, event)| event) .map(|(_, event)| event)
.collect() .collect()
} }
fn advance_with_offsets(&mut self, bytes: &[u8]) -> Vec<(usize, OscTerminalEvent)> { fn advance_with_offsets(&mut self, bytes: &[u8]) -> OscTerminalScan {
let mut events = Vec::new(); let mut events = Vec::new();
let mut has_terminal_output = false;
for (index, &byte) in bytes.iter().enumerate() { for (index, &byte) in bytes.iter().enumerate() {
match self.state { match self.state {
OscTerminalState::Ground => { OscTerminalState::Ground => {
if byte == 0x1b { if byte == 0x1b {
self.state = OscTerminalState::Escape; self.state = OscTerminalState::Escape;
} else {
has_terminal_output = true;
} }
} }
OscTerminalState::Escape => { OscTerminalState::Escape => {
if byte != b']' && byte != 0x1b {
has_terminal_output = true;
}
self.state = match byte { self.state = match byte {
b']' => { b']' => {
self.command.clear(); self.command.clear();
@@ -333,7 +345,10 @@ impl OscTerminalParser {
} }
} }
events OscTerminalScan {
events,
has_terminal_output,
}
} }
fn push_payload_byte(&mut self, byte: u8) { fn push_payload_byte(&mut self, byte: u8) {
@@ -869,8 +884,6 @@ pub struct TerminalTab {
output_decoder: StreamingDecoder, output_decoder: StreamingDecoder,
osc_terminal_parser: OscTerminalParser, osc_terminal_parser: OscTerminalParser,
output_activity_until: Option<Instant>, output_activity_until: Option<Instant>,
command_running: bool,
shell_integration_available: bool,
processor: Processor, processor: Processor,
term: Term<TerminalListener>, term: Term<TerminalListener>,
pub cols: u16, pub cols: u16,
@@ -887,7 +900,7 @@ pub struct TerminalTab {
type HighlightCache = std::cell::RefCell< type HighlightCache = std::cell::RefCell<
Option<( Option<(
Vec<RenderCell>, Vec<RenderCell>,
std::collections::HashMap<(i32, i32), gpui::Hsla>, std::collections::HashMap<(i32, i32), self::highlight::HighlightStyle>,
)>, )>,
>; >;
@@ -940,7 +953,7 @@ pub struct RenderSnapshot {
pub history_size: usize, pub history_size: usize,
pub rows: usize, pub rows: usize,
pub cols: usize, pub cols: usize,
pub highlights: std::collections::HashMap<(i32, i32), gpui::Hsla>, pub highlights: std::collections::HashMap<(i32, i32), self::highlight::HighlightStyle>,
} }
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -1283,7 +1296,7 @@ mod terminal_tab_backend_tests {
); );
tab.note_click_cursor_move_at(predicted, now); tab.note_click_cursor_move_at(predicted, now);
tab.record_terminal_input(b"x"); tab.prepare_for_terminal_input();
assert_eq!(tab.cursor_state_for_click_at(now), actual); assert_eq!(tab.cursor_state_for_click_at(now), actual);
} }
} }
@@ -1542,6 +1555,60 @@ mod osc_terminal_tests {
); );
} }
#[test]
fn distinguishes_terminal_output_from_osc_signaling() {
let mut parser = OscTerminalParser::default();
let shell_marker = parser.advance_with_offsets(b"\x1b]133;C\x07");
assert!(!shell_marker.has_terminal_output);
assert_eq!(
shell_marker.events,
vec![(b"\x1b]133;C\x07".len(), OscTerminalEvent::CommandStarted)]
);
let output = parser.advance_with_offsets(b"server ready\r\n");
assert!(output.has_terminal_output);
assert!(output.events.is_empty());
let mixed = parser.advance_with_offsets(b"built\r\n\x1b]133;D;0\x07");
assert!(mixed.has_terminal_output);
assert_eq!(mixed.events.len(), 1);
}
#[test]
fn excludes_osc_signaling_split_across_output_chunks() {
let mut parser = OscTerminalParser::default();
assert!(
!parser
.advance_with_offsets(b"\x1b]133;")
.has_terminal_output
);
let completed = parser.advance_with_offsets(b"C\x07");
assert!(!completed.has_terminal_output);
assert_eq!(completed.events.len(), 1);
}
#[test]
fn shell_integration_markers_do_not_drive_tab_output_activity() {
let (events_tx, _events_rx) = mpsc::channel();
let mut tab = TerminalTab::new_local(
"tab-1".into(),
"Local".into(),
BackendTx::Pending,
GuardedBackendEventSender::new(events_tx),
);
tab.feed(b"\x1b]133;C\x07");
assert!(!tab.has_recent_output());
tab.feed(b"server ready\r\n");
assert!(tab.has_recent_output());
tab.feed(b"\x1b]133;D;0\x07");
assert!(tab.has_recent_output());
}
#[test] #[test]
fn parses_prompt_click_modes_and_preserves_event_offsets() { fn parses_prompt_click_modes_and_preserves_event_offsets() {
let mut parser = OscTerminalParser::default(); let mut parser = OscTerminalParser::default();
@@ -1549,6 +1616,7 @@ mod osc_terminal_tests {
let (event_end, event) = parser let (event_end, event) = parser
.advance_with_offsets(bytes) .advance_with_offsets(bytes)
.events
.into_iter() .into_iter()
.next() .next()
.expect("prompt marker event"); .expect("prompt marker event");
@@ -1807,8 +1875,6 @@ impl TerminalTab {
output_decoder: StreamingDecoder::new(TextEncoding::Utf8), output_decoder: StreamingDecoder::new(TextEncoding::Utf8),
osc_terminal_parser: OscTerminalParser::default(), osc_terminal_parser: OscTerminalParser::default(),
output_activity_until: None, output_activity_until: None,
command_running: false,
shell_integration_available: false,
processor: Processor::new(), processor: Processor::new(),
term: new_term(100, 30, shared_backend.clone(), id, events.clone()), term: new_term(100, 30, shared_backend.clone(), id, events.clone()),
cols: 100, cols: 100,
@@ -1825,12 +1891,13 @@ impl TerminalTab {
pub fn feed(&mut self, bytes: &[u8]) -> Vec<TerminalNotification> { pub fn feed(&mut self, bytes: &[u8]) -> Vec<TerminalNotification> {
let decoded = self.output_decoder.decode(bytes); let decoded = self.output_decoder.decode(bytes);
if !decoded.is_empty() { let scan = self.osc_terminal_parser.advance_with_offsets(&decoded);
if scan.has_terminal_output {
self.output_activity_until = Some(Instant::now() + TERMINAL_ACTIVITY_GRACE); self.output_activity_until = Some(Instant::now() + TERMINAL_ACTIVITY_GRACE);
} }
let mut notifications = Vec::new(); let mut notifications = Vec::new();
let mut processed_until = 0; let mut processed_until = 0;
for (event_end, event) in self.osc_terminal_parser.advance_with_offsets(&decoded) { for (event_end, event) in scan.events {
self.processor self.processor
.advance(&mut self.term, &decoded[processed_until..event_end]); .advance(&mut self.term, &decoded[processed_until..event_end]);
self.handle_osc_terminal_event(event, &mut notifications); self.handle_osc_terminal_event(event, &mut notifications);
@@ -1850,8 +1917,6 @@ impl TerminalTab {
) { ) {
match event { match event {
OscTerminalEvent::Notification(notification) => { OscTerminalEvent::Notification(notification) => {
self.command_running = false;
self.output_activity_until = None;
notifications.push(notification); notifications.push(notification);
} }
OscTerminalEvent::ProtocolReply(reply) => { OscTerminalEvent::ProtocolReply(reply) => {
@@ -1862,9 +1927,6 @@ impl TerminalTab {
secondary, secondary,
} => { } => {
self.clear_click_cursor_prediction(); self.clear_click_cursor_prediction();
self.shell_integration_available = true;
self.command_running = false;
self.output_activity_until = None;
let prompt_start = self.buffer_cursor_position().unwrap_or((0, 0)); let prompt_start = self.buffer_cursor_position().unwrap_or((0, 0));
if secondary { if secondary {
if let Some(prompt_input) = self.prompt_input.as_mut() { if let Some(prompt_input) = self.prompt_input.as_mut() {
@@ -1907,15 +1969,10 @@ impl TerminalTab {
} }
OscTerminalEvent::CommandStarted => { OscTerminalEvent::CommandStarted => {
self.clear_click_cursor_prediction(); self.clear_click_cursor_prediction();
self.shell_integration_available = true;
self.command_running = true;
self.prompt_input = None; self.prompt_input = None;
} }
OscTerminalEvent::CommandFinished => { OscTerminalEvent::CommandFinished => {
self.clear_click_cursor_prediction(); self.clear_click_cursor_prediction();
self.shell_integration_available = true;
self.command_running = false;
self.output_activity_until = None;
self.prompt_input = None; self.prompt_input = None;
} }
} }
@@ -1944,9 +2001,8 @@ impl TerminalTab {
true true
} }
pub(crate) fn is_command_active(&self) -> bool { pub(crate) fn has_recent_output(&self) -> bool {
(self.command_running && !self.is_alternate_screen_active()) self.output_activity_until.is_some()
|| self.output_activity_until.is_some()
} }
pub(crate) fn expire_output_activity(&mut self, now: Instant) -> bool { pub(crate) fn expire_output_activity(&mut self, now: Instant) -> bool {
@@ -1961,22 +2017,13 @@ impl TerminalTab {
} }
} }
pub(crate) fn record_terminal_input(&mut self, bytes: &[u8]) { pub(crate) fn prepare_for_terminal_input(&mut self) {
self.clear_click_cursor_prediction(); self.clear_click_cursor_prediction();
if self.shell_integration_available
&& !self.is_alternate_screen_active()
&& bytes.iter().any(|byte| matches!(byte, b'\r' | b'\n'))
{
self.command_running = true;
self.output_activity_until = None;
}
} }
pub(crate) fn clear_command_activity(&mut self) -> bool { pub(crate) fn clear_terminal_activity(&mut self) -> bool {
let changed = self.command_running || self.output_activity_until.is_some(); let changed = self.output_activity_until.is_some();
self.command_running = false;
self.output_activity_until = None; self.output_activity_until = None;
self.shell_integration_available = false;
self.prompt_input = None; self.prompt_input = None;
self.clear_click_cursor_prediction(); self.clear_click_cursor_prediction();
self.osc_terminal_parser = OscTerminalParser::default(); self.osc_terminal_parser = OscTerminalParser::default();
@@ -2005,7 +2052,6 @@ impl TerminalTab {
self.output_decoder = StreamingDecoder::new(encoding); self.output_decoder = StreamingDecoder::new(encoding);
self.osc_terminal_parser = OscTerminalParser::default(); self.osc_terminal_parser = OscTerminalParser::default();
self.output_activity_until = None; self.output_activity_until = None;
self.command_running = false;
self.prompt_input = None; self.prompt_input = None;
self.clear_click_cursor_prediction(); self.clear_click_cursor_prediction();
if let Some(session) = self.session.as_mut() { if let Some(session) = self.session.as_mut() {
@@ -2136,7 +2182,7 @@ impl TerminalTab {
let _ = self.cursor_state_for_click_at(now); let _ = self.cursor_state_for_click_at(now);
} }
fn buffer_cursor_position(&self) -> Option<(usize, usize)> { pub(crate) fn buffer_cursor_position(&self) -> Option<(usize, usize)> {
let grid = self.term.grid(); let grid = self.term.grid();
let row = usize::try_from(grid.cursor.point.line.0).ok()?; let row = usize::try_from(grid.cursor.point.line.0).ok()?;
Some(( Some((