1use serde::Deserialize;
2use std::collections::{BTreeMap, BTreeSet};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
5pub enum Category {
6 Core,
7 Workspaces,
8 Tabs,
9 Panes,
10 Agents,
11 Custom,
12 Discovered,
16}
17
18impl Category {
19 pub const ALL: [Category; 7] = [
20 Category::Core,
21 Category::Workspaces,
22 Category::Tabs,
23 Category::Panes,
24 Category::Agents,
25 Category::Custom,
26 Category::Discovered,
27 ];
28
29 pub const fn title(self) -> &'static str {
30 match self {
31 Category::Core => "Core",
32 Category::Workspaces => "Workspaces",
33 Category::Tabs => "Tabs",
34 Category::Panes => "Panes",
35 Category::Agents => "Agents",
36 Category::Custom => "Custom",
37 Category::Discovered => "Discovered",
38 }
39 }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum BindingSource {
44 Default,
45 Custom,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum BindingStatus {
50 Active,
51 Disabled,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Binding {
56 pub action: String,
57 pub label: String,
58 pub keys: Vec<String>,
59 pub default_keys: Vec<String>,
60 pub category: Category,
61 pub tree_path: Vec<String>,
62 pub source: BindingSource,
63 pub status: BindingStatus,
64 pub hint: String,
65}
66
67#[derive(Debug, Clone, Deserialize, Default)]
68pub struct KeysSection {
69 pub prefix: Option<String>,
70 pub help: Option<KeyValue>,
71 pub settings: Option<KeyValue>,
72 pub detach: Option<KeyValue>,
73 pub reload_config: Option<KeyValue>,
74 pub open_notification_target: Option<KeyValue>,
75 pub remote_image_paste: Option<KeyValue>,
76 pub workspace_picker: Option<KeyValue>,
77 pub goto: Option<KeyValue>,
78 pub new_workspace: Option<KeyValue>,
79 pub new_worktree: Option<KeyValue>,
80 pub open_worktree: Option<KeyValue>,
81 pub remove_worktree: Option<KeyValue>,
82 pub rename_workspace: Option<KeyValue>,
83 pub close_workspace: Option<KeyValue>,
84 pub previous_workspace: Option<KeyValue>,
85 pub next_workspace: Option<KeyValue>,
86 pub previous_agent: Option<KeyValue>,
87 pub next_agent: Option<KeyValue>,
88 pub focus_agent: Option<KeyValue>,
89 pub new_tab: Option<KeyValue>,
90 pub rename_tab: Option<KeyValue>,
91 pub previous_tab: Option<KeyValue>,
92 pub next_tab: Option<KeyValue>,
93 pub switch_tab: Option<KeyValue>,
94 pub switch_workspace: Option<KeyValue>,
95 pub close_tab: Option<KeyValue>,
96 pub rename_pane: Option<KeyValue>,
97 pub edit_scrollback: Option<KeyValue>,
98 pub focus_pane_left: Option<KeyValue>,
99 pub focus_pane_down: Option<KeyValue>,
100 pub focus_pane_up: Option<KeyValue>,
101 pub focus_pane_right: Option<KeyValue>,
102 pub cycle_pane_next: Option<KeyValue>,
103 pub cycle_pane_previous: Option<KeyValue>,
104 pub last_pane: Option<KeyValue>,
105 pub split_vertical: Option<KeyValue>,
106 pub split_horizontal: Option<KeyValue>,
107 pub close_pane: Option<KeyValue>,
108 pub zoom: Option<KeyValue>,
109 pub fullscreen: Option<KeyValue>,
110 pub resize_mode: Option<KeyValue>,
111 pub toggle_sidebar: Option<KeyValue>,
112 pub navigate_workspace_up: Option<KeyValue>,
113 pub navigate_workspace_down: Option<KeyValue>,
114 pub navigate_pane_left: Option<KeyValue>,
115 pub navigate_pane_down: Option<KeyValue>,
116 pub navigate_pane_up: Option<KeyValue>,
117 pub navigate_pane_right: Option<KeyValue>,
118 #[serde(default)]
119 pub command: Vec<CommandBinding>,
120}
121
122#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
123#[serde(untagged)]
124pub enum KeyValue {
125 One(String),
126 Many(Vec<String>),
127}
128
129impl KeyValue {
130 pub fn keys(&self) -> Vec<String> {
131 match self {
132 KeyValue::One(value) => vec![value.clone()],
133 KeyValue::Many(values) => values.clone(),
134 }
135 }
136}
137
138#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
139pub struct CommandBinding {
140 pub name: Option<String>,
141 pub description: Option<String>,
142 pub key: Option<KeyValue>,
143 #[serde(default)]
144 pub r#type: Option<String>,
145 pub command: Option<String>,
146}
147
148#[derive(Debug, Clone)]
149struct BindingSpec {
150 action: &'static str,
151 label: &'static str,
152 default_keys: &'static [&'static str],
153 category: Category,
154 hint: &'static str,
155}
156
157const SPECS: &[BindingSpec] = &[
158 BindingSpec {
159 action: "help",
160 label: "Help",
161 default_keys: &["prefix+?"],
162 category: Category::Core,
163 hint: "Open Herdr's canonical key map.",
164 },
165 BindingSpec {
166 action: "settings",
167 label: "Settings",
168 default_keys: &["prefix+s"],
169 category: Category::Core,
170 hint: "Open in-app settings.",
171 },
172 BindingSpec {
173 action: "detach",
174 label: "Detach",
175 default_keys: &["prefix+q"],
176 category: Category::Core,
177 hint: "Leave Herdr; panes keep running.",
178 },
179 BindingSpec {
180 action: "reload_config",
181 label: "Reload config",
182 default_keys: &["prefix+shift+r"],
183 category: Category::Core,
184 hint: "Apply config changes without restarting.",
185 },
186 BindingSpec {
187 action: "open_notification_target",
188 label: "Open notification",
189 default_keys: &["prefix+o"],
190 category: Category::Core,
191 hint: "Jump to what needs attention.",
192 },
193 BindingSpec {
194 action: "remote_image_paste",
195 label: "Remote image paste",
196 default_keys: &["ctrl+v"],
197 category: Category::Core,
198 hint: "Paste images while attached to a remote Herdr session.",
199 },
200 BindingSpec {
201 action: "workspace_picker",
202 label: "Workspace picker",
203 default_keys: &["prefix+w"],
204 category: Category::Workspaces,
205 hint: "Choose a workspace from the native picker.",
206 },
207 BindingSpec {
208 action: "goto",
209 label: "Goto",
210 default_keys: &["prefix+g"],
211 category: Category::Workspaces,
212 hint: "Fuzzy jump across workspaces, tabs, and agents.",
213 },
214 BindingSpec {
215 action: "new_workspace",
216 label: "New workspace",
217 default_keys: &["prefix+shift+n"],
218 category: Category::Workspaces,
219 hint: "Create a fresh workspace.",
220 },
221 BindingSpec {
222 action: "new_worktree",
223 label: "New worktree",
224 default_keys: &["prefix+shift+g"],
225 category: Category::Workspaces,
226 hint: "Create a Git worktree workspace.",
227 },
228 BindingSpec {
229 action: "open_worktree",
230 label: "Open worktree",
231 default_keys: &[],
232 category: Category::Workspaces,
233 hint: "Open an existing worktree.",
234 },
235 BindingSpec {
236 action: "remove_worktree",
237 label: "Remove worktree",
238 default_keys: &[],
239 category: Category::Workspaces,
240 hint: "Remove a worktree after confirmation.",
241 },
242 BindingSpec {
243 action: "rename_workspace",
244 label: "Rename workspace",
245 default_keys: &["prefix+shift+w"],
246 category: Category::Workspaces,
247 hint: "Name the current workspace.",
248 },
249 BindingSpec {
250 action: "close_workspace",
251 label: "Close workspace",
252 default_keys: &["prefix+shift+d"],
253 category: Category::Workspaces,
254 hint: "Close the whole workspace.",
255 },
256 BindingSpec {
257 action: "previous_workspace",
258 label: "Previous workspace",
259 default_keys: &[],
260 category: Category::Workspaces,
261 hint: "Direct previous workspace shortcut.",
262 },
263 BindingSpec {
264 action: "next_workspace",
265 label: "Next workspace",
266 default_keys: &[],
267 category: Category::Workspaces,
268 hint: "Direct next workspace shortcut.",
269 },
270 BindingSpec {
271 action: "new_tab",
272 label: "New tab",
273 default_keys: &["prefix+c"],
274 category: Category::Tabs,
275 hint: "Create a tab in this workspace.",
276 },
277 BindingSpec {
278 action: "rename_tab",
279 label: "Rename tab",
280 default_keys: &["prefix+shift+t"],
281 category: Category::Tabs,
282 hint: "Give the focused tab a useful name.",
283 },
284 BindingSpec {
285 action: "previous_tab",
286 label: "Previous tab",
287 default_keys: &["prefix+p"],
288 category: Category::Tabs,
289 hint: "Move one tab left/back.",
290 },
291 BindingSpec {
292 action: "next_tab",
293 label: "Next tab",
294 default_keys: &["prefix+n"],
295 category: Category::Tabs,
296 hint: "Move one tab right/forward.",
297 },
298 BindingSpec {
299 action: "switch_tab",
300 label: "Switch tab",
301 default_keys: &["prefix+1..9"],
302 category: Category::Tabs,
303 hint: "Jump to tab by index.",
304 },
305 BindingSpec {
306 action: "switch_workspace",
307 label: "Switch workspace",
308 default_keys: &[],
309 category: Category::Workspaces,
310 hint: "Optional indexed workspace jump binding.",
311 },
312 BindingSpec {
313 action: "close_tab",
314 label: "Close tab",
315 default_keys: &["prefix+shift+x"],
316 category: Category::Tabs,
317 hint: "Close the active tab.",
318 },
319 BindingSpec {
320 action: "rename_pane",
321 label: "Rename pane",
322 default_keys: &["prefix+shift+p"],
323 category: Category::Panes,
324 hint: "Name the focused pane.",
325 },
326 BindingSpec {
327 action: "edit_scrollback",
328 label: "Edit scrollback",
329 default_keys: &["prefix+e"],
330 category: Category::Panes,
331 hint: "Open scrollback for review/copy.",
332 },
333 BindingSpec {
334 action: "focus_pane_left",
335 label: "Focus left",
336 default_keys: &["prefix+h"],
337 category: Category::Panes,
338 hint: "Focus pane to the left.",
339 },
340 BindingSpec {
341 action: "focus_pane_down",
342 label: "Focus down",
343 default_keys: &["prefix+j"],
344 category: Category::Panes,
345 hint: "Focus pane below.",
346 },
347 BindingSpec {
348 action: "focus_pane_up",
349 label: "Focus up",
350 default_keys: &["prefix+k"],
351 category: Category::Panes,
352 hint: "Focus pane above.",
353 },
354 BindingSpec {
355 action: "focus_pane_right",
356 label: "Focus right",
357 default_keys: &["prefix+l"],
358 category: Category::Panes,
359 hint: "Focus pane to the right.",
360 },
361 BindingSpec {
362 action: "cycle_pane_next",
363 label: "Next pane",
364 default_keys: &["prefix+tab"],
365 category: Category::Panes,
366 hint: "Cycle through panes.",
367 },
368 BindingSpec {
369 action: "cycle_pane_previous",
370 label: "Previous pane",
371 default_keys: &["prefix+shift+tab"],
372 category: Category::Panes,
373 hint: "Cycle backward through panes.",
374 },
375 BindingSpec {
376 action: "last_pane",
377 label: "Last pane",
378 default_keys: &[],
379 category: Category::Panes,
380 hint: "Jump back to the last pane.",
381 },
382 BindingSpec {
383 action: "split_vertical",
384 label: "Split vertical",
385 default_keys: &["prefix+v"],
386 category: Category::Panes,
387 hint: "Split side by side.",
388 },
389 BindingSpec {
390 action: "split_horizontal",
391 label: "Split horizontal",
392 default_keys: &["prefix+minus"],
393 category: Category::Panes,
394 hint: "Split top/bottom.",
395 },
396 BindingSpec {
397 action: "close_pane",
398 label: "Close pane",
399 default_keys: &["prefix+x"],
400 category: Category::Panes,
401 hint: "Close only the focused pane.",
402 },
403 BindingSpec {
404 action: "zoom",
405 label: "Zoom",
406 default_keys: &["prefix+z"],
407 category: Category::Panes,
408 hint: "Toggle focused pane fullscreen.",
409 },
410 BindingSpec {
411 action: "resize_mode",
412 label: "Resize mode",
413 default_keys: &["prefix+r"],
414 category: Category::Panes,
415 hint: "Enter pane resize mode.",
416 },
417 BindingSpec {
418 action: "toggle_sidebar",
419 label: "Toggle sidebar",
420 default_keys: &["prefix+b"],
421 category: Category::Panes,
422 hint: "Show/hide Herdr's sidebar.",
423 },
424 BindingSpec {
425 action: "navigate_workspace_up",
426 label: "Navigate workspace up",
427 default_keys: &["up"],
428 category: Category::Workspaces,
429 hint: "Move up while Herdr navigate mode is open.",
430 },
431 BindingSpec {
432 action: "navigate_workspace_down",
433 label: "Navigate workspace down",
434 default_keys: &["down"],
435 category: Category::Workspaces,
436 hint: "Move down while Herdr navigate mode is open.",
437 },
438 BindingSpec {
439 action: "navigate_pane_left",
440 label: "Navigate left",
441 default_keys: &["h"],
442 category: Category::Panes,
443 hint: "Move left while Herdr navigate mode is open.",
444 },
445 BindingSpec {
446 action: "navigate_pane_down",
447 label: "Navigate down",
448 default_keys: &["j"],
449 category: Category::Panes,
450 hint: "Move down while Herdr navigate mode is open.",
451 },
452 BindingSpec {
453 action: "navigate_pane_up",
454 label: "Navigate up",
455 default_keys: &["k"],
456 category: Category::Panes,
457 hint: "Move up while Herdr navigate mode is open.",
458 },
459 BindingSpec {
460 action: "navigate_pane_right",
461 label: "Navigate right",
462 default_keys: &["l"],
463 category: Category::Panes,
464 hint: "Move right while Herdr navigate mode is open.",
465 },
466 BindingSpec {
467 action: "previous_agent",
468 label: "Previous agent",
469 default_keys: &[],
470 category: Category::Agents,
471 hint: "Focus previous visible agent.",
472 },
473 BindingSpec {
474 action: "next_agent",
475 label: "Next agent",
476 default_keys: &[],
477 category: Category::Agents,
478 hint: "Focus next visible agent.",
479 },
480 BindingSpec {
481 action: "focus_agent",
482 label: "Focus agent",
483 default_keys: &[],
484 category: Category::Agents,
485 hint: "Indexed agent focus binding.",
486 },
487];
488
489pub fn effective_bindings(keys: &KeysSection) -> Vec<Binding> {
490 effective_bindings_with_discovery(keys, None)
491}
492
493pub fn modeled_actions() -> BTreeSet<&'static str> {
497 SPECS.iter().map(|spec| spec.action).collect()
498}
499
500pub fn effective_bindings_with_discovery(
505 keys: &KeysSection,
506 discovered: Option<&BTreeMap<String, Vec<String>>>,
507) -> Vec<Binding> {
508 let mut out = Vec::new();
509 for spec in SPECS {
510 let configured = configured_value(keys, spec.action);
511 let default_keys = strings(spec.default_keys);
512 let keys = configured.clone().unwrap_or_else(|| default_keys.clone());
513 let status = if keys.iter().all(|key| key.trim().is_empty()) {
514 BindingStatus::Disabled
515 } else {
516 BindingStatus::Active
517 };
518 let source = if configured.is_some() {
519 BindingSource::Custom
520 } else {
521 BindingSource::Default
522 };
523 out.push(Binding {
524 action: spec.action.to_string(),
525 label: spec.label.to_string(),
526 keys: keys
527 .into_iter()
528 .filter(|key| !key.trim().is_empty())
529 .collect(),
530 default_keys,
531 category: spec.category,
532 tree_path: tree_path_for_action(spec.action, spec.category),
533 source,
534 status,
535 hint: spec.hint.to_string(),
536 });
537 }
538
539 for (index, command) in keys.command.iter().enumerate() {
540 let keys = command.key.as_ref().map(KeyValue::keys).unwrap_or_default();
541 let label = command
542 .name
543 .clone()
544 .or_else(|| command.description.clone())
545 .or_else(|| command.command.clone())
546 .unwrap_or_else(|| format!("Custom command {}", index + 1));
547 let status = if keys.iter().all(|key| key.trim().is_empty()) {
548 BindingStatus::Disabled
549 } else {
550 BindingStatus::Active
551 };
552 out.push(Binding {
553 action: format!("command[{index}]"),
554 label,
555 keys: keys
556 .into_iter()
557 .filter(|key| !key.trim().is_empty())
558 .collect(),
559 default_keys: Vec::new(),
560 category: Category::Custom,
561 tree_path: vec!["Custom".to_string(), "Commands".to_string()],
562 source: BindingSource::Custom,
563 status,
564 hint: command
565 .command
566 .clone()
567 .unwrap_or_else(|| "Custom Herdr command".to_string()),
568 });
569 }
570
571 if let Some(discovered) = discovered {
572 let modeled: BTreeSet<&str> = SPECS.iter().map(|spec| spec.action).collect();
573 for (action, default_keys) in discovered {
574 if modeled.contains(action.as_str()) {
575 continue;
576 }
577 let configured = configured_value(keys, action);
578 let binding_keys = configured.clone().unwrap_or_else(|| default_keys.clone());
579 let status = if binding_keys.iter().all(|key| key.trim().is_empty()) {
580 BindingStatus::Disabled
581 } else {
582 BindingStatus::Active
583 };
584 let source = if configured.is_some() {
585 BindingSource::Custom
586 } else {
587 BindingSource::Default
588 };
589 out.push(Binding {
590 action: action.clone(),
591 label: humanize_action(action),
592 keys: binding_keys
593 .into_iter()
594 .filter(|key| !key.trim().is_empty())
595 .collect(),
596 default_keys: default_keys.clone(),
597 category: Category::Discovered,
598 tree_path: vec!["Discovered".to_string(), "Unmodeled".to_string()],
599 source,
600 status,
601 hint: "Auto-discovered from `herdr --default-config`; not yet modeled in Pretty Which specs.".to_string(),
602 });
603 }
604 }
605
606 out
607}
608
609fn humanize_action(action: &str) -> String {
612 action
613 .split('_')
614 .map(|word| {
615 let mut chars = word.chars();
616 match chars.next() {
617 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
618 None => String::new(),
619 }
620 })
621 .collect::<Vec<_>>()
622 .join(" ")
623}
624
625pub fn category_counts(bindings: &[Binding]) -> BTreeMap<Category, usize> {
626 let mut counts = BTreeMap::new();
627 for binding in bindings
628 .iter()
629 .filter(|binding| binding.status == BindingStatus::Active)
630 {
631 *counts.entry(binding.category).or_insert(0) += 1;
632 }
633 counts
634}
635
636fn strings(values: &[&str]) -> Vec<String> {
637 values.iter().map(|value| (*value).to_string()).collect()
638}
639
640fn tree_path_for_action(action: &str, category: Category) -> Vec<String> {
641 let segments = match action {
642 "help"
643 | "settings"
644 | "detach"
645 | "reload_config"
646 | "open_notification_target"
647 | "remote_image_paste" => &["Session", "Core"][..],
648 "workspace_picker" | "goto" => &["Workspaces", "Picker"][..],
649 "new_workspace" | "rename_workspace" | "close_workspace" => {
650 &["Workspaces", "Lifecycle"][..]
651 }
652 "new_worktree" | "open_worktree" | "remove_worktree" => &["Workspaces", "Worktrees"][..],
653 "previous_workspace"
654 | "next_workspace"
655 | "switch_workspace"
656 | "navigate_workspace_up"
657 | "navigate_workspace_down" => &["Workspaces", "Navigation"][..],
658 "new_tab" | "rename_tab" | "close_tab" => &["Tabs", "Lifecycle"][..],
659 "previous_tab" | "next_tab" | "switch_tab" => &["Tabs", "Navigation"][..],
660 "rename_pane" | "close_pane" => &["Panes", "Lifecycle"][..],
661 "edit_scrollback" => &["Panes", "Scrollback"][..],
662 "focus_pane_left"
663 | "focus_pane_down"
664 | "focus_pane_up"
665 | "focus_pane_right"
666 | "cycle_pane_next"
667 | "cycle_pane_previous"
668 | "last_pane" => &["Panes", "Focus"][..],
669 "split_vertical" | "split_horizontal" | "zoom" | "resize_mode" | "toggle_sidebar" => {
670 &["Panes", "Layout"][..]
671 }
672 "navigate_pane_left"
673 | "navigate_pane_down"
674 | "navigate_pane_up"
675 | "navigate_pane_right" => &["Panes", "Navigation mode"][..],
676 "previous_agent" | "next_agent" | "focus_agent" => &["Agents", "Focus"][..],
677 _ => return vec![category.title().to_string(), "Other".to_string()],
678 };
679 strings(segments)
680}
681
682fn configured_value(keys: &KeysSection, action: &str) -> Option<Vec<String>> {
683 let value = match action {
684 "help" => keys.help.as_ref(),
685 "settings" => keys.settings.as_ref(),
686 "detach" => keys.detach.as_ref(),
687 "reload_config" => keys.reload_config.as_ref(),
688 "open_notification_target" => keys.open_notification_target.as_ref(),
689 "remote_image_paste" => keys.remote_image_paste.as_ref(),
690 "workspace_picker" => keys.workspace_picker.as_ref(),
691 "goto" => keys.goto.as_ref(),
692 "new_workspace" => keys.new_workspace.as_ref(),
693 "new_worktree" => keys.new_worktree.as_ref(),
694 "open_worktree" => keys.open_worktree.as_ref(),
695 "remove_worktree" => keys.remove_worktree.as_ref(),
696 "rename_workspace" => keys.rename_workspace.as_ref(),
697 "close_workspace" => keys.close_workspace.as_ref(),
698 "previous_workspace" => keys.previous_workspace.as_ref(),
699 "next_workspace" => keys.next_workspace.as_ref(),
700 "previous_agent" => keys.previous_agent.as_ref(),
701 "next_agent" => keys.next_agent.as_ref(),
702 "focus_agent" => keys.focus_agent.as_ref(),
703 "new_tab" => keys.new_tab.as_ref(),
704 "rename_tab" => keys.rename_tab.as_ref(),
705 "previous_tab" => keys.previous_tab.as_ref(),
706 "next_tab" => keys.next_tab.as_ref(),
707 "switch_tab" => keys.switch_tab.as_ref(),
708 "switch_workspace" => keys.switch_workspace.as_ref(),
709 "close_tab" => keys.close_tab.as_ref(),
710 "rename_pane" => keys.rename_pane.as_ref(),
711 "edit_scrollback" => keys.edit_scrollback.as_ref(),
712 "focus_pane_left" => keys.focus_pane_left.as_ref(),
713 "focus_pane_down" => keys.focus_pane_down.as_ref(),
714 "focus_pane_up" => keys.focus_pane_up.as_ref(),
715 "focus_pane_right" => keys.focus_pane_right.as_ref(),
716 "cycle_pane_next" => keys.cycle_pane_next.as_ref(),
717 "cycle_pane_previous" => keys.cycle_pane_previous.as_ref(),
718 "last_pane" => keys.last_pane.as_ref(),
719 "split_vertical" => keys.split_vertical.as_ref(),
720 "split_horizontal" => keys.split_horizontal.as_ref(),
721 "close_pane" => keys.close_pane.as_ref(),
722 "zoom" => keys.zoom.as_ref().or(keys.fullscreen.as_ref()),
723 "resize_mode" => keys.resize_mode.as_ref(),
724 "toggle_sidebar" => keys.toggle_sidebar.as_ref(),
725 "navigate_workspace_up" => keys.navigate_workspace_up.as_ref(),
726 "navigate_workspace_down" => keys.navigate_workspace_down.as_ref(),
727 "navigate_pane_left" => keys.navigate_pane_left.as_ref(),
728 "navigate_pane_down" => keys.navigate_pane_down.as_ref(),
729 "navigate_pane_up" => keys.navigate_pane_up.as_ref(),
730 "navigate_pane_right" => keys.navigate_pane_right.as_ref(),
731 _ => None,
732 }?;
733 Some(value.keys())
734}
735
736#[cfg(test)]
737mod tests {
738 use super::*;
739
740 #[test]
741 fn defaults_include_native_tab_navigation() {
742 let bindings = effective_bindings(&KeysSection::default());
743 let next_tab = bindings
744 .iter()
745 .find(|binding| binding.action == "next_tab")
746 .unwrap();
747 assert_eq!(next_tab.keys, vec!["prefix+n"]);
748 assert_eq!(next_tab.source, BindingSource::Default);
749 }
750
751 #[test]
752 fn bindings_include_explicit_tree_paths() {
753 let bindings = effective_bindings(&KeysSection::default());
754 let next_tab = bindings
755 .iter()
756 .find(|binding| binding.action == "next_tab")
757 .unwrap();
758 let new_worktree = bindings
759 .iter()
760 .find(|binding| binding.action == "new_worktree")
761 .unwrap();
762 let focus_left = bindings
763 .iter()
764 .find(|binding| binding.action == "focus_pane_left")
765 .unwrap();
766
767 assert_eq!(next_tab.tree_path, vec!["Tabs", "Navigation"]);
768 assert_eq!(new_worktree.tree_path, vec!["Workspaces", "Worktrees"]);
769 assert_eq!(focus_left.tree_path, vec!["Panes", "Focus"]);
770 }
771
772 #[test]
773 fn empty_string_disables_binding() {
774 let keys = KeysSection {
775 next_tab: Some(KeyValue::One(String::new())),
776 ..Default::default()
777 };
778 let bindings = effective_bindings(&keys);
779 let next_tab = bindings
780 .iter()
781 .find(|binding| binding.action == "next_tab")
782 .unwrap();
783 assert_eq!(next_tab.status, BindingStatus::Disabled);
784 assert!(next_tab.keys.is_empty());
785 }
786
787 #[test]
788 fn command_bindings_are_preserved() {
789 let keys = KeysSection {
790 command: vec![CommandBinding {
791 name: Some("Pretty Which".to_string()),
792 key: Some(KeyValue::One("prefix+?".to_string())),
793 command: Some("ramarivera.pretty-which.open".to_string()),
794 ..Default::default()
795 }],
796 ..Default::default()
797 };
798 let bindings = effective_bindings(&keys);
799 assert!(
800 bindings
801 .iter()
802 .any(|binding| binding.category == Category::Custom
803 && binding.label == "Pretty Which")
804 );
805 }
806
807 #[test]
808 fn discovery_surfaces_unmodeled_actions_without_duplicating_modeled_ones() {
809 let mut discovered = BTreeMap::new();
812 discovered.insert("future_action".to_string(), vec!["prefix+9".to_string()]);
813 discovered.insert("next_tab".to_string(), vec!["prefix+n".to_string()]);
815
816 let bindings =
817 effective_bindings_with_discovery(&KeysSection::default(), Some(&discovered));
818
819 let future = bindings
820 .iter()
821 .find(|binding| binding.action == "future_action")
822 .expect("unmodeled discovered action is surfaced");
823 assert_eq!(future.category, Category::Discovered);
824 assert_eq!(future.keys, vec!["prefix+9"]);
825 assert_eq!(future.source, BindingSource::Default);
826 assert_eq!(future.label, "Future Action");
827 assert_eq!(future.tree_path, vec!["Discovered", "Unmodeled"]);
828
829 let next_tab_occurrences = bindings
832 .iter()
833 .filter(|binding| binding.action == "next_tab")
834 .count();
835 assert_eq!(next_tab_occurrences, 1);
836 assert_eq!(
837 bindings
838 .iter()
839 .find(|binding| binding.action == "next_tab")
840 .unwrap()
841 .category,
842 Category::Tabs
843 );
844 }
845
846 #[test]
847 fn modeled_actions_covers_all_static_specs() {
848 let modeled = modeled_actions();
849 assert!(modeled.contains("next_tab"));
850 assert!(modeled.contains("split_vertical"));
851 assert_eq!(modeled.len(), SPECS.len());
852 }
853}