mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-09-22 00:00:59 +00:00
feature:1、新增Phyger主题;2、支持terminal搜索 (#52)
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-component/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Phyger",
|
||||
"author": "phygerr",
|
||||
"url": "https://github.com/phygerr",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Phyger Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#D6E4F0",
|
||||
"accent.foreground": "#1B3A5C",
|
||||
"background": "#F8F9FC",
|
||||
"border": "#D0D5DD",
|
||||
"danger.background": "#D94848",
|
||||
"danger.active.background": "#B53535",
|
||||
"danger.foreground": "#FFFFFF",
|
||||
"foreground": "#2B2D30",
|
||||
"input.border": "#C0C7D0",
|
||||
"link.active.foreground": "#2563EB",
|
||||
"link.foreground": "#2563EB",
|
||||
"link.hover.foreground": "#1D4ED8",
|
||||
"list.active.background": "#2563EB18",
|
||||
"list.hover.background": "#E8EDF4",
|
||||
"list.active.border": "#2563EB",
|
||||
"list.even.background": "#F0F2F7",
|
||||
"muted.background": "#E2E6EE88",
|
||||
"muted.foreground": "#7A7F88",
|
||||
"panel.background": "#ECEEF3",
|
||||
"popover.background": "#F8F9FC",
|
||||
"popover.foreground": "#2B2D30",
|
||||
"primary.active.background": "#1D4ED8",
|
||||
"primary.background": "#2563EB",
|
||||
"primary.foreground": "#FFFFFF",
|
||||
"primary.hover.background": "#3B72F0",
|
||||
"ring": "#2563EB",
|
||||
"scrollbar.background": "#F8F9FC00",
|
||||
"scrollbar.thumb.background": "#C0C7D0",
|
||||
"secondary.background": "#E8EDF4",
|
||||
"secondary.active.background": "#D0D5DD",
|
||||
"secondary.foreground": "#2B2D30",
|
||||
"secondary.hover.background": "#DCE2EB",
|
||||
"tab.active.background": "#F8F9FC",
|
||||
"tab.active.foreground": "#2B2D30",
|
||||
"tab.background": "#ECEEF300",
|
||||
"tab.foreground": "#7A7F88",
|
||||
"tab_bar.background": "#ECEEF3",
|
||||
"title_bar.background": "#ECEEF3",
|
||||
"title_bar.border": "#D0D5DD",
|
||||
"base.magenta.light": "#C2185B44",
|
||||
"base.red": "#D32F2F",
|
||||
"base.green": "#2E7D32",
|
||||
"base.yellow": "#BF8C00",
|
||||
"base.blue": "#1565C0",
|
||||
"base.magenta": "#8E24AA",
|
||||
"base.cyan": "#00838F"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#2B2D30",
|
||||
"editor.background": "#F8F9FC",
|
||||
"editor.active_line.background": "#E8EDF4",
|
||||
"editor.line_number": "#A0A5AE",
|
||||
"editor.active_line_number": "#2B2D30",
|
||||
"editor.invisible": "#A0A5AE55",
|
||||
"conflict": "#D32F2F",
|
||||
"created": "#2E7D32",
|
||||
"deleted": "#C62828",
|
||||
"error": "#D32F2F",
|
||||
"hidden": "#A0A5AE",
|
||||
"hint": "#1565C0",
|
||||
"ignored": "#A0A5AE",
|
||||
"info": "#00838F",
|
||||
"modified": "#BF8C00",
|
||||
"predictive": "#A0A5AE",
|
||||
"renamed": "#BF8C00",
|
||||
"success": "#2E7D32",
|
||||
"unreachable": "#A0A5AE",
|
||||
"warning": "#E65100",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#7B1FA2"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#00838F"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#6A737D",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#6A737D",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#00838F"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#1565C0"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#2B2D30"
|
||||
},
|
||||
"function": {
|
||||
"color": "#6A1B9A"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#1565C0"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#1565C0",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#00838F",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#1B5E20"
|
||||
},
|
||||
"string": {
|
||||
"color": "#BF360C"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#BF360C"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#BF360C"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#00838F"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#00838F"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#1565C0"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#00838F"
|
||||
},
|
||||
"title": {
|
||||
"color": "#6A1B9A",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#2E7D32"
|
||||
},
|
||||
"property": {
|
||||
"color": "#2B2D30"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#D32F2F"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Phyger Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#2A3040",
|
||||
"accent.foreground": "#C8D3E0",
|
||||
"background": "#1A1D23",
|
||||
"border": "#2E3340",
|
||||
"danger.background": "#E05555",
|
||||
"danger.active.background": "#CC3D3D",
|
||||
"danger.foreground": "#FFFFFF",
|
||||
"foreground": "#C8CED8",
|
||||
"input.border": "#3A4050",
|
||||
"link.active.foreground": "#6CB4EE",
|
||||
"link.foreground": "#6CB4EE",
|
||||
"link.hover.foreground": "#8FC8F6",
|
||||
"list.active.background": "#6CB4EE15",
|
||||
"list.hover.background": "#252A34",
|
||||
"list.active.border": "#6CB4EE",
|
||||
"list.even.background": "#1E2229",
|
||||
"muted.background": "#252A3466",
|
||||
"muted.foreground": "#6B7280",
|
||||
"panel.background": "#1E2229",
|
||||
"popover.background": "#1A1D23",
|
||||
"popover.foreground": "#C8CED8",
|
||||
"primary.active.background": "#4A90D9",
|
||||
"primary.background": "#5BA0E0",
|
||||
"primary.foreground": "#1A1D23",
|
||||
"primary.hover.background": "#6CB4EE",
|
||||
"ring": "#5BA0E0",
|
||||
"scrollbar.background": "#1A1D2300",
|
||||
"scrollbar.thumb.background": "#3A4050",
|
||||
"secondary.active.background": "#333A48",
|
||||
"secondary.background": "#252A34",
|
||||
"secondary.foreground": "#C8CED8",
|
||||
"secondary.hover.background": "#2E3542",
|
||||
"tab.active.background": "#1A1D23",
|
||||
"tab.active.foreground": "#C8CED8",
|
||||
"tab.background": "#252A3400",
|
||||
"tab.foreground": "#6B7280",
|
||||
"tab_bar.background": "#1E2229DD",
|
||||
"title_bar.background": "#1E2229",
|
||||
"title_bar.border": "#2E3340",
|
||||
"base.magenta.light": "#C792EA44",
|
||||
"base.red": "#E06060",
|
||||
"base.green": "#7EC699",
|
||||
"base.yellow": "#E8C97A",
|
||||
"base.blue": "#6CB4EE",
|
||||
"base.magenta": "#C792EA",
|
||||
"base.cyan": "#56D4C7"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#C8CED8",
|
||||
"editor.background": "#1A1D23",
|
||||
"editor.active_line.background": "#222730",
|
||||
"editor.line_number": "#5A6170",
|
||||
"editor.active_line_number": "#C8CED8",
|
||||
"editor.invisible": "#5A617055",
|
||||
"conflict": "#E06060",
|
||||
"created": "#7EC699",
|
||||
"deleted": "#CC4444",
|
||||
"error": "#E06060",
|
||||
"hidden": "#6B7280",
|
||||
"hint": "#C792EA",
|
||||
"ignored": "#5A6170",
|
||||
"info": "#56D4C7",
|
||||
"modified": "#E8C97A",
|
||||
"predictive": "#4A5568",
|
||||
"renamed": "#E8C97A",
|
||||
"success": "#7EC699",
|
||||
"unreachable": "#5A6170",
|
||||
"warning": "#E8C97A",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#C792EA"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#56D4C7"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#5A6A7A",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#5A6A7A",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#56D4C7"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#6CB4EE"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#C8CED8"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E2C08D"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#6CB4EE"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#6CB4EE",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#56D4C7",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#7EC699"
|
||||
},
|
||||
"string": {
|
||||
"color": "#E8A87C"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#E8A87C"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#E8A87C"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#56D4C7"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#56D4C7"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#6CB4EE"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#56D4C7"
|
||||
},
|
||||
"title": {
|
||||
"color": "#E2C08D",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#7EC699"
|
||||
},
|
||||
"property": {
|
||||
"color": "#C8CED8"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E06060"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -142,6 +142,9 @@ files_uploading: "%{count} files uploading"
|
||||
panel_minimize: "Minimize Panel"
|
||||
panel_expand: "Expand Panel"
|
||||
settings_new_ssh: "New SSH"
|
||||
settings_open_search: "Search"
|
||||
search: "Search"
|
||||
no_results: "No results"
|
||||
settings_toggle_sidebar: "Toggle Sidebar"
|
||||
settings_toggle_sftp_zoom: "Toggle SFTP Zoom"
|
||||
settings_focus_pane_left: "Focus Pane Left"
|
||||
|
||||
@@ -144,6 +144,9 @@ files_uploading: "%{count} 个文件正在上传"
|
||||
panel_minimize: "折叠面板"
|
||||
panel_expand: "展开面板"
|
||||
settings_new_ssh: "新建 SSH"
|
||||
settings_open_search: "搜索"
|
||||
search: "搜索"
|
||||
no_results: "无结果"
|
||||
settings_toggle_sidebar: "缩放侧边栏"
|
||||
settings_toggle_sftp_zoom: "缩放 SFTP 面板"
|
||||
settings_focus_pane_left: "向左切换焦点"
|
||||
|
||||
@@ -18,6 +18,7 @@ gpui::actions!(
|
||||
OpenSession,
|
||||
OpenTransfers,
|
||||
NewSsh,
|
||||
OpenSearch,
|
||||
ToggleSidebar,
|
||||
ToggleSftpZoom,
|
||||
FocusPaneLeft,
|
||||
@@ -62,6 +63,11 @@ pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[
|
||||
label_key: "settings_new_ssh",
|
||||
default_suffix: "n",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "OpenSearch",
|
||||
label_key: "settings_open_search",
|
||||
default_suffix: "f",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "ToggleSidebar",
|
||||
label_key: "settings_toggle_sidebar",
|
||||
@@ -206,6 +212,7 @@ pub(crate) fn unbind_all_workspace_keys(cx: &mut App, config: &ConfigStore) {
|
||||
unbind_action!("OpenSession", crate::OpenSession);
|
||||
unbind_action!("OpenTransfers", crate::OpenTransfers);
|
||||
unbind_action!("NewSsh", crate::NewSsh);
|
||||
unbind_action!("OpenSearch", crate::OpenSearch);
|
||||
unbind_action!("ToggleSidebar", crate::ToggleSidebar);
|
||||
unbind_action!("ToggleSftpZoom", crate::ToggleSftpZoom);
|
||||
unbind_action!("FocusPaneLeft", crate::FocusPaneLeft);
|
||||
@@ -261,6 +268,7 @@ fn bind_workspace_actions(cx: &mut App, config: &ConfigStore) {
|
||||
bind_action!("OpenSession", crate::OpenSession);
|
||||
bind_action!("OpenTransfers", crate::OpenTransfers);
|
||||
bind_action!("NewSsh", crate::NewSsh);
|
||||
bind_action!("OpenSearch", crate::OpenSearch);
|
||||
bind_action!("ToggleSidebar", crate::ToggleSidebar);
|
||||
bind_action!("ToggleSftpZoom", crate::ToggleSftpZoom);
|
||||
bind_action!("FocusPaneLeft", crate::FocusPaneLeft);
|
||||
@@ -281,7 +289,7 @@ impl KeybindingsPage {
|
||||
let groups = [
|
||||
(
|
||||
"settings_group_keybind_general",
|
||||
vec!["OpenSettings", "OpenSession", "OpenTransfers", "NewSsh"],
|
||||
vec!["OpenSettings", "OpenSession", "OpenTransfers", "NewSsh", "OpenSearch"],
|
||||
),
|
||||
(
|
||||
"settings_group_keybind_zoom",
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod config_sync;
|
||||
pub mod constants;
|
||||
pub mod dialogs;
|
||||
pub mod keybinding_recorder;
|
||||
pub mod search;
|
||||
pub mod startup;
|
||||
pub mod theme;
|
||||
pub mod ui;
|
||||
@@ -288,6 +289,14 @@ pub(crate) struct Ashell {
|
||||
pub(crate) last_system_sample: Instant,
|
||||
pub(crate) last_theme_sync: Instant,
|
||||
|
||||
pub(crate) search_input: Entity<InputState>,
|
||||
pub(crate) search_active: bool,
|
||||
pub(crate) search_query: String,
|
||||
pub(crate) search_matches: Vec<(i32, i32)>,
|
||||
pub(crate) search_current: usize,
|
||||
pub(crate) search_target_tab: Option<String>,
|
||||
pub(crate) search_bar_bounds: Option<Bounds<Pixels>>,
|
||||
|
||||
pub(crate) system_tab_id: Option<String>,
|
||||
pub(crate) sftp_handles: std::collections::HashMap<String, crate::sftp::SftpHandle>,
|
||||
|
||||
@@ -368,6 +377,9 @@ impl Ashell {
|
||||
let sftp_path_input = cx.new(|cx| InputState::new(window, cx).default_value("/"));
|
||||
let sftp_new_folder_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("new_folder").to_string()));
|
||||
let search_input = cx.new(|cx| {
|
||||
InputState::new(window, cx).placeholder(t!("search").to_string())
|
||||
});
|
||||
let config = ConfigStore::load().unwrap_or_else(|err| {
|
||||
tracing::warn!("failed to load config: {err:#}");
|
||||
ConfigStore::in_memory()
|
||||
@@ -437,6 +449,7 @@ impl Ashell {
|
||||
cx.subscribe_in(&passphrase_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sftp_path_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sftp_new_folder_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&search_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sync_endpoint_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sync_username_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sync_webdav_password_input, window, Self::on_input_event),
|
||||
@@ -593,6 +606,14 @@ impl Ashell {
|
||||
last_system_sample: Instant::now(),
|
||||
last_theme_sync: Instant::now(),
|
||||
|
||||
search_input,
|
||||
search_active: false,
|
||||
search_query: String::new(),
|
||||
search_matches: Vec::new(),
|
||||
search_current: 0,
|
||||
search_target_tab: None,
|
||||
search_bar_bounds: None,
|
||||
|
||||
system_tab_id: None,
|
||||
sftp_handles: std::collections::HashMap::new(),
|
||||
|
||||
@@ -651,6 +672,18 @@ impl Ashell {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else if input == &self.search_input {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
if self.search_query.is_empty()
|
||||
|| self.search_input.read(cx).text().to_string() != self.search_query
|
||||
{
|
||||
self.perform_search(window, cx);
|
||||
} else {
|
||||
self.search_goto_next(cx);
|
||||
}
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use gpui::{
|
||||
Context, Focusable as _, Hsla, IntoElement, InteractiveElement as _, MouseButton,
|
||||
ParentElement as _, Styled as _, Window, div, prelude::FluentBuilder as _, px, rems,
|
||||
};
|
||||
use gpui_component::{
|
||||
ActiveTheme as _, Disableable as _, ElementExt as _, IconName, Sizable as _,
|
||||
button::{Button, ButtonVariants as _},
|
||||
h_flex, input::Input,
|
||||
};
|
||||
use rust_i18n::t;
|
||||
|
||||
use crate::Ashell;
|
||||
|
||||
impl Ashell {
|
||||
pub(crate) fn toggle_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.search_active {
|
||||
self.close_search(window, cx);
|
||||
} else {
|
||||
self.open_search(window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn open_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.search_active = true;
|
||||
// Focus the search input on the next frame so it happens after the
|
||||
// current render cycle completes, avoiding focus being stolen back
|
||||
// by the terminal panel's track_focus.
|
||||
let search_input = self.search_input.clone();
|
||||
cx.on_next_frame(window, move |_this, window, cx| {
|
||||
search_input.update(cx, |state, cx| {
|
||||
state.focus_handle(cx).focus(window, cx);
|
||||
});
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn close_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.search_active = false;
|
||||
self.search_query.clear();
|
||||
self.search_matches.clear();
|
||||
self.search_current = 0;
|
||||
self.search_target_tab = None;
|
||||
self.search_bar_bounds = None;
|
||||
self.focus_handle.focus(window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Move keyboard focus back to the search input so the user can keep typing.
|
||||
/// Deferred to the next frame so it happens after the current render cycle,
|
||||
/// preventing the terminal panel's track_focus from stealing focus back.
|
||||
fn refocus_search_input(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let search_input = self.search_input.clone();
|
||||
cx.on_next_frame(window, move |_this, window, cx| {
|
||||
search_input.update(cx, |state, cx| {
|
||||
state.focus_handle(cx).focus(window, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn perform_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let query = self.search_input.read(cx).text().to_string();
|
||||
if query.is_empty() {
|
||||
self.search_query.clear();
|
||||
self.search_matches.clear();
|
||||
self.search_current = 0;
|
||||
self.refocus_search_input(window, cx);
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the active tab — try active_tab first, then fall back to the
|
||||
// first tab in the active group, then any tab.
|
||||
let tab = self
|
||||
.active_tab
|
||||
.as_ref()
|
||||
.and_then(|id| self.tabs.iter().find(|t| &t.id == id));
|
||||
|
||||
let tab = tab.or_else(|| {
|
||||
let first_id = self
|
||||
.active_group
|
||||
.as_ref()
|
||||
.and_then(|gid| self.tab_groups.iter().find(|g| &g.id == gid))
|
||||
.and_then(|g| g.pane_root.tab_ids().into_iter().next())
|
||||
.map(|s| s.to_string());
|
||||
first_id
|
||||
.as_deref()
|
||||
.and_then(|id| self.tabs.iter().find(|t| t.id == id))
|
||||
});
|
||||
|
||||
let tab = tab.or_else(|| self.tabs.first());
|
||||
|
||||
let Some(tab) = tab else {
|
||||
self.status = t!("no_results").into();
|
||||
self.refocus_search_input(window, cx);
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
|
||||
// Remember which tab was searched so highlights only appear in that pane.
|
||||
self.search_target_tab = Some(tab.id.clone());
|
||||
|
||||
let query_lower = query.to_lowercase();
|
||||
let query_byte_len = query.len();
|
||||
|
||||
// Search the ENTIRE terminal buffer (scrollback + visible screen).
|
||||
// full_grid_rows returns grid line indices; the first row is at
|
||||
// `grid_start` (typically -history_size).
|
||||
let (grid_start, all_rows) = tab.full_grid_rows();
|
||||
|
||||
let mut matches: Vec<(i32, i32)> = Vec::new();
|
||||
|
||||
for (row_idx, row) in all_rows.iter().enumerate() {
|
||||
if row.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build text string and byte→column index mapping.
|
||||
let mut text = String::with_capacity(row.len());
|
||||
let mut byte_to_col: Vec<i32> = Vec::new();
|
||||
for &(col, c) in row {
|
||||
text.push(c);
|
||||
while byte_to_col.len() < text.len() {
|
||||
byte_to_col.push(col);
|
||||
}
|
||||
}
|
||||
let text_lower = text.to_lowercase();
|
||||
|
||||
// Grid line index for this row.
|
||||
let abs_row = grid_start + row_idx as i32;
|
||||
|
||||
let mut search_start = 0;
|
||||
while let Some(pos) = text_lower[search_start..].find(&query_lower) {
|
||||
let abs = search_start + pos;
|
||||
let start_col = byte_to_col[abs];
|
||||
let end_byte = (abs + query_byte_len).min(byte_to_col.len());
|
||||
let end_col = byte_to_col[end_byte - 1];
|
||||
for c in start_col..=end_col {
|
||||
matches.push((abs_row, c));
|
||||
}
|
||||
search_start = abs + query_byte_len;
|
||||
}
|
||||
}
|
||||
|
||||
let match_count = count_match_groups(&matches);
|
||||
|
||||
self.search_query = query;
|
||||
self.search_matches = matches;
|
||||
|
||||
if match_count > 0 {
|
||||
self.search_current = 0;
|
||||
self.jump_to_current_match(cx);
|
||||
}
|
||||
|
||||
self.status = format!(
|
||||
"{}: {} ({})",
|
||||
t!("search"),
|
||||
self.search_query,
|
||||
if match_count == 0 {
|
||||
t!("no_results").to_string()
|
||||
} else {
|
||||
format!("{}/{}", self.search_current + 1, match_count)
|
||||
}
|
||||
)
|
||||
.into();
|
||||
|
||||
// Keep focus on the search input so the user can continue typing.
|
||||
self.refocus_search_input(window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn search_goto_next(&mut self, cx: &mut Context<Self>) {
|
||||
let match_count = count_match_groups(&self.search_matches);
|
||||
if match_count == 0 {
|
||||
return;
|
||||
}
|
||||
self.search_current = (self.search_current + 1) % match_count;
|
||||
self.jump_to_current_match(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn search_goto_prev(&mut self, cx: &mut Context<Self>) {
|
||||
let match_count = count_match_groups(&self.search_matches);
|
||||
if match_count == 0 {
|
||||
return;
|
||||
}
|
||||
self.search_current = (self.search_current + match_count - 1) % match_count;
|
||||
self.jump_to_current_match(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn jump_to_current_match(&mut self, _cx: &mut Context<Self>) {
|
||||
// target_grid_line is the grid line index (negative = history).
|
||||
let Some((target_grid_line, _)) =
|
||||
find_nth_match_start(&self.search_matches, self.search_current)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Find the tab ID first (immutable borrow), then look up mutably.
|
||||
let tab_id = self.active_tab.clone().or_else(|| {
|
||||
self.active_group
|
||||
.as_ref()
|
||||
.and_then(|gid| self.tab_groups.iter().find(|g| &g.id == gid))
|
||||
.and_then(|g| g.pane_root.tab_ids().into_iter().next())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
let tab = if let Some(id) = tab_id.as_deref() {
|
||||
self.tabs.iter_mut().find(|t| t.id == id)
|
||||
} else {
|
||||
self.tabs.first_mut()
|
||||
};
|
||||
|
||||
if let Some(tab) = tab {
|
||||
let snapshot = tab.render_snapshot();
|
||||
let display_offset = snapshot.display_offset as i32;
|
||||
let rows = snapshot.rows as i32;
|
||||
|
||||
// viewport row = grid_line + display_offset
|
||||
let vp_row = target_grid_line + display_offset;
|
||||
let visible = vp_row >= 0 && vp_row < rows;
|
||||
|
||||
if !visible {
|
||||
// Scroll so the target grid line appears near the top.
|
||||
// display_offset = -grid_line puts it at viewport row 0.
|
||||
let new_offset = (-target_grid_line).max(0) as usize;
|
||||
if new_offset > snapshot.display_offset {
|
||||
tab.scroll_up_by(new_offset - snapshot.display_offset);
|
||||
} else if new_offset < snapshot.display_offset {
|
||||
tab.scroll_down_by(snapshot.display_offset - new_offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a highlight map for search matches, converting grid line indices
|
||||
/// to the current viewport coordinates. Only returns highlights for the
|
||||
/// pane that was actually searched (`search_target_tab`).
|
||||
pub(crate) fn search_highlight_map(
|
||||
&self,
|
||||
tab_id: &str,
|
||||
match_color: Hsla,
|
||||
current_color: Hsla,
|
||||
) -> Option<HashMap<(i32, i32), Hsla>> {
|
||||
if self.search_matches.is_empty()
|
||||
|| self.search_query.is_empty()
|
||||
|| self.search_target_tab.as_deref() != Some(tab_id)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// Get current display_offset to convert grid line → viewport row.
|
||||
let tab = self
|
||||
.active_tab
|
||||
.as_ref()
|
||||
.and_then(|id| self.tabs.iter().find(|t| &t.id == id));
|
||||
|
||||
let tab = tab.or_else(|| {
|
||||
let first_id = self
|
||||
.active_group
|
||||
.as_ref()
|
||||
.and_then(|gid| self.tab_groups.iter().find(|g| &g.id == gid))
|
||||
.and_then(|g| g.pane_root.tab_ids().into_iter().next())
|
||||
.map(|s| s.to_string());
|
||||
first_id
|
||||
.as_deref()
|
||||
.and_then(|id| self.tabs.iter().find(|t| t.id == id))
|
||||
});
|
||||
|
||||
let tab = tab.or_else(|| self.tabs.first());
|
||||
|
||||
let Some(tab) = tab else {
|
||||
return None;
|
||||
};
|
||||
let snapshot = tab.render_snapshot();
|
||||
let display_offset = snapshot.display_offset as i32;
|
||||
let rows = snapshot.rows as i32;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
|
||||
let mut sorted: Vec<(i32, i32)> = self.search_matches.clone();
|
||||
sorted.sort();
|
||||
|
||||
let mut group_idx = 0;
|
||||
let mut i = 0;
|
||||
while i < sorted.len() {
|
||||
let is_current = group_idx == self.search_current;
|
||||
let color = if is_current {
|
||||
current_color
|
||||
} else {
|
||||
match_color
|
||||
};
|
||||
|
||||
// grid_line → viewport row: vp_row = grid_line + display_offset
|
||||
let (grid_line, _) = sorted[i];
|
||||
let vp_row = grid_line + display_offset;
|
||||
let mut j = i;
|
||||
if vp_row >= 0 && vp_row < rows {
|
||||
while j < sorted.len() && sorted[j].0 == grid_line {
|
||||
if j > i && sorted[j].1 != sorted[j - 1].1 + 1 {
|
||||
break;
|
||||
}
|
||||
map.insert((vp_row, sorted[j].1), color);
|
||||
j += 1;
|
||||
}
|
||||
} else {
|
||||
// Outside current viewport — skip.
|
||||
while j < sorted.len() && sorted[j].0 == grid_line {
|
||||
if j > i && sorted[j].1 != sorted[j - 1].1 + 1 {
|
||||
break;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
group_idx += 1;
|
||||
i = j;
|
||||
}
|
||||
|
||||
Some(map)
|
||||
}
|
||||
|
||||
/// Render the search button (used in the tab bar).
|
||||
pub(crate) fn render_search_button(
|
||||
&self,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
// Wrap in a div so .hover() doesn't conflict with Button's internal hover.
|
||||
div()
|
||||
.child(
|
||||
Button::new("search-btn")
|
||||
.ghost()
|
||||
.small()
|
||||
.rounded(px(999.))
|
||||
.icon(IconName::Search)
|
||||
.tooltip(t!("search").to_string())
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.toggle_search(window, cx);
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// Render the expanded search bar overlay (when search is active).
|
||||
pub(crate) fn render_search_bar(
|
||||
&mut self,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
let match_count = count_match_groups(&self.search_matches);
|
||||
let has_query = !self.search_query.is_empty();
|
||||
let has_matches = match_count > 0;
|
||||
let current_display = if has_matches {
|
||||
format!("{}/{}", self.search_current + 1, match_count)
|
||||
} else if has_query {
|
||||
"0".to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let view = cx.entity();
|
||||
div()
|
||||
.absolute()
|
||||
.top(px(8.))
|
||||
.right(px(24.))
|
||||
.on_prepaint(move |bounds, _window, cx| {
|
||||
let _ = view.update(cx, |this, _| {
|
||||
this.search_bar_bounds = Some(bounds);
|
||||
});
|
||||
})
|
||||
.on_mouse_down(MouseButton::Left, cx.listener(|this, _, window, cx| {
|
||||
this.refocus_search_input(window, cx);
|
||||
cx.stop_propagation();
|
||||
}))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.p_1()
|
||||
.rounded(px(6.))
|
||||
.bg(cx.theme().popover)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
div()
|
||||
.w(px(200.))
|
||||
.on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, window, cx| {
|
||||
if event.keystroke.key.as_str() == "escape" {
|
||||
this.close_search(window, cx);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.child(Input::new(&self.search_input).small()),
|
||||
)
|
||||
.when(!current_display.is_empty(), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.text_size(rems(0.75))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.min_w(px(36.))
|
||||
.text_center()
|
||||
.child(current_display),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Button::new("search-prev")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.icon(IconName::ChevronUp)
|
||||
.disabled(!has_matches)
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.search_goto_prev(cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("search-next")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.icon(IconName::ChevronDown)
|
||||
.disabled(!has_matches)
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.search_goto_next(cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("search-close")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.icon(IconName::Close)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.close_search(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Count distinct match groups in a sorted list of (row, col) positions.
|
||||
/// A group is a run of consecutive columns in the same row.
|
||||
fn count_match_groups(matches: &[(i32, i32)]) -> usize {
|
||||
if matches.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let mut sorted: Vec<(i32, i32)> = matches.to_vec();
|
||||
sorted.sort();
|
||||
let mut count = 0;
|
||||
let mut i = 0;
|
||||
while i < sorted.len() {
|
||||
count += 1;
|
||||
let (r, _) = sorted[i];
|
||||
i += 1;
|
||||
// Skip consecutive columns in the same row.
|
||||
while i < sorted.len() && sorted[i].0 == r && sorted[i].1 == sorted[i - 1].1 + 1 {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Find the (row, col) start of the Nth distinct match group.
|
||||
fn find_nth_match_start(matches: &[(i32, i32)], n: usize) -> Option<(i32, i32)> {
|
||||
if matches.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut sorted: Vec<(i32, i32)> = matches.to_vec();
|
||||
sorted.sort();
|
||||
let mut group_idx = 0;
|
||||
let mut i = 0;
|
||||
while i < sorted.len() {
|
||||
if group_idx == n {
|
||||
return Some(sorted[i]);
|
||||
}
|
||||
group_idx += 1;
|
||||
let (r, _) = sorted[i];
|
||||
i += 1;
|
||||
while i < sorted.len() && sorted[i].0 == r && sorted[i].1 == sorted[i - 1].1 + 1 {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub(crate) const EMBEDDED_THEME_JSONS: &[&str] = &[
|
||||
include_str!("../../assets/themes/tokyonight.json"),
|
||||
include_str!("../../assets/themes/gruvbox.json"),
|
||||
include_str!("../../assets/themes/solarized.json"),
|
||||
include_str!("../../assets/themes/phygerr.json"),
|
||||
];
|
||||
|
||||
pub(crate) fn load_fonts(cx: &mut App) -> Result<()> {
|
||||
|
||||
+45
-31
@@ -2156,43 +2156,51 @@ impl Ashell {
|
||||
cx.stop_propagation();
|
||||
this.split_current_pane("right", cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(self.render_search_button(cx)),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_terminal_panel(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
fn render_terminal_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let has_active = self.active_tab.is_some();
|
||||
let pane_tree = self.pane_root.clone();
|
||||
let view = cx.entity();
|
||||
|
||||
v_flex().size_full().child(
|
||||
div()
|
||||
.size_full()
|
||||
.on_prepaint(move |bounds, _window, cx| {
|
||||
let _ = view.update(cx, |this, _| {
|
||||
this.terminal_panel_bounds = Some(bounds);
|
||||
});
|
||||
})
|
||||
.overflow_hidden()
|
||||
.track_focus(&self.focus_handle)
|
||||
.key_context(TERMINAL_KEY_CONTEXT)
|
||||
.on_mouse_down(MouseButton::Left, cx.listener(Self::focus_terminal))
|
||||
.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
cx.listener(Self::on_terminal_right_click),
|
||||
)
|
||||
.on_mouse_move(cx.listener(Self::on_terminal_mouse_move))
|
||||
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_terminal_mouse_up))
|
||||
.on_key_down(cx.listener(Self::on_terminal_key_down))
|
||||
.on_action(cx.listener(Self::on_terminal_tab_action))
|
||||
.on_action(cx.listener(Self::on_terminal_backtab_action))
|
||||
.on_scroll_wheel(cx.listener(Self::on_terminal_scroll))
|
||||
.child(if has_active {
|
||||
Self::render_pane_tree(self, &pane_tree, &[], cx).into_any_element()
|
||||
} else {
|
||||
self.render_home_page(cx).into_any_element()
|
||||
}),
|
||||
)
|
||||
div()
|
||||
.size_full()
|
||||
.relative()
|
||||
.child(
|
||||
div()
|
||||
.size_full()
|
||||
.on_prepaint(move |bounds, _window, cx| {
|
||||
let _ = view.update(cx, |this, _| {
|
||||
this.terminal_panel_bounds = Some(bounds);
|
||||
});
|
||||
})
|
||||
.overflow_hidden()
|
||||
.track_focus(&self.focus_handle)
|
||||
.key_context(TERMINAL_KEY_CONTEXT)
|
||||
.on_mouse_down(MouseButton::Left, cx.listener(Self::focus_terminal))
|
||||
.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
cx.listener(Self::on_terminal_right_click),
|
||||
)
|
||||
.on_mouse_move(cx.listener(Self::on_terminal_mouse_move))
|
||||
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_terminal_mouse_up))
|
||||
.on_key_down(cx.listener(Self::on_terminal_key_down))
|
||||
.on_action(cx.listener(Self::on_terminal_tab_action))
|
||||
.on_action(cx.listener(Self::on_terminal_backtab_action))
|
||||
.on_scroll_wheel(cx.listener(Self::on_terminal_scroll))
|
||||
.child(if has_active {
|
||||
Self::render_pane_tree(self, &pane_tree, &[], cx).into_any_element()
|
||||
} else {
|
||||
self.render_home_page(cx).into_any_element()
|
||||
}),
|
||||
)
|
||||
// Search bar overlay — only when search is active.
|
||||
.when(self.search_active, |el| {
|
||||
el.child(self.render_search_bar(window, cx))
|
||||
})
|
||||
}
|
||||
|
||||
fn render_pane_tree(
|
||||
@@ -2252,6 +2260,11 @@ impl Ashell {
|
||||
font_size,
|
||||
line_height,
|
||||
cell_width,
|
||||
this.search_highlight_map(
|
||||
tab_id,
|
||||
cx.theme().danger.opacity(0.35),
|
||||
cx.theme().danger.opacity(0.70),
|
||||
),
|
||||
));
|
||||
let scrollbar = this.terminal_scrollbars.entry(tab_id.clone()).or_default();
|
||||
el = el.vertical_scrollbar(scrollbar);
|
||||
@@ -2489,7 +2502,7 @@ impl Render for Ashell {
|
||||
|
||||
let body_panel = v_resizable("ashell-body")
|
||||
.with_state(&self.body_panels)
|
||||
.child(resizable_panel().child(self.render_terminal_panel(cx)))
|
||||
.child(resizable_panel().child(self.render_terminal_panel(window, cx)))
|
||||
.child(
|
||||
resizable_panel()
|
||||
.size(sftp_size)
|
||||
@@ -2594,6 +2607,7 @@ impl Render for Ashell {
|
||||
.on_action(cx.listener(|this, _: &crate::OpenSession, window, cx| this.show_selector_dialog(window, cx)))
|
||||
.on_action(cx.listener(|this, _: &crate::OpenTransfers, window, cx| this.show_transfers_dialog(window, cx)))
|
||||
.on_action(cx.listener(|this, _: &crate::NewSsh, window, cx| this.show_ssh_dialog(window, cx)))
|
||||
.on_action(cx.listener(|this, _: &crate::OpenSearch, window, cx| this.toggle_search(window, cx)))
|
||||
.on_action(cx.listener(|this, _: &crate::ToggleSidebar, _, cx| {
|
||||
this.sidebar_collapsed = !this.sidebar_collapsed;
|
||||
this.config.set_sidebar_collapsed(this.sidebar_collapsed);
|
||||
|
||||
+3
-3
@@ -16,9 +16,9 @@ rust_i18n::i18n!("locales", fallback = "en");
|
||||
gpui::actions!(ashell_terminal, [TerminalTabKey, TerminalBacktabKey]);
|
||||
|
||||
pub(crate) use app::keybinding_recorder::{
|
||||
ClosePane, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSession,
|
||||
OpenSettings, OpenTransfers, SplitPaneDown, SplitPaneLeft, SplitPaneRight, SplitPaneUp,
|
||||
ToggleSftpZoom, ToggleSidebar,
|
||||
ClosePane, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSearch,
|
||||
OpenSession, OpenSettings, OpenTransfers, SplitPaneDown, SplitPaneLeft, SplitPaneRight,
|
||||
SplitPaneUp, ToggleSftpZoom, ToggleSidebar,
|
||||
};
|
||||
|
||||
pub(crate) use app::{
|
||||
|
||||
+28
-1
@@ -805,6 +805,15 @@ impl Ashell {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// If the search bar is visible and the click is inside it, let the
|
||||
// search bar handle the event instead of switching pane focus.
|
||||
if self.search_active {
|
||||
if let Some(bounds) = self.search_bar_bounds {
|
||||
if bounds.contains(&event.position) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.focus_handle.focus(window, cx);
|
||||
// Check if click is in a different pane and focus it
|
||||
let click_pos = event.position;
|
||||
@@ -1052,7 +1061,16 @@ impl Ashell {
|
||||
if let Some(new_path) = Self::find_adjacent_pane(&self.pane_root, &path, direction) {
|
||||
self.focused_pane_path = new_path;
|
||||
if let Some(id) = self.pane_root.focused_tab_id(&self.focused_pane_path) {
|
||||
self.active_tab = Some(id.to_string());
|
||||
let id_owned = id.to_string();
|
||||
let changed = self.active_tab.as_deref() != Some(id_owned.as_str());
|
||||
self.active_tab = Some(id_owned);
|
||||
// Clear stale search state when switching to a different pane.
|
||||
if changed && self.search_active {
|
||||
self.search_query.clear();
|
||||
self.search_matches.clear();
|
||||
self.search_current = 0;
|
||||
self.search_target_tab = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1335,8 +1353,17 @@ impl Ashell {
|
||||
}
|
||||
let mut path = Vec::new();
|
||||
if find_path(&self.pane_root, &tab_id, &mut path) {
|
||||
let changed = self.active_tab.as_deref() != Some(tab_id.as_str());
|
||||
self.focused_pane_path = path;
|
||||
self.active_tab = Some(tab_id);
|
||||
// Clear stale search state when switching to a different pane.
|
||||
// The user can press Enter to re-search in the new pane.
|
||||
if changed && self.search_active {
|
||||
self.search_query.clear();
|
||||
self.search_matches.clear();
|
||||
self.search_current = 0;
|
||||
self.search_target_tab = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use gpui::{Bounds, Hsla, Path, Pixels, Window, fill, point, px, size};
|
||||
|
||||
use super::RenderCell;
|
||||
|
||||
pub fn is_custom_block_supported(c: char) -> bool {
|
||||
match c as u32 {
|
||||
0x2580..=0x258F | 0x2590 | 0x2594..=0x259F => true, // Block Elements
|
||||
@@ -335,3 +339,390 @@ pub fn paint_custom_block(
|
||||
|
||||
painted
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terminal keyword / pattern highlighting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn hsla(r: u8, g: u8, b: u8) -> Hsla {
|
||||
Hsla {
|
||||
h: 0.0,
|
||||
s: 0.0,
|
||||
l: 0.0,
|
||||
a: 1.0,
|
||||
}
|
||||
.into_rgba_like(r, g, b)
|
||||
}
|
||||
|
||||
trait HslaExt {
|
||||
fn into_rgba_like(self, r: u8, g: u8, b: u8) -> Self;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Highlight colors for common terminal keywords and patterns.
|
||||
struct HighlightColors {
|
||||
error: Hsla, // ERROR, FATAL, CRITICAL, PANIC
|
||||
success: Hsla, // SUCCESS, OK, PASS
|
||||
warning: Hsla, // WARN, WARNING
|
||||
info: Hsla, // INFO, NOTICE
|
||||
failure: Hsla, // FAIL, FAILED, DENIED, REJECTED, TIMEOUT
|
||||
network: Hsla, // IP addresses
|
||||
url: Hsla, // http://, https://
|
||||
port: Hsla, // :22, :443, etc.
|
||||
debug: Hsla, // DEBUG, DBG, TRACE
|
||||
}
|
||||
|
||||
fn highlight_colors() -> HighlightColors {
|
||||
HighlightColors {
|
||||
error: hsla(224, 96, 96), // #E06060 red
|
||||
success: hsla(126, 198, 153), // #7EC699 green
|
||||
warning: hsla(232, 201, 122), // #E8C97A yellow
|
||||
info: hsla(108, 180, 238), // #6CB4EE blue
|
||||
failure: hsla(232, 168, 124), // #E8A87C orange
|
||||
network: hsla(199, 146, 234), // #C792EA purple
|
||||
url: hsla( 86, 212, 199), // #56D4C7 teal
|
||||
port: hsla(130, 170, 200), // #82AAC8 muted teal
|
||||
debug: hsla(130, 140, 155), // #828C9B gray
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if `c` is a word boundary (not alphanumeric or underscore).
|
||||
fn is_boundary(c: char) -> bool {
|
||||
!c.is_ascii_alphanumeric() && c != '_'
|
||||
}
|
||||
|
||||
/// Scan terminal cells for common keywords and patterns, returning a map of
|
||||
/// `(row, col) -> highlight_color` for cells that should be recolored.
|
||||
/// If `search_map` is provided, search match colors take priority over keyword colors.
|
||||
pub fn highlight_cells(
|
||||
cells: &[RenderCell],
|
||||
rows: usize,
|
||||
search_map: Option<&HashMap<(i32, i32), Hsla>>,
|
||||
) -> HashMap<(i32, i32), Hsla> {
|
||||
let colors = highlight_colors();
|
||||
|
||||
// Build a per-row char array with cell column tracking.
|
||||
// row_chars[row] = Vec<(col, char)>
|
||||
let mut row_chars: Vec<Vec<(i32, char)>> = vec![vec![]; rows];
|
||||
for rc in cells {
|
||||
if rc.row < 0 || (rc.row as usize) >= rows {
|
||||
continue;
|
||||
}
|
||||
row_chars[rc.row as usize].push((rc.col, rc.cell.c));
|
||||
}
|
||||
for row in row_chars.iter_mut() {
|
||||
row.sort_by_key(|&(col, _)| col);
|
||||
}
|
||||
|
||||
let mut map = HashMap::new();
|
||||
|
||||
for (row_idx, row) in row_chars.iter().enumerate() {
|
||||
if row.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let row_i32 = row_idx as i32;
|
||||
|
||||
// Build the text string and a byte-offset → column index lookup.
|
||||
let mut chars_buf = String::with_capacity(row.len());
|
||||
let mut byte_to_col: Vec<i32> = Vec::new();
|
||||
for &(col, c) in row {
|
||||
chars_buf.push(c);
|
||||
// Pad the mapping so every byte of this char points to `col`.
|
||||
while byte_to_col.len() < chars_buf.len() {
|
||||
byte_to_col.push(col);
|
||||
}
|
||||
}
|
||||
let text = chars_buf.as_str();
|
||||
|
||||
// ── 1. Error keywords (highest priority) ──────────────────────────
|
||||
for kw in &["EMERGENCY", "CRITICAL", "FATAL", "PANIC", "ERROR", "ERR"] {
|
||||
for m in find_keyword(text, kw) {
|
||||
let start_col = byte_to_col[m];
|
||||
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
|
||||
for c in start_col..=end_col {
|
||||
map.entry((row_i32, c)).or_insert(colors.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Success keywords ───────────────────────────────────────────
|
||||
for kw in &["SUCCESS", "SUCCEEDED", "PASSED", "PASS", "OK"] {
|
||||
for m in find_keyword(text, kw) {
|
||||
let start_col = byte_to_col[m];
|
||||
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
|
||||
for c in start_col..=end_col {
|
||||
map.entry((row_i32, c)).or_insert(colors.success);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Failure keywords ───────────────────────────────────────────
|
||||
for kw in &["FAILED", "FAILURE", "DENIED", "REJECTED", "TIMEOUT", "FAIL"] {
|
||||
for m in find_keyword(text, kw) {
|
||||
let start_col = byte_to_col[m];
|
||||
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
|
||||
for c in start_col..=end_col {
|
||||
map.entry((row_i32, c)).or_insert(colors.failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Warning keywords ───────────────────────────────────────────
|
||||
for kw in &["WARNING", "WARN"] {
|
||||
for m in find_keyword(text, kw) {
|
||||
let start_col = byte_to_col[m];
|
||||
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
|
||||
for c in start_col..=end_col {
|
||||
map.entry((row_i32, c)).or_insert(colors.warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. Info keywords ──────────────────────────────────────────────
|
||||
for kw in &["NOTICE", "INFO"] {
|
||||
for m in find_keyword(text, kw) {
|
||||
let start_col = byte_to_col[m];
|
||||
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
|
||||
for c in start_col..=end_col {
|
||||
map.entry((row_i32, c)).or_insert(colors.info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Debug keywords ─────────────────────────────────────────────
|
||||
for kw in &["DEBUG", "DBG", "TRACE"] {
|
||||
for m in find_keyword(text, kw) {
|
||||
let start_col = byte_to_col[m];
|
||||
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
|
||||
for c in start_col..=end_col {
|
||||
map.entry((row_i32, c)).or_insert(colors.debug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 7. IP addresses ───────────────────────────────────────────────
|
||||
for m in find_ip_addresses(text) {
|
||||
let start_col = byte_to_col[m];
|
||||
let end_col = byte_to_col[(m + find_ip_len(&text[m..])).min(byte_to_col.len() - 1)];
|
||||
for c in start_col..=end_col {
|
||||
map.entry((row_i32, c)).or_insert(colors.network);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 8. URLs ───────────────────────────────────────────────────────
|
||||
for m in find_urls(text) {
|
||||
let url_len = find_url_len(&text[m..]);
|
||||
let start_col = byte_to_col[m];
|
||||
let end_col = byte_to_col[(m + url_len).min(byte_to_col.len() - 1)];
|
||||
for c in start_col..=end_col {
|
||||
map.entry((row_i32, c)).or_insert(colors.url);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 9. Port numbers (:digits) ─────────────────────────────────────
|
||||
for m in find_ports(text) {
|
||||
let port_len = find_port_len(&text[m..]);
|
||||
let start_col = byte_to_col[m];
|
||||
let end_col = byte_to_col[(m + port_len).min(byte_to_col.len() - 1)];
|
||||
for c in start_col..=end_col {
|
||||
map.entry((row_i32, c)).or_insert(colors.port);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge search highlight map — search colors override keyword colors.
|
||||
if let Some(sm) = search_map {
|
||||
for (key, color) in sm {
|
||||
map.insert(*key, *color);
|
||||
}
|
||||
}
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
/// Find all occurrences of `keyword` as a whole word in `text`.
|
||||
/// Returns byte offsets.
|
||||
fn find_keyword(text: &str, keyword: &str) -> Vec<usize> {
|
||||
let mut positions = Vec::new();
|
||||
let mut start = 0;
|
||||
while let Some(pos) = text[start..].find(keyword) {
|
||||
let abs = start + pos;
|
||||
let before_ok = abs == 0
|
||||
|| text.as_bytes()[abs - 1] == b' '
|
||||
|| is_boundary(text.as_bytes()[abs - 1] as char);
|
||||
let after_pos = abs + keyword.len();
|
||||
let after_ok = after_pos >= text.len()
|
||||
|| text.as_bytes()[after_pos] == b' '
|
||||
|| is_boundary(text.as_bytes()[after_pos] as char);
|
||||
if before_ok && after_ok {
|
||||
positions.push(abs);
|
||||
}
|
||||
start = abs + keyword.len();
|
||||
}
|
||||
positions
|
||||
}
|
||||
|
||||
/// Check if `text` starts with a valid IP address. Returns byte length if so.
|
||||
fn find_ip_len(text: &str) -> usize {
|
||||
let bytes = text.as_bytes();
|
||||
let mut dots = 0u8;
|
||||
let mut digits = 0u8;
|
||||
let mut len = 0usize;
|
||||
|
||||
for &b in bytes {
|
||||
match b {
|
||||
b'0'..=b'9' => {
|
||||
digits += 1;
|
||||
if digits > 3 {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
b'.' => {
|
||||
if digits == 0 {
|
||||
return 0;
|
||||
}
|
||||
dots += 1;
|
||||
if dots > 3 {
|
||||
return 0;
|
||||
}
|
||||
digits = 0;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
len += 1;
|
||||
}
|
||||
|
||||
if dots == 3 && digits > 0 {
|
||||
len
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Find byte offsets of IP addresses in text.
|
||||
fn find_ip_addresses(text: &str) -> Vec<usize> {
|
||||
let mut positions = Vec::new();
|
||||
let bytes = text.as_bytes();
|
||||
let len = bytes.len();
|
||||
|
||||
for i in 0..len {
|
||||
if bytes[i].is_ascii_digit()
|
||||
&& (i == 0 || is_boundary(bytes[i - 1] as char))
|
||||
{
|
||||
let remaining = &text[i..];
|
||||
let ip_len = find_ip_len(remaining);
|
||||
if ip_len > 0 {
|
||||
// Validate each octet is 0-255.
|
||||
let ip_str = &remaining[..ip_len];
|
||||
let valid = ip_str
|
||||
.split('.')
|
||||
.all(|octet| octet.parse::<u8>().is_ok());
|
||||
if valid {
|
||||
positions.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
positions
|
||||
}
|
||||
|
||||
/// Find byte offsets of URLs starting with http:// or https://
|
||||
fn find_urls(text: &str) -> Vec<usize> {
|
||||
let mut positions = Vec::new();
|
||||
let mut start = 0;
|
||||
while let Some(pos) = text[start..].find("http") {
|
||||
let abs = start + pos;
|
||||
let remaining = &text[abs..];
|
||||
if remaining.starts_with("https://") || remaining.starts_with("http://") {
|
||||
if abs == 0 || is_boundary(text.as_bytes()[abs - 1] as char) {
|
||||
positions.push(abs);
|
||||
}
|
||||
}
|
||||
start = abs + 4;
|
||||
}
|
||||
positions
|
||||
}
|
||||
|
||||
/// Get byte length of a URL token (until whitespace or end of string).
|
||||
fn find_url_len(text: &str) -> usize {
|
||||
text.find(|c: char| c.is_ascii_whitespace())
|
||||
.unwrap_or(text.len())
|
||||
}
|
||||
|
||||
/// Find byte offsets of port patterns like `:22`, `:443`, `:8080`.
|
||||
fn find_ports(text: &str) -> Vec<usize> {
|
||||
let mut positions = Vec::new();
|
||||
let bytes = text.as_bytes();
|
||||
let len = bytes.len();
|
||||
|
||||
for i in 0..len {
|
||||
if bytes[i] == b':'
|
||||
&& i + 1 < len
|
||||
&& bytes[i + 1].is_ascii_digit()
|
||||
&& (i == 0 || is_boundary(bytes[i - 1] as char) || bytes[i - 1] == b' ')
|
||||
{
|
||||
let mut j = i + 1;
|
||||
while j < len && bytes[j].is_ascii_digit() {
|
||||
j += 1;
|
||||
}
|
||||
let port_str = &text[i + 1..j];
|
||||
if let Ok(port) = port_str.parse::<u16>() {
|
||||
if port > 0 {
|
||||
let after_ok = j >= len || is_boundary(bytes[j] as char);
|
||||
if after_ok {
|
||||
positions.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
positions
|
||||
}
|
||||
|
||||
/// Get byte length of a port token starting at ':'.
|
||||
fn find_port_len(text: &str) -> usize {
|
||||
if !text.starts_with(':') {
|
||||
return 0;
|
||||
}
|
||||
let mut len = 1;
|
||||
for b in text.as_bytes()[1..].iter() {
|
||||
if b.is_ascii_digit() {
|
||||
len += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
len
|
||||
}
|
||||
|
||||
+14
-2
@@ -11,7 +11,7 @@ use gpui::{
|
||||
use gpui_component::ActiveTheme as _;
|
||||
|
||||
use crate::Ashell;
|
||||
use crate::terminal::custom_blocks::{is_custom_block_supported, paint_custom_block};
|
||||
use crate::terminal::custom_blocks::{highlight_cells, is_custom_block_supported, paint_custom_block};
|
||||
use crate::terminal::{RenderSnapshot, ViewportSelection};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -132,6 +132,7 @@ pub struct TerminalElement {
|
||||
font_size: Pixels,
|
||||
line_height: Pixels,
|
||||
cell_width: Pixels,
|
||||
search_highlights: Option<std::collections::HashMap<(i32, i32), Hsla>>,
|
||||
}
|
||||
|
||||
pub struct PrepaintState {
|
||||
@@ -270,6 +271,7 @@ impl TerminalElement {
|
||||
font_size: Pixels,
|
||||
line_height: Pixels,
|
||||
cell_width: Pixels,
|
||||
search_highlights: Option<std::collections::HashMap<(i32, i32), Hsla>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
view,
|
||||
@@ -280,6 +282,7 @@ impl TerminalElement {
|
||||
font_size,
|
||||
line_height,
|
||||
cell_width,
|
||||
search_highlights,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,6 +357,10 @@ impl TerminalElement {
|
||||
let mut custom_blocks = Vec::new();
|
||||
let mut current_run: Option<BatchedTextRun> = None;
|
||||
|
||||
// Compute keyword / pattern highlight map once per frame.
|
||||
let highlights =
|
||||
highlight_cells(&self.snapshot.cells, self.snapshot.rows, self.search_highlights.as_ref());
|
||||
|
||||
for render_cell in &self.snapshot.cells {
|
||||
let cell = &render_cell.cell;
|
||||
if cell.flags.intersects(
|
||||
@@ -392,7 +399,12 @@ impl TerminalElement {
|
||||
continue;
|
||||
}
|
||||
|
||||
let style = self.cell_run_style(cell, cx);
|
||||
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;
|
||||
}
|
||||
|
||||
// Box Drawing & Block Elements interception
|
||||
let is_custom_block = is_custom_block_supported(cell.c);
|
||||
|
||||
+13
-2
@@ -3,8 +3,8 @@ use std::ops::Range;
|
||||
use alacritty_terminal::index::Side;
|
||||
use alacritty_terminal::selection::SelectionType;
|
||||
use gpui::{
|
||||
ClipboardItem, Context, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent,
|
||||
MouseUpEvent, Pixels, Point, ScrollDelta, ScrollWheelEvent, Window, px,
|
||||
ClipboardItem, Context, Focusable as _, KeyDownEvent, MouseButton, MouseDownEvent,
|
||||
MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollDelta, ScrollWheelEvent, Window, px,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@@ -19,6 +19,17 @@ impl Ashell {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// If the search input is focused, skip terminal key processing
|
||||
// so the input can handle text entry, paste, etc. normally.
|
||||
if self
|
||||
.search_input
|
||||
.read(cx)
|
||||
.focus_handle(cx)
|
||||
.is_focused(window)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Pane navigation: Alt + h/j/k/l
|
||||
if event.keystroke.modifiers.alt
|
||||
&& !event.keystroke.modifiers.shift
|
||||
|
||||
+29
-1
@@ -7,7 +7,7 @@ use std::sync::mpsc::Sender;
|
||||
use alacritty_terminal::{
|
||||
event::{Event, EventListener},
|
||||
grid::{Dimensions, Scroll},
|
||||
index::{Column, Point, Side},
|
||||
index::{Column, Line, Point, Side},
|
||||
selection::{Selection, SelectionRange, SelectionType},
|
||||
term::{Config, Term, TermMode, cell::Cell, point_to_viewport, viewport_to_point},
|
||||
vte::ansi::{CursorShape, Processor},
|
||||
@@ -339,6 +339,34 @@ impl TerminalTab {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return `(grid_line_base, rows_data)` for the **entire** terminal buffer
|
||||
/// including scrollback history. `grid_line_base` is the grid line index of
|
||||
/// the first row (typically `-history_size`). Each entry in `rows_data` is
|
||||
/// a sorted `Vec<(col, char)>` for that row.
|
||||
pub fn full_grid_rows(&self) -> (i32, Vec<Vec<(i32, char)>>) {
|
||||
let grid = self.term.grid();
|
||||
let history = grid.history_size() as i32;
|
||||
let screen = grid.screen_lines() as i32;
|
||||
let total = history + screen;
|
||||
let cols = self.cols as i32;
|
||||
let start_line = -history;
|
||||
|
||||
let mut rows_data: Vec<Vec<(i32, char)>> = Vec::with_capacity(total as usize);
|
||||
for line_idx in start_line..(start_line + total) {
|
||||
let line = Line(line_idx);
|
||||
let mut cells: Vec<(i32, char)> = Vec::new();
|
||||
for col_idx in 0..cols {
|
||||
let point = Point::new(line, Column(col_idx as usize));
|
||||
let c = grid[point].c;
|
||||
if c != ' ' && c != '\0' {
|
||||
cells.push((col_idx, c));
|
||||
}
|
||||
}
|
||||
rows_data.push(cells);
|
||||
}
|
||||
(start_line, rows_data)
|
||||
}
|
||||
|
||||
pub fn scroll_history(&mut self, delta: i32) {
|
||||
if delta != 0 {
|
||||
self.term.scroll_display(Scroll::Delta(delta));
|
||||
|
||||
Reference in New Issue
Block a user