diff --git a/assets/themes/phygerr.json b/assets/themes/phygerr.json new file mode 100644 index 0000000..986a2b6 --- /dev/null +++ b/assets/themes/phygerr.json @@ -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" + } + } + } + } + ] +} diff --git a/locales/en.yml b/locales/en.yml index 3d144c6..156b1cd 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -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" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 06a1a79..d2ec0c8 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -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: "向左切换焦点" diff --git a/src/app/keybinding_recorder.rs b/src/app/keybinding_recorder.rs index c58cfe5..dcd6284 100644 --- a/src/app/keybinding_recorder.rs +++ b/src/app/keybinding_recorder.rs @@ -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", diff --git a/src/app/mod.rs b/src/app/mod.rs index 7860ba0..bee1b22 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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, + 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, + pub(crate) search_bar_bounds: Option>, + pub(crate) system_tab_id: Option, pub(crate) sftp_handles: std::collections::HashMap, @@ -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(); } diff --git a/src/app/search.rs b/src/app/search.rs new file mode 100644 index 0000000..70784ce --- /dev/null +++ b/src/app/search.rs @@ -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) { + 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.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.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) { + 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) { + 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 = 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) { + 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) { + 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) { + // 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> { + 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, + ) -> 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, + ) -> 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 +} diff --git a/src/app/theme.rs b/src/app/theme.rs index 651c562..d1147c0 100644 --- a/src/app/theme.rs +++ b/src/app/theme.rs @@ -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<()> { diff --git a/src/app/ui.rs b/src/app/ui.rs index c0b935e..938c45f 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -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) -> impl IntoElement { + fn render_terminal_panel(&mut self, window: &mut Window, cx: &mut Context) -> 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); diff --git a/src/main.rs b/src/main.rs index a2a4cee..9c4e91e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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::{ diff --git a/src/session/mod.rs b/src/session/mod.rs index 43b87c4..f249a39 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -805,6 +805,15 @@ impl Ashell { window: &mut Window, cx: &mut Context, ) { + // 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; + } } } } diff --git a/src/terminal/custom_blocks.rs b/src/terminal/custom_blocks.rs index 1668c97..141764a 100644 --- a/src/terminal/custom_blocks.rs +++ b/src/terminal/custom_blocks.rs @@ -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![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 = 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 { + 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 { + 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::().is_ok()); + if valid { + positions.push(i); + } + } + } + } + positions +} + +/// Find byte offsets of URLs starting with http:// or https:// +fn find_urls(text: &str) -> Vec { + 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 { + 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::() { + 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 +} diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 3c2b5b7..fb0eada 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -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>, } pub struct PrepaintState { @@ -270,6 +271,7 @@ impl TerminalElement { font_size: Pixels, line_height: Pixels, cell_width: Pixels, + search_highlights: Option>, ) -> 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 = 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); diff --git a/src/terminal/input.rs b/src/terminal/input.rs index b3ce385..e71ccc8 100644 --- a/src/terminal/input.rs +++ b/src/terminal/input.rs @@ -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, ) { + // 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 diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 941156e..80ceac1 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -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>) { + 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::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));