diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/client/shell/overlay_input.rs | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff) | |
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell
* feat: add client-owned shell mouse controls
* feat: complete client-owned shell chrome
* feat: add client-owned shell settings and notifications
* feat: run client shell custom commands
* feat: render popup terminals in client shell
* feat: add client-owned scrollback and copy mode
* fix: preserve client worktree trust defaults
* feat: render startup diagnostics in client shell
* feat: render onboarding in client shell
* feat: render product announcements in client shell
* feat: render release notes in client shell
* feat: render mobile switcher in client shell
* feat: render kitty graphics in client shell
* fix: preserve client shell lifecycle coherence
* refactor: route client shell commands through endpoint connections
* refactor: make the client-rendered shell the default
* refactor: remove monolithic launch mode
* refactor: complete client-rendered shell cutover
* perf: retain client pane surface updates
* perf: scale retained pane surface updates
* perf: remove remote pane input latency
* test: make client shell checks platform-independent
* fix: prevent client timer starvation
* test: fix client shell platform gates
* test: accept retained surfaces in cross-area checks
* test: make client mode assertions platform-neutral
* fix: badge only outdated integrations
Diffstat (limited to 'src/client/shell/overlay_input.rs')
| -rw-r--r-- | src/client/shell/overlay_input.rs | 1091 |
1 files changed, 1091 insertions, 0 deletions
diff --git a/src/client/shell/overlay_input.rs b/src/client/shell/overlay_input.rs new file mode 100644 index 00000000..154b00b6 --- /dev/null +++ b/src/client/shell/overlay_input.rs @@ -0,0 +1,1091 @@ +use super::*; + +impl ClientShellState { + pub(super) fn dismiss_product_announcement(&mut self, outcome: &mut ClientShellInput) { + let announcement = match self.overlay.take() { + Some(ClientShellOverlay::ProductAnnouncement(announcement)) => announcement, + other => { + self.overlay = other; + return; + } + }; + if self.snapshot.is_none() { + self.overlay = Some(ClientShellOverlay::ProductAnnouncement(announcement)); + return; + } + self.dismissed_product_announcement = + Some((announcement.version.clone(), announcement.id.clone())); + self.chrome_drag = None; + self.push_endpoint_method_with_kind( + crate::api::schema::Method::ProductAnnouncementDismiss( + crate::api::schema::ProductAnnouncementDismissParams { + version: announcement.version.clone(), + id: announcement.id.clone(), + }, + ), + PendingEndpointKind::ProductAnnouncementDismiss { + version: announcement.version, + id: announcement.id, + }, + outcome, + ); + outcome.repaint = true; + } + + pub(super) fn scroll_product_announcement(&mut self, delta: isize) { + let max_scroll = self.hits.product_announcement_max_scroll; + if let Some(ClientShellOverlay::ProductAnnouncement(announcement)) = self.overlay.as_mut() { + let current = usize::from(announcement.scroll); + let next = if delta.is_negative() { + current.saturating_sub(delta.unsigned_abs()) + } else { + current.saturating_add(delta as usize) + } + .min(max_scroll); + announcement.scroll = u16::try_from(next).unwrap_or(u16::MAX); + } + } + + pub(super) fn set_product_announcement_offset_from_bottom( + &mut self, + offset_from_bottom: usize, + ) { + let max_scroll = self.hits.product_announcement_max_scroll; + if let Some(ClientShellOverlay::ProductAnnouncement(announcement)) = self.overlay.as_mut() { + announcement.scroll = + u16::try_from(max_scroll.saturating_sub(offset_from_bottom.min(max_scroll))) + .unwrap_or(u16::MAX); + } + } + + pub(super) fn open_release_notes(&mut self) { + let Some(notes) = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.release_notes.as_ref()) + else { + return; + }; + self.overlay = Some(ClientShellOverlay::ReleaseNotes(release_notes_state(notes))); + self.chrome_drag = None; + } + + pub(super) fn dismiss_release_notes(&mut self, outcome: &mut ClientShellInput) { + let notes = match self.overlay.take() { + Some(ClientShellOverlay::ReleaseNotes(notes)) => notes, + other => { + self.overlay = other; + return; + } + }; + self.chrome_drag = None; + self.mode = if self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.as_deref()) + .is_some() + { + ClientShellMode::Terminal + } else { + ClientShellMode::Navigate + }; + self.push_endpoint_method_with_kind( + crate::api::schema::Method::ReleaseNotesDismiss( + crate::api::schema::ReleaseNotesDismissParams { + version: notes.version.clone(), + }, + ), + PendingEndpointKind::ReleaseNotesDismiss, + outcome, + ); + outcome.repaint = true; + } + + pub(super) fn current_release_notes_input_geometry( + &self, + ) -> Option<(Rect, Option<Rect>, crate::pane::ScrollMetrics)> { + let notes = match self.overlay.as_ref()? { + ClientShellOverlay::ReleaseNotes(notes) => notes, + _ => return None, + }; + let (cols, rows) = self.last_composed_size?; + let outer = crate::ui::centered_popup_rect( + Rect::new(0, 0, cols, rows), + crate::ui::RELEASE_NOTES_MODAL_SIZE.0, + crate::ui::RELEASE_NOTES_MODAL_SIZE.1, + )?; + let inner = Rect::new( + outer.x.saturating_add(1), + outer.y.saturating_add(1), + outer.width.saturating_sub(2), + outer.height.saturating_sub(2), + ); + if inner.height < 8 || inner.width < 20 { + return None; + } + let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1); + let close = crate::ui::release_notes_close_button_rect(Rect::new( + stack.header.x, + stack.header.y, + stack.header.width, + 1, + )); + let install_command = self + .snapshot + .as_deref() + .map(|snapshot| snapshot.update_install_command.as_str()) + .unwrap_or_default(); + let metrics = crate::ui::release_notes_scroll_metrics( + notes, + install_command, + stack.content, + &self.config.palette, + ); + let track = crate::ui::release_notes_scrollbar_rect(stack.content, metrics); + Some((close, track, metrics)) + } + + fn current_release_notes_max_scroll(&self) -> usize { + self.current_release_notes_input_geometry() + .map(|(_, _, metrics)| metrics.max_offset_from_bottom) + .unwrap_or(self.hits.release_notes_max_scroll) + } + + pub(super) fn scroll_release_notes(&mut self, delta: isize) { + let max_scroll = self.current_release_notes_max_scroll(); + if let Some(ClientShellOverlay::ReleaseNotes(notes)) = self.overlay.as_mut() { + let current = usize::from(notes.scroll); + let next = if delta.is_negative() { + current.saturating_sub(delta.unsigned_abs()) + } else { + current.saturating_add(delta as usize) + } + .min(max_scroll); + notes.scroll = u16::try_from(next).unwrap_or(u16::MAX); + } + } + + pub(super) fn set_release_notes_offset_from_bottom(&mut self, offset_from_bottom: usize) { + let max_scroll = self.current_release_notes_max_scroll(); + if let Some(ClientShellOverlay::ReleaseNotes(notes)) = self.overlay.as_mut() { + notes.scroll = + u16::try_from(max_scroll.saturating_sub(offset_from_bottom.min(max_scroll))) + .unwrap_or(u16::MAX); + } + } + + pub(super) fn complete_onboarding(&mut self, outcome: &mut ClientShellInput) { + if self.snapshot.is_none() { + return; + } + if let Err(error) = crate::config::update_file_at( + &self.config.local_config_path, + "onboarding setting", + |content| crate::config::upsert_top_level_bool(content, "onboarding", false), + ) { + self.set_local_config_diagnostic(Some(error)); + } + self.config.startup_onboarding = false; + self.open_settings_overlay(); + self.select_settings_section(ClientSettingsSection::Integrations, outcome); + } + + pub(super) fn open_navigator_overlay(&mut self) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let expanded_workspaces = snapshot + .workspaces + .iter() + .map(|workspace| workspace.workspace_id.clone()) + .collect(); + let mut navigator = ClientNavigatorOverlay { + query: String::new(), + search_focused: false, + selected: 0, + scroll: 0, + filter: None, + expanded_workspaces, + }; + let rows = render::client_navigator_rows(snapshot, &navigator); + navigator.selected = rows.iter().position(|row| row.current).unwrap_or(0); + self.overlay = Some(ClientShellOverlay::Navigator(navigator)); + } + + pub(super) fn move_navigator_selection(&mut self, delta: isize) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() else { + return; + }; + let rows = render::client_navigator_rows(snapshot, navigator); + if rows.is_empty() { + navigator.selected = 0; + return; + } + navigator.selected = (navigator.selected as isize + delta) + .clamp(0, rows.len().saturating_sub(1) as isize) as usize; + } + + pub(super) fn accept_navigator_selection(&mut self, outcome: &mut ClientShellInput) { + let target = { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_ref() else { + return; + }; + render::client_navigator_rows(snapshot, navigator) + .get(navigator.selected) + .map(|row| row.target.clone()) + }; + let Some(target) = target else { + return; + }; + self.overlay = None; + let method = match target { + ClientNavigatorTarget::Workspace(workspace_id) => { + crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id, + }) + } + ClientNavigatorTarget::Tab(tab_id) => { + crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { tab_id }) + } + ClientNavigatorTarget::Pane(pane_id) => { + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }) + } + }; + self.push_endpoint_method(method, outcome); + outcome.repaint = true; + } + + pub(super) fn toggle_selected_navigator_workspace(&mut self) { + let workspace_id = { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_ref() else { + return; + }; + render::client_navigator_rows(snapshot, navigator) + .get(navigator.selected) + .and_then(|row| match &row.target { + ClientNavigatorTarget::Workspace(workspace_id) => Some(workspace_id.clone()), + _ => None, + }) + }; + if let (Some(workspace_id), Some(ClientShellOverlay::Navigator(navigator))) = + (workspace_id, self.overlay.as_mut()) + { + if !navigator.expanded_workspaces.remove(&workspace_id) { + navigator.expanded_workspaces.insert(workspace_id); + } + navigator.selected = 0; + navigator.scroll = 0; + } + } + + pub(super) fn workspace_action_id(&self) -> Option<String> { + self.navigate_workspace_id.clone().or_else(|| { + self.snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.clone()) + }) + } + + pub(super) fn open_new_workspace_overlay(&mut self) { + let source_workspace_id = self.workspace_action_id(); + let cwd = self.snapshot.as_deref().and_then(|snapshot| { + let workspace_id = source_workspace_id.as_deref()?; + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + .map(|workspace| workspace.new_workspace_cwd.clone()) + }); + let suggested_name = cwd + .as_deref() + .map(std::path::Path::new) + .map(crate::workspace::derive_label_from_cwd) + .unwrap_or_else(|| "workspace".to_owned()); + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "new workspace", + input: suggested_name.clone(), + replace_on_type: true, + target: ClientRenameTarget::NewWorkspace { + source_workspace_id, + cwd, + suggested_name, + }, + })); + } + + pub(super) fn open_rename_workspace_overlay(&mut self) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(workspace_id) = self.workspace_action_id() else { + return; + }; + let Some(workspace) = snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + else { + return; + }; + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename workspace", + input: workspace.label.clone(), + replace_on_type: false, + target: ClientRenameTarget::Workspace { workspace_id }, + })); + } + + pub(super) fn open_new_tab_overlay(&mut self) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(workspace_id) = snapshot.focused_workspace_id.clone() else { + return; + }; + let default_name = (snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace_id) + .count() + + 1) + .to_string(); + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "new tab", + input: default_name.clone(), + replace_on_type: true, + target: ClientRenameTarget::NewTab { + workspace_id, + default_name, + }, + })); + } + + pub(super) fn open_rename_tab_overlay(&mut self) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(tab_id) = snapshot.focused_tab_id.as_deref() else { + return; + }; + let Some(tab) = snapshot.tabs.iter().find(|tab| tab.tab_id == tab_id) else { + return; + }; + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename tab", + input: tab.label.clone(), + replace_on_type: false, + target: ClientRenameTarget::Tab { + tab_id: tab.tab_id.clone(), + auto_name: !tab.custom_label, + original_name: tab.label.clone(), + }, + })); + } + + pub(super) fn open_rename_pane_overlay(&mut self) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(pane_id) = snapshot.focused_pane_id.as_deref() else { + return; + }; + let Some(pane) = snapshot.panes.iter().find(|pane| pane.pane_id == pane_id) else { + return; + }; + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename pane", + input: pane.label.clone().unwrap_or_default(), + replace_on_type: pane.label.is_none(), + target: ClientRenameTarget::Pane { + pane_id: pane.pane_id.clone(), + }, + })); + } + + pub(super) fn insert_overlay_text(&mut self, text: &str) -> bool { + if self.insert_worktree_overlay_text(text) { + return true; + } + match self.overlay.as_mut() { + Some(ClientShellOverlay::Rename(rename)) => { + if rename.replace_on_type { + rename.input.clear(); + rename.replace_on_type = false; + } + rename.input.push_str(text); + true + } + Some(ClientShellOverlay::Help(help)) if help.search_focused => { + help.query + .extend(text.chars().filter(|character| !character.is_control())); + help.scroll = 0; + true + } + Some(ClientShellOverlay::Navigator(navigator)) if navigator.search_focused => { + navigator.query.push_str(text); + navigator.filter = None; + navigator.selected = 0; + true + } + _ => false, + } + } + + pub(super) fn route_overlay_key( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) { + use crossterm::event::KeyModifiers; + + if matches!(self.overlay, Some(ClientShellOverlay::Onboarding)) { + if matches!( + key.code, + KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') + ) { + self.complete_onboarding(outcome); + } + return; + } + + if matches!( + self.overlay, + Some(ClientShellOverlay::ProductAnnouncement(_)) + ) { + match key.code { + KeyCode::Enter | KeyCode::Esc => self.dismiss_product_announcement(outcome), + KeyCode::Up | KeyCode::Char('k') => { + self.scroll_product_announcement(-1); + outcome.repaint = true; + } + KeyCode::Down | KeyCode::Char('j') => { + self.scroll_product_announcement(1); + outcome.repaint = true; + } + KeyCode::PageUp => { + self.scroll_product_announcement(-8); + outcome.repaint = true; + } + KeyCode::PageDown => { + self.scroll_product_announcement(8); + outcome.repaint = true; + } + KeyCode::Home => { + if let Some(ClientShellOverlay::ProductAnnouncement(announcement)) = + self.overlay.as_mut() + { + announcement.scroll = 0; + } + outcome.repaint = true; + } + KeyCode::End => { + if let Some(ClientShellOverlay::ProductAnnouncement(announcement)) = + self.overlay.as_mut() + { + announcement.scroll = + u16::try_from(self.hits.product_announcement_max_scroll) + .unwrap_or(u16::MAX); + } + outcome.repaint = true; + } + _ => {} + } + return; + } + + if matches!(self.overlay, Some(ClientShellOverlay::ReleaseNotes(_))) { + match key.code { + KeyCode::Enter | KeyCode::Esc => self.dismiss_release_notes(outcome), + KeyCode::Up | KeyCode::Char('k') => { + self.scroll_release_notes(-1); + outcome.repaint = true; + } + KeyCode::Down | KeyCode::Char('j') => { + self.scroll_release_notes(1); + outcome.repaint = true; + } + KeyCode::PageUp => { + self.scroll_release_notes(-8); + outcome.repaint = true; + } + KeyCode::PageDown => { + self.scroll_release_notes(8); + outcome.repaint = true; + } + KeyCode::Home => { + if let Some(ClientShellOverlay::ReleaseNotes(notes)) = self.overlay.as_mut() { + notes.scroll = 0; + } + outcome.repaint = true; + } + KeyCode::End => { + let max_scroll = self.current_release_notes_max_scroll(); + if let Some(ClientShellOverlay::ReleaseNotes(notes)) = self.overlay.as_mut() { + notes.scroll = u16::try_from(max_scroll).unwrap_or(u16::MAX); + } + outcome.repaint = true; + } + _ => {} + } + return; + } + + if matches!(self.overlay, Some(ClientShellOverlay::GlobalMenu(_))) { + match key.code { + KeyCode::Esc => { + self.overlay = None; + outcome.repaint = true; + } + KeyCode::Up | KeyCode::Char('k') => { + self.move_global_menu_selection(-1); + outcome.repaint = true; + } + KeyCode::Down | KeyCode::Char('j') => { + self.move_global_menu_selection(1); + outcome.repaint = true; + } + KeyCode::Enter => { + let highlighted = match self.overlay.as_ref() { + Some(ClientShellOverlay::GlobalMenu(menu)) => menu.highlighted, + _ => return, + }; + self.activate_global_menu_item(highlighted, outcome); + } + _ => {} + } + return; + } + + if self.route_settings_key(key, outcome) { + return; + } + + if matches!(self.overlay, Some(ClientShellOverlay::ContextMenu(_))) { + match key.code { + KeyCode::Esc => { + self.overlay = None; + outcome.repaint = true; + } + KeyCode::Up => { + self.move_context_menu_selection(-1); + outcome.repaint = true; + } + KeyCode::Down => { + self.move_context_menu_selection(1); + outcome.repaint = true; + } + KeyCode::Enter => { + let highlighted = match self.overlay.as_ref() { + Some(ClientShellOverlay::ContextMenu(menu)) => menu.highlighted, + _ => return, + }; + self.activate_context_menu_item(highlighted, outcome); + } + _ => {} + } + return; + } + + if self.route_worktree_overlay_key(key, outcome) { + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::Navigator(_))) { + let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); + let search_focused = matches!( + self.overlay, + Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay { + search_focused: true, + .. + })) + ); + if code == KeyCode::Esc { + if search_focused { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.search_focused = false; + } + } else { + self.overlay = None; + } + outcome.repaint = true; + return; + } + if code == KeyCode::Enter { + self.accept_navigator_selection(outcome); + return; + } + if search_focused { + if code == KeyCode::Up + || code == KeyCode::Char('p') && modifiers.contains(KeyModifiers::CONTROL) + { + self.move_navigator_selection(-1); + outcome.repaint = true; + return; + } + if code == KeyCode::Down + || code == KeyCode::Char('n') && modifiers.contains(KeyModifiers::CONTROL) + { + self.move_navigator_selection(1); + outcome.repaint = true; + return; + } + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + if code == KeyCode::Char('u') && modifiers.contains(KeyModifiers::CONTROL) { + navigator.query.clear(); + navigator.filter = None; + navigator.selected = 0; + } else if code == KeyCode::Backspace { + navigator.query.pop(); + navigator.filter = None; + navigator.selected = 0; + } else if let KeyCode::Char(character) = code { + if modifiers.difference(KeyModifiers::SHIFT).is_empty() { + navigator.filter = None; + if let Some(text) = key.generated_text.as_deref() { + navigator.query.push_str(text); + } else { + navigator.query.push(character); + } + navigator.selected = 0; + } + } + outcome.repaint = true; + } + return; + } + if code == KeyCode::Backspace && modifiers.is_empty() { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + if navigator.filter.take().is_some() { + navigator.selected = 0; + } + } + outcome.repaint = true; + return; + } + if code == KeyCode::Home && modifiers.is_empty() { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.selected = 0; + navigator.scroll = 0; + } + outcome.repaint = true; + return; + } + if matches!(code, KeyCode::End | KeyCode::Char('G')) && modifiers.is_empty() { + let last = self + .snapshot + .as_deref() + .zip(self.overlay.as_ref()) + .and_then(|(snapshot, overlay)| match overlay { + ClientShellOverlay::Navigator(navigator) => { + Some(render::client_navigator_rows(snapshot, navigator).len()) + } + _ => None, + }) + .unwrap_or(0) + .saturating_sub(1); + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.selected = last; + } + outcome.repaint = true; + return; + } + if code == KeyCode::Char('/') && modifiers.is_empty() { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.search_focused = true; + navigator.filter = None; + } + outcome.repaint = true; + return; + } + if matches!(code, KeyCode::Down | KeyCode::Char('j')) && modifiers.is_empty() { + self.move_navigator_selection(1); + outcome.repaint = true; + return; + } + if matches!(code, KeyCode::Up | KeyCode::Char('k')) && modifiers.is_empty() { + self.move_navigator_selection(-1); + outcome.repaint = true; + return; + } + if code == KeyCode::Char('d') && modifiers.contains(KeyModifiers::CONTROL) { + self.move_navigator_selection(8); + outcome.repaint = true; + return; + } + if code == KeyCode::Char('u') && modifiers.contains(KeyModifiers::CONTROL) { + self.move_navigator_selection(-8); + outcome.repaint = true; + return; + } + if let Some(filter) = match code { + KeyCode::Char('b') if modifiers.is_empty() => Some(ClientNavigatorFilter::Blocked), + KeyCode::Char('w') if modifiers.is_empty() => Some(ClientNavigatorFilter::Working), + KeyCode::Char('i') if modifiers.is_empty() => Some(ClientNavigatorFilter::Idle), + KeyCode::Char('d') if modifiers.is_empty() => Some(ClientNavigatorFilter::Done), + _ => None, + } { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.query.clear(); + navigator.filter = Some(filter); + navigator.selected = 0; + } + outcome.repaint = true; + return; + } + if code == KeyCode::Char('a') && modifiers.is_empty() { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.query.clear(); + navigator.filter = None; + navigator.selected = 0; + } + outcome.repaint = true; + return; + } + if code == KeyCode::Char(' ') && modifiers.is_empty() { + self.toggle_selected_navigator_workspace(); + outcome.repaint = true; + return; + } + return; + } + + if matches!(self.overlay, Some(ClientShellOverlay::Help(_))) { + let text_character = crate::input::keybind_help_text_char(key); + let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); + let search_focused = matches!( + self.overlay, + Some(ClientShellOverlay::Help(ClientHelpOverlay { + search_focused: true, + .. + })) + ); + if search_focused { + match code { + KeyCode::Esc => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.search_focused = false; + help.query.clear(); + help.scroll = 0; + } + } + KeyCode::Enter => self.overlay = None, + KeyCode::Home => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = 0; + } + } + KeyCode::End => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = self.hits.help_max_scroll; + } + } + KeyCode::Up | KeyCode::Down | KeyCode::PageUp | KeyCode::PageDown => { + let delta = match code { + KeyCode::Up => -1, + KeyCode::Down => 1, + KeyCode::PageUp => -8, + KeyCode::PageDown => 8, + _ => unreachable!(), + }; + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = help + .scroll + .saturating_add_signed(delta) + .min(self.hits.help_max_scroll); + } + } + KeyCode::Backspace => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.query.pop(); + help.scroll = 0; + } + } + KeyCode::Char('u') if modifiers == KeyModifiers::CONTROL => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.query.clear(); + help.scroll = 0; + } + } + _ => { + if let Some(character) = text_character { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.query.push(character); + help.scroll = 0; + } + } + } + } + outcome.repaint = true; + return; + } + + match code { + KeyCode::Esc | KeyCode::Enter => self.overlay = None, + KeyCode::Home => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = 0; + } + } + KeyCode::End => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = self.hits.help_max_scroll; + } + } + KeyCode::Up + | KeyCode::Char('k') + | KeyCode::Down + | KeyCode::Char('j') + | KeyCode::PageUp + | KeyCode::PageDown => { + let delta = match code { + KeyCode::Up | KeyCode::Char('k') => -1, + KeyCode::Down | KeyCode::Char('j') => 1, + KeyCode::PageUp => -8, + KeyCode::PageDown => 8, + _ => unreachable!(), + }; + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = help + .scroll + .saturating_add_signed(delta) + .min(self.hits.help_max_scroll); + } + } + _ if text_character == Some('/') => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.search_focused = true; + help.scroll = 0; + } + } + _ if text_character == Some('?') => self.overlay = None, + _ => {} + } + outcome.repaint = true; + return; + } + + if matches!(self.overlay, Some(ClientShellOverlay::ConfirmClose(_))) { + if key.code == KeyCode::Enter { + let Some(ClientShellOverlay::ConfirmClose(confirm)) = self.overlay.take() else { + return; + }; + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceClose( + crate::api::schema::WorkspaceCloseParams { + workspace_id: confirm.workspace_id, + close_group: true, + }, + ), + outcome, + ); + outcome.repaint = true; + } else if key.code == KeyCode::Esc { + self.overlay = None; + self.mode = ClientShellMode::Navigate; + self.navigate_workspace_id = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.clone()); + outcome.repaint = true; + } + return; + } + + let Some(ClientShellOverlay::Rename(rename)) = self.overlay.as_mut() else { + return; + }; + if key.code == KeyCode::Enter { + self.save_rename_overlay(outcome); + return; + } + if key.code == KeyCode::Esc { + self.overlay = None; + outcome.repaint = true; + return; + } + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + rename.input.clear(); + rename.replace_on_type = false; + outcome.repaint = true; + return; + } + if (key.code == KeyCode::Char('u') && key.modifiers.contains(KeyModifiers::CONTROL)) + || (key.code == KeyCode::Backspace && key.modifiers.contains(KeyModifiers::SUPER)) + { + rename.input.clear(); + rename.replace_on_type = false; + outcome.repaint = true; + return; + } + if key.code == KeyCode::Backspace + && (key.modifiers.contains(KeyModifiers::CONTROL) + || key.modifiers.contains(KeyModifiers::ALT)) + || matches!(key.code, KeyCode::Char('h' | 'w')) + && key.modifiers.contains(KeyModifiers::CONTROL) + { + delete_overlay_word(rename); + outcome.repaint = true; + return; + } + if key.code == KeyCode::Backspace { + if rename.replace_on_type { + rename.input.clear(); + rename.replace_on_type = false; + } else { + rename.input.pop(); + } + outcome.repaint = true; + return; + } + if let KeyCode::Char(character) = key.code { + if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() { + if rename.replace_on_type { + rename.input.clear(); + rename.replace_on_type = false; + } + if let Some(text) = key.generated_text.as_deref() { + rename.input.push_str(text); + } else { + rename.input.push(character); + } + outcome.repaint = true; + } + } + } + + pub(super) fn save_rename_overlay(&mut self, outcome: &mut ClientShellInput) { + let Some(ClientShellOverlay::Rename(rename)) = self.overlay.take() else { + return; + }; + let trimmed = rename.input.trim(); + let method = match rename.target { + ClientRenameTarget::NewWorkspace { + source_workspace_id, + cwd, + suggested_name, + } => Some(crate::api::schema::Method::WorkspaceCreate( + crate::api::schema::WorkspaceCreateParams { + source_workspace_id, + cwd, + focus: true, + label: (!trimmed.is_empty() && trimmed != suggested_name) + .then(|| trimmed.to_owned()), + env: Default::default(), + }, + )), + ClientRenameTarget::Workspace { workspace_id } => (!trimmed.is_empty()).then(|| { + crate::api::schema::Method::WorkspaceRename( + crate::api::schema::WorkspaceRenameParams { + workspace_id, + label: trimmed.to_owned(), + }, + ) + }), + ClientRenameTarget::NewTab { + workspace_id, + default_name, + } => Some(crate::api::schema::Method::TabCreate( + crate::api::schema::TabCreateParams { + workspace_id: Some(workspace_id), + cwd: None, + focus: true, + label: (!trimmed.is_empty() && trimmed != default_name) + .then(|| trimmed.to_owned()), + env: Default::default(), + }, + )), + ClientRenameTarget::Tab { + tab_id, + auto_name, + original_name, + } => (!(trimmed.is_empty() || auto_name && trimmed == original_name)).then(|| { + crate::api::schema::Method::TabRename(crate::api::schema::TabRenameParams { + tab_id, + label: trimmed.to_owned(), + }) + }), + ClientRenameTarget::Pane { pane_id } => Some(crate::api::schema::Method::PaneRename( + crate::api::schema::PaneRenameParams { + pane_id, + label: Some(trimmed.to_owned()), + }, + )), + }; + if let Some(method) = method { + self.push_endpoint_method(method, outcome); + } + outcome.repaint = true; + } + + pub(super) fn open_confirm_close_overlay(&mut self, workspace_id: String) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(workspace) = snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + else { + return; + }; + let group_key = workspace + .worktree + .as_ref() + .filter(|worktree| !worktree.is_linked_worktree) + .map(|worktree| worktree.key.as_str()); + let group = group_key + .map(|key| { + snapshot + .workspaces + .iter() + .filter(|member| { + member + .worktree + .as_ref() + .is_some_and(|worktree| worktree.key == key) + }) + .collect::<Vec<_>>() + }) + .unwrap_or_else(|| vec![workspace]); + let closes_group = group.len() > 1; + let pane_count = group + .iter() + .map(|member| { + snapshot + .panes + .iter() + .filter(|pane| pane.workspace_id == member.workspace_id) + .count() + }) + .sum::<usize>(); + let panes = if pane_count == 1 { + "1 pane".to_owned() + } else { + format!("{pane_count} panes") + }; + let scope = if closes_group { + format!("{} workspaces, {panes}", group.len()) + } else { + panes + }; + self.overlay = Some(ClientShellOverlay::ConfirmClose( + ClientConfirmCloseOverlay { + workspace_id, + title: if closes_group { + "Close worktree group?".to_owned() + } else { + "Close workspace?".to_owned() + }, + detail: format!("{} — {scope}", workspace.label), + }, + )); + } +} |
