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:
- all
- windows-x64
- macos-arm64
permissions:
contents: write # needed to create / update the GitHub Release
@@ -29,6 +30,7 @@ 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 }}
@@ -338,6 +340,122 @@ jobs:
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
needs: build
Generated
+1 -1
View File
@@ -315,7 +315,7 @@ dependencies = [
[[package]]
name = "ashell"
version = "0.5.0"
version = "0.5.2-fix1"
dependencies = [
"alacritty_terminal",
"anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ashell"
version = "0.5.0"
version = "0.5.2-fix1"
edition = "2024"
authors = ["ashell contributors"]
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
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. The loading indicator beside a tab title prioritizes OSC 133/633
shell-integration command lifecycle markers. Shells and CLI tools without those markers fall back to
recent terminal output activity, while an OSC 9 completion notification clears the task's loading
state immediately.
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:
+2 -3
View File
@@ -106,9 +106,8 @@ OSC 9 携带通知正文;OSC 777 和 OSC 99 可以同时携带标题与正文
符合显示时机的通知来自后台或非当前可见标签时,ashell 会显示系统通知,标签按钮以
闪动铃铛标记未读状态,并在 macOS Dock 或 Windows 任务栏图标上显示红色徽标。点击
系统通知会激活 ashell 并切换到对应的终端标签,查看对应标签后自动清除未读状态。
如果应用已激活且通知来自当前可见标签,则不重复提醒。标签标题左侧的 Loading 动画
优先识别 OSC 133/633 shell integration 的命令开始和结束标记;未提供协议标记时根据
近期终端输出活动回退显示。
如果应用已激活且通知来自当前可见标签,则不重复提醒。未选中的标签在最近 2 秒内收到
终端输出时,会在标题左侧显示 Loading 动画;持续输出会刷新显示时间,当前标签不会显示。
可以分别使用下面的命令测试四种提醒:
+4
View File
@@ -262,6 +262,10 @@ 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"
+4
View File
@@ -262,6 +262,10 @@ 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"
+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>) {
if self.active_dialog.is_some() {
let parent_dialog = self.active_dialog;
if parent_dialog.is_some() && parent_dialog != Some(DialogKind::Settings) {
return;
}
@@ -695,7 +696,7 @@ impl Ashell {
let sessions = sessions_for_export.clone();
view_for_ok.update(cx, |this, cx| {
if this.active_dialog == Some(DialogKind::ConnectionExport) {
this.active_dialog = None;
this.active_dialog = parent_dialog;
}
this.start_connection_csv_export(sessions, window, cx);
cx.notify();
@@ -707,7 +708,7 @@ impl Ashell {
move |_, _, cx| {
view.update(cx, |this, cx| {
if this.active_dialog == Some(DialogKind::ConnectionExport) {
this.active_dialog = None;
this.active_dialog = parent_dialog;
}
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(
div()
.relative()
@@ -790,7 +746,7 @@ impl Ashell {
|(ix, session)| {
let connect_id = session.id.clone();
let is_selected =
selected_index == ix + 2;
selected_index == ix + 1;
let name = session.name.clone();
let detail = if session.protocol
== "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");
}),
)
.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) 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) remote_sample_in_flight: bool,
@@ -449,7 +450,6 @@ pub(crate) struct HoveredUrl {
#[derive(Clone)]
pub(crate) enum SelectorEntry {
Local,
NewSsh,
Saved(String),
}
@@ -944,6 +944,7 @@ impl Ashell {
search_bar_bounds: None,
system_tab_id: None,
last_applied_system_tab_id: None,
sftp_handles: std::collections::HashMap::new(),
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>) {
let was_active = self.window_active;
self.sync_window_activation(window);
if !was_active && self.window_active {
window.activate_window();
}
cx.notify();
}
@@ -1390,6 +1395,7 @@ impl Ashell {
tab.disconnected_reason = None;
}
self.sync_sftp_path_from_terminal_title(&tab_id, cx);
self.sync_sftp_to_active_tab();
self.sync_system_tab_to_active_group();
if self.system_tab_id.as_deref() == Some(tab_id.as_str()) {
self.system_status = None;
@@ -1488,6 +1494,12 @@ impl Ashell {
if self.is_connected_system_tab(&tab_id) {
self.remote_sample_in_flight = false;
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);
}
}
@@ -1496,6 +1508,11 @@ impl Ashell {
self.remote_sample_in_flight = false;
self.system_status = Some(reason.clone().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 } => {
@@ -1565,7 +1582,7 @@ impl Ashell {
continue;
}
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.status = reason.clone();
tab.disconnected_reason = Some(reason.clone());
@@ -1734,6 +1751,12 @@ impl Ashell {
}
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();
self.apply_system_snapshot(snapshot);
return true;
@@ -1760,6 +1783,7 @@ impl Ashell {
self.cpu_history.clear();
self.net_rx_history.clear();
self.net_tx_history.clear();
self.last_applied_system_tab_id = None;
self.remote_sample_in_flight = false;
self.remote_processes_in_flight = false;
self.remote_processes.clear();
+270 -117
View File
@@ -1884,10 +1884,26 @@ impl Ashell {
};
let mem_pct = self.system.mem_percent;
let swap_pct = self.system.swap_percent;
let mem_detail = self.system.mem_detail.clone();
let swap_detail = self.system.swap_detail.clone();
let net_rx = self.system.net_rx.clone();
let net_tx = self.system.net_tx.clone();
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 (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)
@@ -2208,7 +2224,11 @@ impl Ashell {
div()
.text_size(ui_rems(0.833))
.text_color(muted_fg)
.child(format!("{:.0}%", disk_pct)),
.child(if disk_total > 0 {
format!("{:.0}%", disk_pct)
} else {
"--".to_string()
}),
),
)
.child(
@@ -2222,40 +2242,72 @@ impl Ashell {
.track_scroll(&self.disk_scroll_handle)
.overflow_y_scroll()
.size_full()
.children(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!("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)),
)
})),
.children(if disks.is_empty() {
vec![
h_flex()
.w_full()
.items_center()
.gap_1()
.child(
div()
.w(px(20.))
.h(px(8.))
.rounded_sm()
.bg(cx.theme().skeleton),
)
.child(
div()
.flex_1()
.h(px(4.))
.rounded_full()
.bg(cx.theme().skeleton.opacity(0.4)),
)
.child(
div()
.w(px(22.))
.h(px(8.))
.rounded_sm()
.bg(cx.theme().skeleton.opacity(0.7)),
)
.into_any_element(),
]
} else {
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!("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(
div()
@@ -2313,7 +2365,32 @@ impl Ashell {
let disk_color = cx.theme().chart_5;
let net_color = cx.theme().chart_4;
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 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
.active_tab
.as_ref()
@@ -2323,6 +2400,8 @@ impl Ashell {
.unwrap_or_else(|| (t!("system_info").to_string(), t!("live").to_string()));
let status_color = if interactive {
cx.theme().success
} else if is_connecting_ssh {
cx.theme().warning
} else if active_is_ssh {
cx.theme().danger
} else {
@@ -2448,7 +2527,7 @@ impl Ashell {
div()
.text_size(ui_rems(0.85))
.text_color(muted_fg)
.child(self.system.mem_detail.clone()),
.child(mem_detail),
),
)
.child(
@@ -2475,7 +2554,7 @@ impl Ashell {
div()
.text_size(ui_rems(0.85))
.text_color(muted_fg)
.child(self.system.swap_detail.clone()),
.child(swap_detail),
),
)
.child(
@@ -2514,49 +2593,146 @@ impl Ashell {
div()
.relative()
.w_full()
.h(px(90.))
.child(
v_flex()
.id("sidebar-disk-scroll")
.track_scroll(&self.disk_scroll_handle)
.overflow_y_scroll()
.max_h(px(90.))
.size_full()
.gap_2()
.children(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(),
)
})),
.children(if self.system.disks.is_empty() {
vec![
v_flex()
.gap_0p5()
.child(
h_flex()
.justify_between()
.items_center()
.h(px(14.))
.child(
div()
.w(px(44.))
.h(px(10.))
.rounded_sm()
.bg(cx.theme().skeleton),
)
.child(
div()
.w(px(28.))
.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(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(
div()
@@ -2621,7 +2797,7 @@ impl Ashell {
.child(
div()
.text_size(ui_rems(0.75))
.child(self.system.net_rx.clone()),
.child(net_rx),
),
)
.child(
@@ -2639,7 +2815,7 @@ impl Ashell {
.child(
div()
.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>,
cx: &mut Context<Self>,
) -> gpui::AnyElement {
let connect_id = session.id.clone();
let edit_id = session.id.clone();
let delete_id = session.id.clone();
let management_mode = self.connection_management_mode;
@@ -3347,8 +3522,6 @@ impl Ashell {
let selection_id = session.id.clone();
let row_selection_id = selection_id.clone();
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()
.id(row_id)
@@ -3477,33 +3650,7 @@ impl Ashell {
.text_size(ui_rems(0.75))
.text_color(cx.theme().muted_foreground)
.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()
}
@@ -4380,12 +4527,13 @@ impl Ashell {
pane_ids.iter().any(|id| {
self.unread_terminal_notifications.contains(id)
});
let output_active = pane_ids.iter().any(|id| {
self.tabs
.iter()
.find(|tab| tab.id == *id)
.is_some_and(TerminalTab::is_command_active)
});
let output_active = ix != selected
&& pane_ids.iter().any(|id| {
self.tabs
.iter()
.find(|tab| tab.id == *id)
.is_some_and(TerminalTab::has_recent_output)
});
h_flex()
.id(("ashell-tab", ix))
.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_active_ssh_connected = self
.active_tab
+6 -14
View File
@@ -409,9 +409,6 @@ fn default_terminal_font_size() -> f32 {
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 {
14.0
}
@@ -1167,9 +1164,7 @@ impl ConfigStore {
}
pub fn terminal_font_size(&self) -> f32 {
if self.cache.terminal_font_size <= 0.0
|| (self.cache.terminal_font_size - LEGACY_DEFAULT_TERMINAL_FONT_SIZE).abs() < 0.01
{
if self.cache.terminal_font_size <= 0.0 {
default_terminal_font_size()
} else {
self.cache.terminal_font_size
@@ -1287,10 +1282,7 @@ impl ConfigStore {
}
pub fn ui_font_size(&self) -> f32 {
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.
if self.cache.ui_font_size <= 0.0 {
default_ui_font_size()
} else {
self.cache.ui_font_size
@@ -1942,17 +1934,17 @@ mod tests {
}
#[test]
fn font_sizes_migrate_the_previous_defaults() {
fn font_sizes_preserve_user_configurations() {
let mut store = ConfigStore::in_memory();
assert_eq!(store.terminal_font_size(), 12.0);
assert_eq!(store.ui_font_size(), 14.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);
assert_eq!(store.ui_font_size(), 14.0);
store.set_ui_font_size(16.0);
assert_eq!(store.ui_font_size(), 16.0);
store.set_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> {
let mut entries = vec![SelectorEntry::Local, SelectorEntry::NewSsh];
let mut entries = vec![SelectorEntry::Local];
entries.extend(
self.config
.sessions()
@@ -1278,11 +1278,7 @@ impl Ashell {
}
pub(crate) fn default_selector_index(&self) -> usize {
if self.config.sessions().is_empty() {
0
} else {
2
}
0
}
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;
if next != self.selector_selection {
self.selector_selection = next;
if next >= 2 {
self.selector_scroll_handle.scroll_to_item(next - 2);
if next >= 1 {
self.selector_scroll_handle.scroll_to_item(next - 1);
}
cx.notify();
}
@@ -1317,10 +1313,6 @@ impl Ashell {
self.open_local(cx);
window.close_dialog(cx);
}
SelectorEntry::NewSsh => {
window.close_dialog(cx);
self.open_new_ssh_dialog(window, cx);
}
SelectorEntry::Saved(session_id) => {
self.connect_saved_session(session_id, window, cx);
window.close_dialog(cx);
@@ -2111,12 +2103,28 @@ impl Ashell {
}
pub(crate) fn active_ssh_session(&self) -> Option<(String, Session)> {
let active_id = self.active_tab.as_ref()?;
let tab = self.tabs.iter().find(|tab| &tab.id == active_id)?;
if !tab.connected {
return None;
if let Some(active_id) = self.active_tab.as_ref() {
if let Some(tab) = self.tabs.iter().find(|tab| &tab.id == active_id) {
if tab.kind == TabKind::Ssh {
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> {
@@ -2293,9 +2301,6 @@ impl Ashell {
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.focused_pane_path.is_empty() {
return;
}
let previous_active_tab = self.active_tab.clone();
let mut active_tab_changed = false;
let path = self.focused_pane_path.clone();
@@ -2319,6 +2324,28 @@ impl Ashell {
}
}
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 {
self.update_terminal_focus(previous_active_tab.as_deref());
@@ -2446,7 +2473,13 @@ impl Ashell {
}
}
// 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.active_group = Some(group_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 {
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
.iter()
.find(|tab| {
@@ -2519,12 +2552,49 @@ impl Ashell {
})
.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
.tab_groups
.iter()
.find(|group| group.id == group_id)
.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| {
self.tabs
.iter()
@@ -2535,7 +2605,7 @@ impl Ashell {
let target_session_id = target.as_ref().map(|(_, session)| session.id.as_str());
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;
}
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) {
let active_ssh_tab = self.active_tab.as_ref().and_then(|id| {
self.tabs
.iter()
.find(|tab| tab.id == *id && tab.kind == TabKind::Ssh)
let active_group = self.active_group.as_ref().and_then(|group_id| {
self.tab_groups.iter().find(|group| &group.id == group_id)
});
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| {
(!tab.connected).then(|| {
tab.disconnected_reason
@@ -2596,13 +2705,24 @@ impl Ashell {
if 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 {
self.system_status = Some(status.clone().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 {
self.system_status = None;
}
self.request_active_system_snapshot();
if self.active_dialog == Some(crate::app::DialogKind::Processes) {
self.request_active_process_snapshot();
+129 -40
View File
@@ -12,6 +12,7 @@ use gpui_component::ActiveTheme as _;
use crate::Ashell;
use crate::terminal::custom_blocks::{is_custom_block_supported, paint_custom_block};
use crate::terminal::highlight::HighlightStyle;
use crate::terminal::{RenderSnapshot, ViewportSelection};
#[derive(Clone, Copy)]
@@ -338,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),
});
@@ -360,7 +356,7 @@ impl TerminalElement {
.flags
.contains(Flags::STRIKEOUT)
.then(|| StrikethroughStyle {
color: Some(fg),
color: Some(foreground),
thickness: px(1.0),
});
@@ -377,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(),
@@ -408,12 +404,6 @@ impl TerminalElement {
let mut underlines = Vec::new();
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 {
let cell = &render_cell.cell;
if cell.flags.intersects(
@@ -425,24 +415,54 @@ impl TerminalElement {
let selected = self.snapshot.selection.is_some_and(|selection| {
selection_contains(selection, render_cell.row, render_cell.col)
});
let bg = color_to_hsla(cell.bg, false, cx);
if selected || !is_default_bg(cell.bg) || cell.flags.contains(Flags::INVERSE) {
let cell_position = (render_cell.row, render_cell.col);
let keyword_highlight = self.snapshot.highlights.get(&cell_position).copied();
let search_highlight = self
.search_highlights
.as_ref()
.and_then(|highlights| highlights.get(&cell_position))
.copied();
let (foreground, background) = resolve_cell_colors(
color_to_hsla(cell.fg, true, cx),
color_to_hsla(cell.bg, false, cx),
keyword_highlight,
cell.flags,
);
let cell_span = if cell.flags.contains(Flags::WIDE_CHAR) {
2
} else {
1
};
if selected {
rects.push(LayoutRect {
row: render_cell.row,
col: render_cell.col,
cells: if cell.flags.contains(Flags::WIDE_CHAR) {
2
} else {
1
},
color: if selected {
cx.theme().selection
} else if cell.flags.contains(Flags::INVERSE) {
color_to_hsla(cell.fg, true, cx)
} else {
bg
},
cells: cell_span,
color: cx.theme().selection,
});
} else {
let has_highlight_background =
keyword_highlight.is_some_and(|style| style.background.is_some());
if !is_default_bg(cell.bg)
|| cell.flags.contains(Flags::INVERSE)
|| has_highlight_background
{
rects.push(LayoutRect {
row: render_cell.row,
col: render_cell.col,
cells: cell_span,
color: background,
});
}
if let Some(search_background) = search_highlight {
rects.push(LayoutRect {
row: render_cell.row,
col: render_cell.col,
cells: cell_span,
color: search_background,
});
}
}
if is_blank(cell) {
@@ -452,12 +472,7 @@ impl TerminalElement {
continue;
}
let mut style = self.cell_run_style(cell, cx);
// 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;
}
let style = self.cell_run_style(cell, foreground);
// Apply hover underline if mouse is hovering over this 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
}
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()
@@ -965,7 +1005,14 @@ fn named_color(named: NamedColor, _foreground: bool, cx: &App) -> Hsla {
#[cfg(test)]
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]
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, 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 gpui::Hsla;
use gpui::{Hsla, Rgba};
use std::collections::HashMap;
trait HslaExt {
fn into_rgba_like(self, r: u8, g: u8, b: u8) -> Self;
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct HighlightStyle {
pub foreground: Option<Hsla>,
pub background: Option<Hsla>,
}
impl HslaExt for Hsla {
fn into_rgba_like(self, r: u8, g: u8, b: u8) -> Self {
let rf = r as f32 / 255.0;
let gf = g as f32 / 255.0;
let bf = b as f32 / 255.0;
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,
};
impl HighlightStyle {
fn from_foreground(color: Hsla) -> Self {
Self {
foreground: Some(color),
background: None,
}
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 {
Hsla {
h: 0.0,
s: 0.0,
l: 0.0,
Hsla::from(Rgba {
r: r as f32 / 255.0,
g: g as f32 / 255.0,
b: b as f32 / 255.0,
a: 1.0,
}
.into_rgba_like(r, g, b)
})
}
fn highlight_colors() -> HighlightColors {
@@ -168,7 +145,7 @@ fn highlight_colors() -> HighlightColors {
struct KeywordMatch {
start_col: i32,
end_col: i32,
color: Hsla,
style: HighlightStyle,
priority: usize,
}
@@ -184,7 +161,7 @@ impl KeywordMatch {
#[derive(Default)]
struct KeywordHighlights {
colors: HashMap<(i32, i32), Hsla>,
styles: HashMap<(i32, i32), HighlightStyle>,
pending: Vec<KeywordMatch>,
next_priority: usize,
}
@@ -214,7 +191,7 @@ impl KeywordHighlights {
for candidate in selected {
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 {
start_col,
end_col,
color,
style: HighlightStyle::from_foreground(color),
priority,
});
start = abs + kw.len();
@@ -276,7 +253,7 @@ fn highlight_keywords(
/// Highlight HTTP status codes (200, 301, 404, 500, etc.)
/// Only matches specific common HTTP codes, not all 3-digit numbers.
fn highlight_http_codes(
map: &mut HashMap<(i32, i32), Hsla>,
map: &mut HashMap<(i32, i32), HighlightStyle>,
text: &str,
byte_to_col: &[i32],
row_i32: i32,
@@ -352,12 +329,13 @@ fn highlight_http_codes(
let start_col = byte_to_col[i];
let end_col = byte_to_col[(i + 2).min(byte_to_col.len() - 1)];
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 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 {
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() {
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);
// ── 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 ───────────────────────────────────
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 end_col = byte_to_col[(m + ip_len - 1).min(byte_to_col.len() - 1)];
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 end_col = byte_to_col[(m + port_len - 1).min(byte_to_col.len() - 1)];
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;
if idx < line.byte_to_cell.len() {
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 {
@@ -967,32 +953,69 @@ fn find_urls(text: &str) -> Vec<usize> {
}
fn find_url_len(text: &str) -> usize {
let end = text
.find(|c: char| c.is_ascii_whitespace())
.unwrap_or(text.len());
let mut url = &text[..end];
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 {
let mut end = text.len();
let mut expected_closings = Vec::new();
for (index, character) in text.char_indices() {
if is_url_terminator(character) {
end = index;
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()
}
fn is_url_terminator(character: char) -> bool {
character.is_whitespace()
|| matches!(
character,
'"' | '\''
| '`'
| '<'
| '>'
| ','
| '。'
| ';'
| ':'
| '!'
| '?'
| '、'
| '('
| ')'
| '【'
| '】'
| '《'
| '》'
| '“'
| '”'
| '‘'
| '’'
)
}
fn find_ports(text: &str) -> Vec<usize> {
let mut positions = Vec::new();
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 {
line.text.push(rc.cell.c);
let end_len = line.text.len();
while line.byte_to_cell.len() < end_len {
line.byte_to_cell.push((rc.row as usize, rc.col as usize));
if let Some(character) = rendered_text_character(&rc.cell) {
line.text.push(character);
let end_len = line.text.len();
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);
}
@@ -1108,6 +1133,21 @@ pub fn build_logical_lines<'a>(cells: &'a [RenderCell], rows: usize) -> Vec<Logi
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(
cells: &[RenderCell],
rows: usize,
@@ -1142,7 +1182,7 @@ pub fn find_url_at_cell(
#[cfg(test)]
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 {
&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]
fn overlapping_keywords_keep_the_longest_match_color() {
let long_color = hsla(10, 20, 30);
@@ -1181,21 +1243,26 @@ mod tests {
KeywordMatch {
start_col: 3,
end_col: 11,
color: short_color,
style: HighlightStyle::from_foreground(short_color),
priority: 0,
},
KeywordMatch {
start_col: 0,
end_col: 11,
color: long_color,
style: HighlightStyle::from_foreground(long_color),
priority: 1,
},
];
highlights.apply_row(0);
assert_eq!(highlights.colors.len(), 12);
assert!(highlights.colors.values().all(|color| *color == long_color));
assert_eq!(highlights.styles.len(), 12);
assert!(
highlights
.styles
.values()
.all(|style| style.foreground == Some(long_color) && style.background.is_none())
);
}
#[test]
@@ -1208,8 +1275,13 @@ mod tests {
super::highlight_keywords(&mut highlights, text, &byte_to_col, 0, &["BOOT"], color);
highlights.apply_row(0);
assert_eq!(highlights.colors.len(), 4);
assert!((0..9).all(|col| !highlights.colors.contains_key(&(0, col))));
assert!((10..14).all(|col| highlights.colors.get(&(0, col)) == Some(&color)));
assert_eq!(highlights.styles.len(), 4);
assert!((0..9).all(|col| !highlights.styles.contains_key(&(0, col))));
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.record_terminal_input(&bytes);
tab.prepare_for_terminal_input();
let encoded = tab.encode_input(&bytes);
tab.send_backend(BackendCommand::Input(encoded));
window.prevent_default();
@@ -325,7 +325,7 @@ impl Ashell {
}
tab.clear_selection();
self.terminal_marked_text = None;
tab.record_terminal_input(&bytes);
tab.prepare_for_terminal_input();
let encoded = tab.encode_input(&bytes);
tab.send_backend(BackendCommand::Input(encoded));
window.invalidate_character_coordinates();
@@ -345,7 +345,7 @@ impl Ashell {
};
(
session.id.clone(),
tab.cursor_state().map(|cursor| (cursor.row, cursor.col)),
tab.buffer_cursor_position(),
tab.is_alternate_screen_active(),
)
};
@@ -1284,6 +1284,11 @@ fn terminal_command_text(
start: (usize, usize),
end: Option<(usize, usize)>,
) -> 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 =
crate::terminal::highlight::build_logical_lines(&snapshot.cells, snapshot.rows);
for line in logical_lines {
@@ -1315,42 +1320,38 @@ fn terminal_command_text(
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 {
let rendered = rendered.unwrap_or_default().trim();
let buffered = buffered.trim();
if !input_uncertain && !buffered.is_empty() {
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() {
return buffered.to_string();
}
if buffered.is_empty() {
return rendered.to_string();
}
if rendered.starts_with(buffered) || rendered.ends_with(buffered) {
return rendered.to_string();
}
if buffered.starts_with(rendered) || buffered.ends_with(rendered) {
// Completion extends the current token; a new argument after exact raw input is stale content.
if rendered
.strip_prefix(buffered)
.and_then(|suffix| suffix.chars().next())
.is_some_and(char::is_whitespace)
{
return buffered.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()
}
rendered.to_string()
}
#[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]
fn truncates_rendered_command_at_the_submission_cursor() {
let command = "sh /site/vocano/vocano-restart.sh";
@@ -1592,4 +1645,26 @@ mod tests {
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,
}
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 MAX_OSC_PAYLOAD_BYTES: usize = 4096;
const MAX_NOTIFICATION_TEXT_BYTES: usize = 8192;
@@ -242,27 +242,39 @@ struct OscTerminalParser {
pending_osc99: HashMap<String, PendingOsc99Notification>,
}
struct OscTerminalScan {
events: Vec<(usize, OscTerminalEvent)>,
has_terminal_output: bool,
}
impl OscTerminalParser {
/// Scans decoded terminal output without consuming it from the terminal emulator.
#[cfg(test)]
fn advance(&mut self, bytes: &[u8]) -> Vec<OscTerminalEvent> {
self.advance_with_offsets(bytes)
.events
.into_iter()
.map(|(_, event)| event)
.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 has_terminal_output = false;
for (index, &byte) in bytes.iter().enumerate() {
match self.state {
OscTerminalState::Ground => {
if byte == 0x1b {
self.state = OscTerminalState::Escape;
} else {
has_terminal_output = true;
}
}
OscTerminalState::Escape => {
if byte != b']' && byte != 0x1b {
has_terminal_output = true;
}
self.state = match byte {
b']' => {
self.command.clear();
@@ -333,7 +345,10 @@ impl OscTerminalParser {
}
}
events
OscTerminalScan {
events,
has_terminal_output,
}
}
fn push_payload_byte(&mut self, byte: u8) {
@@ -869,8 +884,6 @@ pub struct TerminalTab {
output_decoder: StreamingDecoder,
osc_terminal_parser: OscTerminalParser,
output_activity_until: Option<Instant>,
command_running: bool,
shell_integration_available: bool,
processor: Processor,
term: Term<TerminalListener>,
pub cols: u16,
@@ -887,7 +900,7 @@ pub struct TerminalTab {
type HighlightCache = std::cell::RefCell<
Option<(
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 rows: 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)]
@@ -1283,7 +1296,7 @@ mod terminal_tab_backend_tests {
);
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);
}
}
@@ -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]
fn parses_prompt_click_modes_and_preserves_event_offsets() {
let mut parser = OscTerminalParser::default();
@@ -1549,6 +1616,7 @@ mod osc_terminal_tests {
let (event_end, event) = parser
.advance_with_offsets(bytes)
.events
.into_iter()
.next()
.expect("prompt marker event");
@@ -1807,8 +1875,6 @@ impl TerminalTab {
output_decoder: StreamingDecoder::new(TextEncoding::Utf8),
osc_terminal_parser: OscTerminalParser::default(),
output_activity_until: None,
command_running: false,
shell_integration_available: false,
processor: Processor::new(),
term: new_term(100, 30, shared_backend.clone(), id, events.clone()),
cols: 100,
@@ -1825,12 +1891,13 @@ impl TerminalTab {
pub fn feed(&mut self, bytes: &[u8]) -> Vec<TerminalNotification> {
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);
}
let mut notifications = Vec::new();
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
.advance(&mut self.term, &decoded[processed_until..event_end]);
self.handle_osc_terminal_event(event, &mut notifications);
@@ -1850,8 +1917,6 @@ impl TerminalTab {
) {
match event {
OscTerminalEvent::Notification(notification) => {
self.command_running = false;
self.output_activity_until = None;
notifications.push(notification);
}
OscTerminalEvent::ProtocolReply(reply) => {
@@ -1862,9 +1927,6 @@ impl TerminalTab {
secondary,
} => {
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));
if secondary {
if let Some(prompt_input) = self.prompt_input.as_mut() {
@@ -1907,15 +1969,10 @@ impl TerminalTab {
}
OscTerminalEvent::CommandStarted => {
self.clear_click_cursor_prediction();
self.shell_integration_available = true;
self.command_running = true;
self.prompt_input = None;
}
OscTerminalEvent::CommandFinished => {
self.clear_click_cursor_prediction();
self.shell_integration_available = true;
self.command_running = false;
self.output_activity_until = None;
self.prompt_input = None;
}
}
@@ -1944,9 +2001,8 @@ impl TerminalTab {
true
}
pub(crate) fn is_command_active(&self) -> bool {
(self.command_running && !self.is_alternate_screen_active())
|| self.output_activity_until.is_some()
pub(crate) fn has_recent_output(&self) -> bool {
self.output_activity_until.is_some()
}
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();
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 {
let changed = self.command_running || self.output_activity_until.is_some();
self.command_running = false;
pub(crate) fn clear_terminal_activity(&mut self) -> bool {
let changed = self.output_activity_until.is_some();
self.output_activity_until = None;
self.shell_integration_available = false;
self.prompt_input = None;
self.clear_click_cursor_prediction();
self.osc_terminal_parser = OscTerminalParser::default();
@@ -2005,7 +2052,6 @@ impl TerminalTab {
self.output_decoder = StreamingDecoder::new(encoding);
self.osc_terminal_parser = OscTerminalParser::default();
self.output_activity_until = None;
self.command_running = false;
self.prompt_input = None;
self.clear_click_cursor_prediction();
if let Some(session) = self.session.as_mut() {
@@ -2136,7 +2182,7 @@ impl TerminalTab {
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 row = usize::try_from(grid.cursor.point.line.0).ok()?;
Some((