1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
//! Keyboard input routing: the global key handler and the Terminal-screen key
//! handler that decide which `AppAction` (if any) a keystroke produces.
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::*;
use crate::ssh::pty as ssh_pty;
use crate::ui;
impl App {
pub(crate) async fn handle_key(&mut self, key: KeyEvent) -> anyhow::Result<Option<AppAction>> {
// The update popup is modal — it captures all input until dismissed.
// Ctrl+C still quits as an escape hatch.
if self.view.update_popup.is_some() {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
return Ok(Some(AppAction::Quit));
}
self.handle_update_popup_key(key).await;
return Ok(None);
}
let screen = self.state.read().await.screen.clone();
// ----------------------------------------------------------------
// Terminal screen intercepts ALL keys — including Ctrl+C which must
// be forwarded to the PTY rather than quitting the application.
// F1/F2/F3 are the escape hatch back to other screens and are
// handled inside handle_terminal_key.
// ----------------------------------------------------------------
if matches!(screen, Screen::Terminal) {
return Ok(self.handle_terminal_key(key));
}
// Ctrl+C always quits regardless of any other state (non-Terminal screens).
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
return Ok(Some(AppAction::Quit));
}
// Tag popup takes full priority over everything except Ctrl+C.
if self.view.host_list.tag_popup_open && matches!(screen, Screen::Dashboard) {
return Ok(ui::dashboard::handle_tag_popup_input(key, &mut self.view));
}
// File manager popup takes full priority on the File Manager screen.
if self.view.file_manager.popup.is_some() && matches!(screen, Screen::FileManager) {
return Ok(ui::file_manager::handle_input(key, &mut self.view));
}
// Snippet overlay popups (Results, QuickExecuteInput) are visible on
// any screen and capture all input except Ctrl+C.
let snip_overlay_active = matches!(
self.view.snippets_view.popup,
Some(SnippetPopup::Results { .. }) | Some(SnippetPopup::QuickExecuteInput { .. })
);
if snip_overlay_active {
return Ok(ui::snippets::handle_input(key, &mut self.view));
}
// Snippet screen popups (Add/Edit/Delete/ParamInput/BroadcastPicker)
// or search mode on the snippets screen — delegate to snippets handler.
let snippet_popup_or_search =
self.view.snippets_view.popup.is_some() || self.view.snippets_view.search_mode;
if snippet_popup_or_search && matches!(screen, Screen::Snippets) {
return Ok(ui::snippets::handle_input(key, &mut self.view));
}
// When a host-list popup is open or the user is searching, the screen
// handler takes full priority (no global key interception except Ctrl+C).
let popup_or_search =
self.view.host_list.popup.is_some() || self.view.host_list.search_mode;
if popup_or_search {
return Ok(match screen {
Screen::Dashboard => ui::dashboard::handle_input(key, &mut self.view),
Screen::FileManager | Screen::Snippets | Screen::Terminal | Screen::DetailView => {
None
}
});
}
// ── Configurable global keys ───────────────────────────
// These are checked before the main match so user-defined bindings
// override the defaults without requiring changes to every branch.
{
let kb = &self.view.keybindings;
if key.code == kb.quit {
return Ok(Some(AppAction::Quit));
}
if key.code == kb.dashboard || key.code == KeyCode::Char('1') {
self.state.write().await.screen = Screen::Dashboard;
self.view.status_message = None;
return Ok(None);
}
if key.code == kb.file_manager || key.code == KeyCode::Char('2') {
self.state.write().await.screen = Screen::FileManager;
self.view.status_message = None;
self.bootstrap_file_manager().await;
return Ok(None);
}
if key.code == kb.snippets || key.code == KeyCode::Char('3') {
self.state.write().await.screen = Screen::Snippets;
self.view.status_message = None;
return Ok(None);
}
}
// Global key handling.
match key.code {
// `q` is handled above via keybindings; kept here as dead arm to
// avoid changing all the code below but effectively unreachable
// when the default keybinding is used.
KeyCode::Char('4') | KeyCode::F(4) => {
// On DetailView, '4' is used for Quick View (Docker), not switching screens
if matches!(screen, Screen::DetailView) {
// Delegate to DetailView handler for Quick View actions
return Ok(ui::detail_view::handle_input(key, &mut self.view));
} else {
// Switch to Terminal screen; open host picker if no tabs are open.
self.state.write().await.screen = Screen::Terminal;
self.view.status_message = None;
if self.view.terminal_view.tabs.is_empty() {
self.view.terminal_view.host_picker = Some(TermHostPicker::default());
}
}
}
_code if self.view.keybindings.next_screen.matches(key) => {
// On the File Manager screen this key switches between the two
// panels (local ↔ remote) rather than cycling to the next screen.
if matches!(screen, Screen::FileManager) {
return Ok(Some(AppAction::FmSwitchPanel));
}
let new_screen = {
let mut state = self.state.write().await;
state.screen = match state.screen {
Screen::Dashboard => Screen::FileManager,
Screen::DetailView => Screen::Dashboard, // Detail View → Dashboard
Screen::FileManager => Screen::Snippets,
Screen::Snippets => Screen::Terminal,
Screen::Terminal => Screen::Dashboard, // unreachable here (handled above)
};
state.screen.clone()
};
self.view.status_message = None;
if matches!(new_screen, Screen::FileManager) {
self.bootstrap_file_manager().await;
}
if matches!(new_screen, Screen::Terminal) && self.view.terminal_view.tabs.is_empty()
{
self.view.terminal_view.host_picker = Some(TermHostPicker::default());
}
}
KeyCode::Char('?') => {
self.view.show_help = !self.view.show_help;
// Reset scroll when opening help
if self.view.show_help {
self.view.help_scroll = 0;
}
}
KeyCode::Esc => {
// Close help popup if it's open
if self.view.show_help {
self.view.show_help = false;
self.view.help_scroll = 0;
return Ok(None);
}
// Clear status message if present
if self.view.status_message.is_some() {
self.view.status_message = None;
return Ok(None);
}
// Otherwise, delegate to screen handler (e.g., DetailView can return to Dashboard)
return Ok(match screen {
Screen::Dashboard => ui::dashboard::handle_input(key, &mut self.view),
Screen::DetailView => ui::detail_view::handle_input(key, &mut self.view),
Screen::Snippets => ui::snippets::handle_input(key, &mut self.view),
Screen::FileManager => ui::file_manager::handle_input(key, &mut self.view),
Screen::Terminal => None,
});
}
_ => {
// Delegate to the current screen's input handler.
return Ok(match screen {
Screen::Dashboard => ui::dashboard::handle_input(key, &mut self.view),
Screen::DetailView => ui::detail_view::handle_input(key, &mut self.view),
Screen::Snippets => ui::snippets::handle_input(key, &mut self.view),
Screen::FileManager => ui::file_manager::handle_input(key, &mut self.view),
// Terminal is handled at the very top of handle_key; unreachable here.
Screen::Terminal => None,
});
}
}
Ok(None)
}
/// Handles key events when the Terminal screen is active.
///
/// Returns an [`AppAction`] to pass to `process_action`, or forwards the
/// keystroke as raw bytes to the active PTY.
fn handle_terminal_key(&mut self, key: KeyEvent) -> Option<AppAction> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
// Host-picker popup has priority (Ctrl+T flow).
if self.view.terminal_view.host_picker.is_some() {
return ui::terminal::handle_host_picker_input(key, &mut self.view);
}
// F1/F2/F3 switch to other screens (escape hatch from Terminal).
// ── Screen switching ────────────────────────────────────────────────
// Ctrl+Q → Dashboard (works on macOS; Ctrl+letter always reliable)
// F1/F2/F3 → Dashboard/Files/Snippets (Linux; macOS captures F-keys)
match key.code {
KeyCode::Char('q') if ctrl => {
return Some(AppAction::SwitchScreen(Screen::Dashboard));
}
KeyCode::F(1) => return Some(AppAction::SwitchScreen(Screen::Dashboard)),
KeyCode::F(2) => return Some(AppAction::SwitchScreen(Screen::FileManager)),
KeyCode::F(3) => return Some(AppAction::SwitchScreen(Screen::Snippets)),
_ => {}
}
// ── Terminal control combos ──────────────────────────────────────────
// Ctrl+T → new tab host picker
// Ctrl+W → close active tab
// Ctrl+\ → toggle vertical split (byte 0x1C → Char('4')+CONTROL)
// Ctrl+] → toggle horizontal split (byte 0x1D → Char('5')+CONTROL)
// Note: Ctrl+\ and Ctrl+] are physically adjacent keys on US layout.
// Ctrl+- maps to 0x0D (Enter) with no CONTROL modifier — unusable.
// Ctrl+Right → next tab in the focused pane (wraps around)
// Ctrl+Left → prev tab in the focused pane (wraps around)
if ctrl {
match key.code {
KeyCode::Char('t') => return Some(AppAction::TermOpenHostPicker),
KeyCode::Char('w') => return Some(AppAction::TermCloseTab),
KeyCode::Char('h') => {
// Ctrl+H: Switch host in the focused pane (only in split mode)
if self.view.terminal_view.split.is_some() {
return Some(AppAction::TermSwitchPaneHost);
}
return None;
}
// Ctrl+\ sends byte 0x1C; crossterm decodes it as Char('4')+CONTROL
KeyCode::Char('4') => return Some(AppAction::TermSplitVertical),
// Ctrl+] sends byte 0x1D; crossterm decodes it as Char('5')+CONTROL
KeyCode::Char('5') => return Some(AppAction::TermSplitHorizontal),
KeyCode::Right => {
// Cycle within the focused pane (secondary or primary).
let tv = &mut self.view.terminal_view;
if tv.tabs.len() > 1 {
if let (Some(sv), SplitFocus::Secondary) =
(&mut tv.split, tv.split_focus.clone())
{
sv.secondary_tab = (sv.secondary_tab + 1) % tv.tabs.len();
return None; // already mutated
}
let next = (tv.active_tab + 1) % tv.tabs.len();
return Some(AppAction::TermSwitchTab(next));
}
return None;
}
KeyCode::Left => {
// Cycle within the focused pane (secondary or primary).
let tv = &mut self.view.terminal_view;
if tv.tabs.len() > 1 {
if let (Some(sv), SplitFocus::Secondary) =
(&mut tv.split, tv.split_focus.clone())
{
sv.secondary_tab = if sv.secondary_tab == 0 {
tv.tabs.len() - 1
} else {
sv.secondary_tab - 1
};
return None;
}
let prev = if tv.active_tab == 0 {
tv.tabs.len() - 1
} else {
tv.active_tab - 1
};
return Some(AppAction::TermSwitchTab(prev));
}
return None;
}
_ => {}
}
}
// ── Tab / next-tab keybinding ──────────────────────────────────────
// • In split mode → switch pane focus.
// • Otherwise → cycle to the next tab AND enter tab-select mode
// (a subsequent digit 1–9 jumps to that tab directly).
if self.view.keybindings.next_tab.matches(key) {
if self.view.terminal_view.split.is_some() {
return Some(AppAction::TermFocusNextPane);
}
// Cycle to next tab and enter select mode.
let tv = &mut self.view.terminal_view;
if tv.tabs.len() > 1 {
tv.active_tab = (tv.active_tab + 1) % tv.tabs.len();
// Mark the newly-active tab as seen.
tv.tabs[tv.active_tab].has_activity = false;
}
tv.tab_select_mode = true;
return None; // state already mutated; nothing to dispatch
}
// In tab-select mode a digit key 1–9 jumps to that tab.
if self.view.terminal_view.tab_select_mode {
if let KeyCode::Char(c @ '1'..='9') = key.code {
let n = (c as u8 - b'1') as usize; // 0-based
self.view.terminal_view.tab_select_mode = false;
if n < self.view.terminal_view.tabs.len() {
return Some(AppAction::TermSwitchTab(n));
}
return None;
}
// Any other key exits select mode and falls through to normal handling.
self.view.terminal_view.tab_select_mode = false;
}
// Forward everything else as raw bytes to the PTY.
let bytes = ssh_pty::key_to_bytes(key);
if bytes.is_empty() {
None
} else {
Some(AppAction::TermInput(bytes))
}
}
}