parent
9eec3029e1
commit
57c880f0eb
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
### Added
|
||||
- Added a session navigator at `prefix+g` with a searchable workspace/tab/pane tree, agent state filters, mouse switching, and keyboard navigation. (#157)
|
||||
- Added a configurable `last_pane` keybinding action for tmux-style back-and-forth navigation to the last focused pane across workspaces and tabs. It is unset by default. (#287)
|
||||
- Added scrollback support to direct agent terminal attaches. Mouse wheel and plain PageUp/PageDown now scroll the attached terminal viewport, while terminal apps that request mouse or alternate-scroll input still receive those events. The client/server protocol is now version 11.
|
||||
- Added `ui.redraw_on_focus_gained` to keep the existing full redraw on outer-terminal focus gain by default while allowing users to opt out of the visible refresh. (#282)
|
||||
- Added `--handoff` for `herdr update` and `herdr --remote` to opt into live server handoff for supported running servers. Plain update and remote attach use the normal restart/stop flow by default.
|
||||
|
|
|
|||
|
|
@ -243,6 +243,8 @@ resize mode: `h`/`l` resize width, `j`/`k` resize height, `esc` exit.
|
|||
|
||||
session navigator opens a searchable workspace, tab, and pane tree. use `/` for text search, `b`/`w`/`i`/`d` for blocked, working, idle, and done filters, `a` or backspace to clear a state filter, and enter to switch to the highlighted row.
|
||||
|
||||
last-pane is available but unset by default. bind `last_pane` in `[keys]` if you want tmux-style back-and-forth navigation to the last focused pane across workspaces and tabs; for example, `last_pane = "prefix+tab"`.
|
||||
|
||||
custom command keybindings can launch detached shell helpers or temporary panes:
|
||||
|
||||
```toml
|
||||
|
|
|
|||
|
|
@ -128,6 +128,9 @@ focus_pane_left = "prefix+h"
|
|||
focus_pane_down = "prefix+j"
|
||||
focus_pane_up = "prefix+k"
|
||||
focus_pane_right = "prefix+l"
|
||||
cycle_pane_next = "prefix+tab"
|
||||
cycle_pane_previous = "prefix+shift+tab"
|
||||
last_pane = ""
|
||||
split_vertical = "prefix+v"
|
||||
split_horizontal = "prefix+minus"
|
||||
close_pane = "prefix+x"
|
||||
|
|
@ -142,11 +145,14 @@ Optional actions are unset by default. Bind them with `prefix+` for prefix-mode
|
|||
[keys]
|
||||
previous_workspace = "prefix+shift+left"
|
||||
next_workspace = "prefix+shift+right"
|
||||
last_pane = "prefix+tab"
|
||||
open_worktree = "prefix+shift+o"
|
||||
remove_worktree = "prefix+alt+d"
|
||||
next_tab = ["prefix+n", "ctrl+alt+]"]
|
||||
```
|
||||
|
||||
`last_pane` switches back to the last focused pane across workspaces and tabs. It is unset by default because the tmux-style pane binding `prefix+l` is already used for pane-right focus.
|
||||
|
||||
Key strings accept plain keys, modifier combinations such as `ctrl+a`, `shift+n`, `alt+1`, `cmd+k`, and special keys such as `enter`, `tab`, `esc`, `left`, `right`, `up`, and `down`. Named punctuation such as `minus`, `comma`, `ampersand`, `plus`, and `backtick` is also accepted. Plain direct printable keys such as `n` are unsafe because they intercept typing; use `prefix+n` unless you intentionally want a direct binding. The `navigate_workspace_*` and `navigate_pane_*` fields are navigate-mode-only and may use plain keys such as `j` or `k`; they must not use `prefix+`, `esc`, `enter`, `tab`, `shift+tab`, `left`, `right`, or unmodified `1` through `9`. Left and right arrows are permanent aliases for pane-left and pane-right navigation. These navigate-mode shortcuts are independent from general action bindings such as `focus_pane_down = "prefix+j"`; when both use the same key, the navigate-mode shortcut wins while navigate mode is open. Alt, Cmd/Super, and punctuation with modifiers depend on your terminal and tmux settings.
|
||||
|
||||
If you have old custom keybindings and want the new defaults, run `herdr config reset-keys`. Herdr backs up `config.toml`, removes `[keys]` and `[[keys.command]]`, and uses built-in v2 defaults after restart or `herdr server reload-config`.
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ use crate::workspace::WorkspaceGitStatus;
|
|||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
use super::state::{
|
||||
AppState, Mode, NavigatorRow, NavigatorStateFilter, NavigatorTarget, ToastKind,
|
||||
ToastNotification, ToastTarget, ViewLayout,
|
||||
AppState, Mode, NavigatorRow, NavigatorStateFilter, NavigatorTarget, PaneFocusTarget,
|
||||
ToastKind, ToastNotification, ToastTarget, ViewLayout,
|
||||
};
|
||||
|
||||
fn is_background_completion_transition(prev_state: AgentState, new_state: AgentState) -> bool {
|
||||
|
|
@ -104,6 +104,80 @@ pub struct PaneStateUpdate {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl AppState {
|
||||
pub(crate) fn current_pane_focus_target(&self) -> Option<PaneFocusTarget> {
|
||||
let ws_idx = self.active?;
|
||||
let ws = self.workspaces.get(ws_idx)?;
|
||||
let pane_id = ws.focused_pane_id()?;
|
||||
Some(PaneFocusTarget {
|
||||
workspace_id: ws.id.clone(),
|
||||
pane_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn pane_focus_target_indices(&self, target: &PaneFocusTarget) -> Option<(usize, usize)> {
|
||||
let ws_idx = self
|
||||
.workspaces
|
||||
.iter()
|
||||
.position(|ws| ws.id == target.workspace_id)?;
|
||||
let tab_idx = self.workspaces[ws_idx].find_tab_index_for_pane(target.pane_id)?;
|
||||
Some((ws_idx, tab_idx))
|
||||
}
|
||||
|
||||
pub(crate) fn record_pane_focus_change(
|
||||
&mut self,
|
||||
previous: Option<PaneFocusTarget>,
|
||||
ws_idx: usize,
|
||||
pane_id: PaneId,
|
||||
) {
|
||||
let Some(ws) = self.workspaces.get(ws_idx) else {
|
||||
return;
|
||||
};
|
||||
let target = PaneFocusTarget {
|
||||
workspace_id: ws.id.clone(),
|
||||
pane_id,
|
||||
};
|
||||
if previous.as_ref() != Some(&target) {
|
||||
self.previous_pane_focus = previous;
|
||||
}
|
||||
}
|
||||
|
||||
fn record_pane_focus_after_navigation(&mut self, previous: Option<PaneFocusTarget>) {
|
||||
let current = self.current_pane_focus_target();
|
||||
if previous != current {
|
||||
self.previous_pane_focus = previous;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn focus_pane_in_workspace(&mut self, ws_idx: usize, pane_id: PaneId) -> bool {
|
||||
let Some(ws) = self.workspaces.get(ws_idx) else {
|
||||
return false;
|
||||
};
|
||||
let Some(tab_idx) = ws.find_tab_index_for_pane(pane_id) else {
|
||||
return false;
|
||||
};
|
||||
let previous = self.current_pane_focus_target();
|
||||
let target = PaneFocusTarget {
|
||||
workspace_id: ws.id.clone(),
|
||||
pane_id,
|
||||
};
|
||||
if previous.as_ref() == Some(&target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.switch_workspace_tab(ws_idx, tab_idx);
|
||||
if let Some(tab) = self
|
||||
.workspaces
|
||||
.get_mut(ws_idx)
|
||||
.and_then(|ws| ws.tabs.get_mut(tab_idx))
|
||||
{
|
||||
tab.layout.focus_pane(pane_id);
|
||||
self.previous_pane_focus = previous;
|
||||
self.mark_session_dirty();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn open_navigator(&mut self) {
|
||||
self.navigator.query.clear();
|
||||
self.navigator.search_focused = false;
|
||||
|
|
@ -403,8 +477,7 @@ impl AppState {
|
|||
if !tab_exists {
|
||||
return false;
|
||||
}
|
||||
self.switch_workspace(ws_idx);
|
||||
self.switch_tab(tab_idx);
|
||||
self.switch_workspace_tab(ws_idx, tab_idx);
|
||||
self.mode = Mode::Terminal;
|
||||
true
|
||||
}
|
||||
|
|
@ -416,19 +489,15 @@ impl AppState {
|
|||
if ws_idx >= self.workspaces.len() {
|
||||
return false;
|
||||
}
|
||||
self.switch_workspace(ws_idx);
|
||||
self.switch_tab(tab_idx);
|
||||
if let Some(tab) = self
|
||||
if self
|
||||
.workspaces
|
||||
.get_mut(ws_idx)
|
||||
.and_then(|ws| ws.tabs.get_mut(tab_idx))
|
||||
.get(ws_idx)
|
||||
.and_then(|ws| ws.tabs.get(tab_idx))
|
||||
.is_some_and(|tab| tab.panes.contains_key(&pane_id))
|
||||
{
|
||||
if tab.panes.contains_key(&pane_id) {
|
||||
tab.layout.focus_pane(pane_id);
|
||||
self.mark_session_dirty();
|
||||
self.mode = Mode::Terminal;
|
||||
return true;
|
||||
}
|
||||
self.focus_pane_in_workspace(ws_idx, pane_id);
|
||||
self.mode = Mode::Terminal;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
|
@ -601,6 +670,7 @@ impl AppState {
|
|||
|
||||
pub fn switch_workspace(&mut self, idx: usize) {
|
||||
if idx < self.workspaces.len() {
|
||||
let previous_focus = self.current_pane_focus_target();
|
||||
self.selection = None;
|
||||
self.selection_autoscroll = None;
|
||||
self.active = Some(idx);
|
||||
|
|
@ -623,9 +693,53 @@ impl AppState {
|
|||
}
|
||||
self.tab_scroll_follow_active = true;
|
||||
self.refresh_tab_bar_view();
|
||||
self.record_pane_focus_after_navigation(previous_focus);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn switch_workspace_tab(&mut self, ws_idx: usize, tab_idx: usize) -> bool {
|
||||
if ws_idx >= self.workspaces.len() {
|
||||
return false;
|
||||
}
|
||||
if self
|
||||
.workspaces
|
||||
.get(ws_idx)
|
||||
.is_none_or(|ws| tab_idx >= ws.tabs.len())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let previous_focus = self.current_pane_focus_target();
|
||||
let workspace_changed = self.active != Some(ws_idx);
|
||||
self.selection = None;
|
||||
self.selection_autoscroll = None;
|
||||
self.active = Some(ws_idx);
|
||||
self.selected = ws_idx;
|
||||
let workspace_id = self.workspaces[ws_idx].id.clone();
|
||||
if workspace_changed {
|
||||
crate::logging::workspace_focused(&workspace_id);
|
||||
}
|
||||
self.mark_session_dirty();
|
||||
if workspace_changed
|
||||
&& matches!(
|
||||
self.agent_panel_scope,
|
||||
crate::app::state::AgentPanelScope::CurrentWorkspace
|
||||
)
|
||||
{
|
||||
self.agent_panel_scroll = 0;
|
||||
}
|
||||
self.ensure_workspace_visible(ws_idx);
|
||||
if let Some(ws) = self.workspaces.get_mut(ws_idx) {
|
||||
ws.switch_tab(tab_idx);
|
||||
let tab_id = format!("{}:{}", workspace_id, tab_idx + 1);
|
||||
crate::logging::tab_focused(&workspace_id, &tab_id);
|
||||
}
|
||||
self.tab_scroll_follow_active = true;
|
||||
self.refresh_tab_bar_view();
|
||||
self.record_pane_focus_after_navigation(previous_focus);
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_workspace_visible(&mut self, idx: usize) {
|
||||
if idx >= self.workspaces.len() {
|
||||
return;
|
||||
|
|
@ -707,6 +821,7 @@ impl AppState {
|
|||
|
||||
pub fn switch_tab(&mut self, idx: usize) {
|
||||
if let Some(ws_idx) = self.active {
|
||||
let previous_focus = self.current_pane_focus_target();
|
||||
self.selection = None;
|
||||
self.selection_autoscroll = None;
|
||||
let Some(ws) = self.workspaces.get_mut(ws_idx) else {
|
||||
|
|
@ -719,6 +834,7 @@ impl AppState {
|
|||
self.mark_session_dirty();
|
||||
self.tab_scroll_follow_active = true;
|
||||
self.refresh_tab_bar_view();
|
||||
self.record_pane_focus_after_navigation(previous_focus);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -893,22 +1009,17 @@ impl AppState {
|
|||
return false;
|
||||
};
|
||||
let ws_idx = target.ws_idx;
|
||||
let tab_idx = target.tab_idx;
|
||||
let pane_id = target.pane_id;
|
||||
|
||||
self.switch_workspace(ws_idx);
|
||||
self.switch_tab(tab_idx);
|
||||
if let Some(tab) = self
|
||||
.workspaces
|
||||
.get_mut(ws_idx)
|
||||
.and_then(|ws| ws.tabs.get_mut(tab_idx))
|
||||
if self.active == Some(ws_idx) && self.workspaces[ws_idx].focused_pane_id() == Some(pane_id)
|
||||
{
|
||||
if tab.panes.contains_key(&pane_id) {
|
||||
tab.layout.focus_pane(pane_id);
|
||||
self.mark_session_dirty();
|
||||
self.ensure_agent_panel_entry_visible(idx);
|
||||
return true;
|
||||
}
|
||||
self.ensure_agent_panel_entry_visible(idx);
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.focus_pane_in_workspace(ws_idx, pane_id) {
|
||||
self.ensure_agent_panel_entry_visible(idx);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
|
@ -1124,14 +1235,7 @@ impl AppState {
|
|||
|
||||
if let Some(focused) = panes.iter().find(|p| p.is_focused) {
|
||||
if let Some(target) = find_in_direction(focused, direction, &panes) {
|
||||
if let Some(tab) = self
|
||||
.workspaces
|
||||
.get_mut(ws_idx)
|
||||
.and_then(|ws| ws.active_tab_mut())
|
||||
{
|
||||
tab.layout.focus_pane(target);
|
||||
self.mark_session_dirty();
|
||||
}
|
||||
self.focus_pane_in_workspace(ws_idx, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1155,16 +1259,45 @@ impl AppState {
|
|||
}
|
||||
|
||||
pub fn cycle_pane(&mut self, reverse: bool) {
|
||||
if let Some(tab) = self
|
||||
.active
|
||||
.and_then(|i| self.workspaces.get_mut(i))
|
||||
.and_then(|ws| ws.active_tab_mut())
|
||||
{
|
||||
if reverse {
|
||||
tab.layout.focus_prev();
|
||||
let Some(ws_idx) = self.active else {
|
||||
return;
|
||||
};
|
||||
let Some(tab) = self.workspaces.get(ws_idx).and_then(|ws| ws.active_tab()) else {
|
||||
return;
|
||||
};
|
||||
let ids = tab.layout.pane_ids();
|
||||
if let Some(pos) = ids.iter().position(|id| *id == tab.layout.focused()) {
|
||||
let target = if reverse {
|
||||
ids[(pos + ids.len() - 1) % ids.len()]
|
||||
} else {
|
||||
tab.layout.focus_next();
|
||||
}
|
||||
ids[(pos + 1) % ids.len()]
|
||||
};
|
||||
self.focus_pane_in_workspace(ws_idx, target);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn last_pane(&mut self) {
|
||||
let Some(target) = self.previous_pane_focus.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some((ws_idx, tab_idx)) = self.pane_focus_target_indices(&target) else {
|
||||
self.previous_pane_focus = None;
|
||||
return;
|
||||
};
|
||||
let current = self.current_pane_focus_target();
|
||||
if current.as_ref() == Some(&target) {
|
||||
self.previous_pane_focus = None;
|
||||
return;
|
||||
}
|
||||
|
||||
self.switch_workspace_tab(ws_idx, tab_idx);
|
||||
if let Some(tab) = self
|
||||
.workspaces
|
||||
.get_mut(ws_idx)
|
||||
.and_then(|ws| ws.tabs.get_mut(tab_idx))
|
||||
{
|
||||
tab.layout.focus_pane(target.pane_id);
|
||||
self.previous_pane_focus = current;
|
||||
self.mark_session_dirty();
|
||||
}
|
||||
}
|
||||
|
|
@ -2514,6 +2647,18 @@ mod tests {
|
|||
assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_agent_entry_succeeds_for_already_focused_agent() {
|
||||
let mut state = app_with_workspaces(&["one"]);
|
||||
let root = state.workspaces[0].tabs[0].root_pane;
|
||||
state.agent_panel_scope = crate::app::state::AgentPanelScope::AllWorkspaces;
|
||||
mark_agent(&mut state, 0, 0, root);
|
||||
|
||||
assert!(state.focus_agent_entry(0));
|
||||
assert_eq!(state.active, Some(0));
|
||||
assert_eq!(state.workspaces[0].focused_pane_id(), Some(root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_agent_cycles_only_current_scope_entries() {
|
||||
let mut first = Workspace::test_new("one");
|
||||
|
|
@ -2577,6 +2722,122 @@ mod tests {
|
|||
assert_eq!(state.selected, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_pane_toggles_to_previous_focus_in_active_tab() {
|
||||
let mut state = app_with_workspaces(&["test"]);
|
||||
let root = state.workspaces[0].tabs[0].root_pane;
|
||||
let right = state.workspaces[0].test_split(Direction::Horizontal);
|
||||
|
||||
state.focus_pane_in_workspace(0, root);
|
||||
state.focus_pane_in_workspace(0, right);
|
||||
state.last_pane();
|
||||
|
||||
assert_eq!(state.workspaces[0].focused_pane_id(), Some(root));
|
||||
|
||||
state.last_pane();
|
||||
|
||||
assert_eq!(state.workspaces[0].focused_pane_id(), Some(right));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_background_pane_preserves_last_pane_history() {
|
||||
let mut state = app_with_workspaces(&["test"]);
|
||||
let root = state.workspaces[0].tabs[0].root_pane;
|
||||
let right = state.workspaces[0].test_split(Direction::Horizontal);
|
||||
let background = state.workspaces[0].test_split(Direction::Horizontal);
|
||||
|
||||
state.focus_pane_in_workspace(0, root);
|
||||
state.focus_pane_in_workspace(0, right);
|
||||
state.workspaces[0].remove_pane(background);
|
||||
state.last_pane();
|
||||
|
||||
assert_eq!(state.workspaces[0].focused_pane_id(), Some(root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_pane_jumps_across_workspaces_and_tabs() {
|
||||
let mut state = app_with_workspaces(&["one", "two"]);
|
||||
let first_root = state.workspaces[0].tabs[0].root_pane;
|
||||
let second_tab = state.workspaces[1].test_add_tab(Some("logs"));
|
||||
let second_tab_root = state.workspaces[1].tabs[second_tab].root_pane;
|
||||
|
||||
state.focus_pane_in_workspace(0, first_root);
|
||||
state.focus_pane_in_workspace(1, second_tab_root);
|
||||
state.last_pane();
|
||||
|
||||
assert_eq!(state.active, Some(0));
|
||||
assert_eq!(state.workspaces[0].active_tab, 0);
|
||||
assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root));
|
||||
|
||||
state.last_pane();
|
||||
|
||||
assert_eq!(state.active, Some(1));
|
||||
assert_eq!(state.workspaces[1].active_tab, second_tab);
|
||||
assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_tab_root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_pane_tracks_tab_and_workspace_switches() {
|
||||
let mut state = app_with_workspaces(&["one", "two"]);
|
||||
let first_root = state.workspaces[0].tabs[0].root_pane;
|
||||
let first_second_tab = state.workspaces[0].test_add_tab(Some("logs"));
|
||||
let first_second_root = state.workspaces[0].tabs[first_second_tab].root_pane;
|
||||
let second_root = state.workspaces[1].tabs[0].root_pane;
|
||||
|
||||
state.switch_tab(first_second_tab);
|
||||
state.last_pane();
|
||||
|
||||
assert_eq!(state.active, Some(0));
|
||||
assert_eq!(state.workspaces[0].active_tab, 0);
|
||||
assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root));
|
||||
|
||||
state.last_pane();
|
||||
|
||||
assert_eq!(state.active, Some(0));
|
||||
assert_eq!(state.workspaces[0].active_tab, first_second_tab);
|
||||
assert_eq!(
|
||||
state.workspaces[0].focused_pane_id(),
|
||||
Some(first_second_root)
|
||||
);
|
||||
|
||||
state.switch_workspace(1);
|
||||
state.last_pane();
|
||||
|
||||
assert_eq!(state.active, Some(0));
|
||||
assert_eq!(state.workspaces[0].active_tab, first_second_tab);
|
||||
assert_eq!(
|
||||
state.workspaces[0].focused_pane_id(),
|
||||
Some(first_second_root)
|
||||
);
|
||||
|
||||
state.last_pane();
|
||||
|
||||
assert_eq!(state.active, Some(1));
|
||||
assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_pane_tracks_cross_workspace_tab_selection() {
|
||||
let mut state = app_with_workspaces(&["one", "two"]);
|
||||
let first_root = state.workspaces[0].tabs[0].root_pane;
|
||||
let second_first_root = state.workspaces[1].tabs[0].root_pane;
|
||||
let second_tab = state.workspaces[1].test_add_tab(Some("logs"));
|
||||
let second_tab_root = state.workspaces[1].tabs[second_tab].root_pane;
|
||||
|
||||
state.switch_workspace_tab(1, second_tab);
|
||||
state.last_pane();
|
||||
|
||||
assert_eq!(state.active, Some(0));
|
||||
assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root));
|
||||
|
||||
state.last_pane();
|
||||
|
||||
assert_eq!(state.active, Some(1));
|
||||
assert_eq!(state.workspaces[1].active_tab, second_tab);
|
||||
assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_tab_root));
|
||||
assert_ne!(second_first_root, second_tab_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_workspace_keeps_selected_visible_in_scrolled_sidebar() {
|
||||
let mut state = app_with_workspaces(&["a", "b", "c", "d", "e", "f", "g", "h"]);
|
||||
|
|
@ -3387,7 +3648,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn close_tab_last_tab_closes_active_workspace_not_selected_workspace() {
|
||||
fn close_tab_closes_active_workspace_not_selected_workspace() {
|
||||
let mut state = app_with_workspaces(&["selected", "active"]);
|
||||
let active_terminal_id = state
|
||||
.terminal_id_for_pane(1, state.workspaces[1].tabs[0].root_pane)
|
||||
|
|
@ -3419,7 +3680,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn close_tab_last_tab_in_linked_worktree_closes_workspace_only() {
|
||||
fn close_tab_in_linked_worktree_closes_workspace_only() {
|
||||
let mut state = app_with_workspaces(&["selected", "active"]);
|
||||
mark_linked_worktree(&mut state, 1);
|
||||
state.active = Some(1);
|
||||
|
|
|
|||
|
|
@ -36,16 +36,8 @@ impl App {
|
|||
target: &str,
|
||||
) -> Result<crate::api::schema::AgentInfo, TerminalTargetError> {
|
||||
let resolved = self.resolve_terminal_target(target)?;
|
||||
self.state.switch_workspace(resolved.ws_idx);
|
||||
self.state.switch_tab(resolved.tab_idx);
|
||||
if let Some(tab) = self
|
||||
.state
|
||||
.workspaces
|
||||
.get_mut(resolved.ws_idx)
|
||||
.and_then(|ws| ws.tabs.get_mut(resolved.tab_idx))
|
||||
{
|
||||
tab.layout.focus_pane(resolved.pane_id);
|
||||
}
|
||||
self.state
|
||||
.focus_pane_in_workspace(resolved.ws_idx, resolved.pane_id);
|
||||
self.state.mode = Mode::Terminal;
|
||||
self.agent_info(resolved.ws_idx, resolved.pane_id)
|
||||
.ok_or_else(|| TerminalTargetError::NotFound {
|
||||
|
|
@ -359,6 +351,7 @@ impl App {
|
|||
focus: bool,
|
||||
) -> Result<(usize, usize, crate::layout::PaneId), AgentStartError> {
|
||||
let (rows, cols) = self.state.estimate_pane_size();
|
||||
let previous_focus = self.state.current_pane_focus_target();
|
||||
let direction = match split {
|
||||
SplitDirection::Right => ratatui::layout::Direction::Horizontal,
|
||||
SplitDirection::Down => ratatui::layout::Direction::Vertical,
|
||||
|
|
@ -390,8 +383,9 @@ impl App {
|
|||
.terminals
|
||||
.insert(result.1.terminal.id.clone(), result.1.terminal);
|
||||
if focus {
|
||||
self.state.switch_workspace(ws_idx);
|
||||
self.state.switch_tab(result.0);
|
||||
self.state.switch_workspace_tab(ws_idx, result.0);
|
||||
self.state
|
||||
.record_pane_focus_change(previous_focus, ws_idx, result.1.pane_id);
|
||||
self.state.mode = Mode::Terminal;
|
||||
}
|
||||
self.schedule_session_save();
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ impl App {
|
|||
let default_shell = self.state.default_shell.clone();
|
||||
let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes;
|
||||
let host_terminal_theme = self.state.host_terminal_theme;
|
||||
let previous_focus = self.state.current_pane_focus_target();
|
||||
let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
|
||||
return pane_not_found(id, ¶ms.target_pane_id);
|
||||
};
|
||||
|
|
@ -57,8 +58,9 @@ impl App {
|
|||
None => return pane_not_found(id, ¶ms.target_pane_id),
|
||||
};
|
||||
if params.focus {
|
||||
self.state.switch_workspace(ws_idx);
|
||||
self.state.switch_tab(target_tab_idx);
|
||||
self.state.switch_workspace_tab(ws_idx, target_tab_idx);
|
||||
self.state
|
||||
.record_pane_focus_change(previous_focus, ws_idx, new_pane.pane_id);
|
||||
self.state.mode = Mode::Terminal;
|
||||
}
|
||||
self.terminal_runtimes
|
||||
|
|
|
|||
|
|
@ -109,8 +109,7 @@ impl App {
|
|||
}
|
||||
}
|
||||
if focus {
|
||||
self.state.switch_workspace(ws_idx);
|
||||
self.state.switch_tab(tab_idx);
|
||||
self.state.switch_workspace_tab(ws_idx, tab_idx);
|
||||
self.state.mode = Mode::Terminal;
|
||||
}
|
||||
self.schedule_session_save();
|
||||
|
|
@ -142,8 +141,7 @@ impl App {
|
|||
let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else {
|
||||
return tab_not_found(id, &target.tab_id);
|
||||
};
|
||||
self.state.switch_workspace(ws_idx);
|
||||
self.state.switch_tab(tab_idx);
|
||||
self.state.switch_workspace_tab(ws_idx, tab_idx);
|
||||
let tab = self.tab_info(ws_idx, tab_idx).unwrap();
|
||||
|
||||
encode_success(id, ResponseResult::TabInfo { tab })
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ impl App {
|
|||
self.terminal_runtimes.insert(terminal.id.clone(), runtime);
|
||||
self.state.terminals.insert(terminal.id.clone(), terminal);
|
||||
if focus {
|
||||
ws.switch_tab(idx);
|
||||
self.state.switch_workspace_tab(ws_idx, idx);
|
||||
self.state.mode = Mode::Terminal;
|
||||
}
|
||||
let workspace_id = self.state.workspaces[ws_idx].id.clone();
|
||||
|
|
|
|||
|
|
@ -367,7 +367,11 @@ impl AppState {
|
|||
follow_cwd,
|
||||
));
|
||||
|
||||
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
|
||||
let previous_focus = self.current_pane_focus_target();
|
||||
if let Some(ws_idx) = self.active {
|
||||
let Some(ws) = self.workspaces.get_mut(ws_idx) else {
|
||||
return;
|
||||
};
|
||||
if let Ok(new_pane) = ws.split_focused(
|
||||
direction,
|
||||
new_rows,
|
||||
|
|
@ -381,7 +385,7 @@ impl AppState {
|
|||
terminal_runtimes.insert(new_pane.terminal.id.clone(), new_pane.runtime);
|
||||
self.terminals
|
||||
.insert(new_pane.terminal.id.clone(), new_pane.terminal);
|
||||
ws.layout.focus_pane(new_id);
|
||||
self.record_pane_focus_change(previous_focus, ws_idx, new_id);
|
||||
self.mark_session_dirty();
|
||||
self.mode = Mode::Terminal;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -455,12 +455,10 @@ impl AppState {
|
|||
return None;
|
||||
}
|
||||
|
||||
if let Some((ws_idx, tab_idx, pane_id)) =
|
||||
if let Some((ws_idx, _tab_idx, pane_id)) =
|
||||
self.collapsed_agent_detail_target_at(mouse.row)
|
||||
{
|
||||
self.switch_workspace(ws_idx);
|
||||
self.switch_tab(tab_idx);
|
||||
self.focus_pane(pane_id);
|
||||
self.focus_pane_in_workspace(ws_idx, pane_id);
|
||||
self.mode = Mode::Terminal;
|
||||
}
|
||||
return None;
|
||||
|
|
@ -550,11 +548,10 @@ impl AppState {
|
|||
return None;
|
||||
}
|
||||
|
||||
if let Some((ws_idx, tab_idx, pane_id)) = self.agent_detail_target_at(mouse.row)
|
||||
if let Some((ws_idx, _tab_idx, pane_id)) =
|
||||
self.agent_detail_target_at(mouse.row)
|
||||
{
|
||||
self.switch_workspace(ws_idx);
|
||||
self.switch_tab(tab_idx);
|
||||
self.focus_pane(pane_id);
|
||||
self.focus_pane_in_workspace(ws_idx, pane_id);
|
||||
self.mode = Mode::Terminal;
|
||||
return None;
|
||||
}
|
||||
|
|
@ -1027,12 +1024,10 @@ impl AppState {
|
|||
}
|
||||
Some(crate::ui::MobileSwitcherTarget::Agent {
|
||||
ws_idx,
|
||||
tab_idx,
|
||||
tab_idx: _,
|
||||
pane_id,
|
||||
}) => {
|
||||
self.switch_workspace(ws_idx);
|
||||
self.switch_tab(tab_idx);
|
||||
self.focus_pane(pane_id);
|
||||
self.focus_pane_in_workspace(ws_idx, pane_id);
|
||||
self.mode = Mode::Terminal;
|
||||
}
|
||||
Some(crate::ui::MobileSwitcherTarget::Menu(action_idx)) => {
|
||||
|
|
@ -1264,11 +1259,8 @@ impl AppState {
|
|||
}
|
||||
|
||||
pub(super) fn focus_pane(&mut self, pane_id: crate::layout::PaneId) {
|
||||
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
|
||||
if ws.layout.focused() != pane_id {
|
||||
ws.layout.focus_pane(pane_id);
|
||||
self.mark_session_dirty();
|
||||
}
|
||||
if let Some(ws_idx) = self.active {
|
||||
self.focus_pane_in_workspace(ws_idx, pane_id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1290,13 +1282,11 @@ impl AppState {
|
|||
else {
|
||||
return;
|
||||
};
|
||||
let Some(tab_idx) = self.workspaces[ws_idx].find_tab_index_for_pane(target.pane_id) else {
|
||||
let Some(_tab_idx) = self.workspaces[ws_idx].find_tab_index_for_pane(target.pane_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.switch_workspace(ws_idx);
|
||||
self.switch_tab(tab_idx);
|
||||
self.focus_pane(target.pane_id);
|
||||
self.focus_pane_in_workspace(ws_idx, target.pane_id);
|
||||
self.toast = None;
|
||||
self.mode = Mode::Terminal;
|
||||
}
|
||||
|
|
@ -1738,6 +1728,14 @@ mod tests {
|
|||
assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target_pane));
|
||||
assert!(app.state.toast.is_none());
|
||||
assert_eq!(app.state.mode, Mode::Terminal);
|
||||
|
||||
app.state.last_pane();
|
||||
|
||||
assert_eq!(app.state.active, Some(0));
|
||||
assert_eq!(
|
||||
app.state.workspaces[0].focused_pane_id(),
|
||||
Some(app.state.workspaces[0].tabs[0].root_pane)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -285,6 +285,7 @@ impl App {
|
|||
let Some(ws_idx) = self.state.active else {
|
||||
return Err(std::io::Error::other("no active workspace"));
|
||||
};
|
||||
let previous_focus_target = self.state.current_pane_focus_target();
|
||||
let (rows, cols) = self.state.estimate_pane_size();
|
||||
let new_rows = rows.max(4);
|
||||
let new_cols = cols.max(10);
|
||||
|
|
@ -323,6 +324,13 @@ impl App {
|
|||
self.state
|
||||
.terminals
|
||||
.insert(new_pane.terminal.id.clone(), new_pane.terminal);
|
||||
let new_focus_target = crate::app::state::PaneFocusTarget {
|
||||
workspace_id: ws.id.clone(),
|
||||
pane_id: new_pane_id,
|
||||
};
|
||||
if previous_focus_target.as_ref() != Some(&new_focus_target) {
|
||||
self.state.previous_pane_focus = previous_focus_target;
|
||||
}
|
||||
ws.active_tab_mut()
|
||||
.expect("workspace must have an active tab")
|
||||
.layout
|
||||
|
|
@ -499,6 +507,7 @@ pub(crate) enum NavigateAction {
|
|||
ToggleSidebar,
|
||||
CyclePaneNext,
|
||||
CyclePanePrevious,
|
||||
LastPane,
|
||||
Help,
|
||||
Settings,
|
||||
ReloadConfig,
|
||||
|
|
@ -589,6 +598,7 @@ fn action_for_key(
|
|||
(&kb.focus_pane_down, NavigateAction::FocusPaneDown),
|
||||
(&kb.focus_pane_up, NavigateAction::FocusPaneUp),
|
||||
(&kb.focus_pane_right, NavigateAction::FocusPaneRight),
|
||||
(&kb.last_pane, NavigateAction::LastPane),
|
||||
(&kb.cycle_pane_next, NavigateAction::CyclePaneNext),
|
||||
(&kb.cycle_pane_previous, NavigateAction::CyclePanePrevious),
|
||||
(&kb.split_vertical, NavigateAction::SplitVertical),
|
||||
|
|
@ -794,6 +804,10 @@ pub(super) fn execute_navigate_action_in_context(
|
|||
state.cycle_pane(true);
|
||||
leave_navigate_mode(state);
|
||||
}
|
||||
NavigateAction::LastPane => {
|
||||
state.last_pane();
|
||||
leave_navigate_mode(state);
|
||||
}
|
||||
NavigateAction::Help => super::modal::open_keybind_help(state),
|
||||
NavigateAction::Settings => super::settings::open_settings(state),
|
||||
NavigateAction::ReloadConfig => {
|
||||
|
|
@ -1453,6 +1467,40 @@ navigate_pane_right = "ctrl+l"
|
|||
assert_eq!(action, Some(NavigateAction::FocusPaneLeft));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_direct_last_pane_shortcut_maps_to_navigation_action() {
|
||||
let mut state = state_with_workspaces(&["test"]);
|
||||
state.keybinds.last_pane = crate::config::ActionKeybinds::direct("alt+l");
|
||||
|
||||
let action = terminal_direct_navigation_action(
|
||||
&state,
|
||||
TerminalKey::new(KeyCode::Char('l'), KeyModifiers::ALT),
|
||||
);
|
||||
|
||||
assert_eq!(action, Some(NavigateAction::LastPane));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_tab_override_can_map_to_last_pane() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[keys]
|
||||
last_pane = "prefix+tab"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut state = state_with_workspaces(&["test"]);
|
||||
state.keybinds = config.keybinds();
|
||||
|
||||
let pane_action = action_for_key(
|
||||
&state,
|
||||
TerminalKey::new(KeyCode::Tab, KeyModifiers::empty()),
|
||||
BindingDispatch::Prefix,
|
||||
);
|
||||
|
||||
assert_eq!(pane_action, Some(NavigateAction::LastPane));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_direct_indexed_tab_shortcut_maps_to_navigation_action() {
|
||||
let mut state = state_with_workspaces(&["test"]);
|
||||
|
|
@ -1747,6 +1795,7 @@ navigate_pane_right = "ctrl+l"
|
|||
app.render_dirty.clone(),
|
||||
)
|
||||
.expect("workspace should spawn");
|
||||
let root_pane = workspace.tabs[0].root_pane;
|
||||
app.state.workspaces = vec![workspace];
|
||||
app.terminal_runtimes.insert(terminal.id.clone(), runtime);
|
||||
app.state.terminals.insert(terminal.id.clone(), terminal);
|
||||
|
|
@ -1774,6 +1823,19 @@ navigate_pane_right = "ctrl+l"
|
|||
assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2);
|
||||
assert_eq!(app.terminal_runtimes.len(), 2);
|
||||
assert!(app.state.workspaces[0].tabs[0].zoomed);
|
||||
let overlay_pane = app.state.workspaces[0].focused_pane_id().unwrap();
|
||||
assert_ne!(overlay_pane, root_pane);
|
||||
|
||||
app.state.last_pane();
|
||||
|
||||
assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root_pane));
|
||||
|
||||
app.state.last_pane();
|
||||
|
||||
assert_eq!(
|
||||
app.state.workspaces[0].focused_pane_id(),
|
||||
Some(overlay_pane)
|
||||
);
|
||||
|
||||
let _ = wait_for_file(&output_path);
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
||||
|
|
|
|||
|
|
@ -392,6 +392,7 @@ impl App {
|
|||
direct_attach_resize_locks: std::collections::HashSet::new(),
|
||||
workspaces,
|
||||
active,
|
||||
previous_pane_focus: None,
|
||||
selected,
|
||||
mode,
|
||||
should_quit: false,
|
||||
|
|
@ -2550,6 +2551,9 @@ mod tests {
|
|||
.cwd = split_cwd.clone();
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state
|
||||
.focus_pane_in_workspace(0, background_previous_focus);
|
||||
app.state.focus_pane_in_workspace(0, active_pane);
|
||||
|
||||
let target_pane_id = app.pane_info(0, target_pane).unwrap().pane_id;
|
||||
let target_tab_id = app.public_tab_id(0, background_tab).unwrap();
|
||||
|
|
@ -2592,6 +2596,14 @@ mod tests {
|
|||
.pane_count(),
|
||||
3
|
||||
);
|
||||
app.state.last_pane();
|
||||
assert_eq!(app.state.workspaces[0].active_tab, background_tab);
|
||||
assert_eq!(
|
||||
app.state.workspaces[0].tabs[background_tab]
|
||||
.layout
|
||||
.focused(),
|
||||
background_previous_focus
|
||||
);
|
||||
|
||||
let runtimes: Vec<_> = app.terminal_runtimes.drain().collect();
|
||||
for (_terminal_id, runtime) in runtimes {
|
||||
|
|
@ -2650,6 +2662,44 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn focused_agent_start_records_previous_pane() {
|
||||
let mut app = test_app();
|
||||
let workspace = Workspace::test_new("agent-start-focus");
|
||||
let root = workspace.tabs[0].root_pane;
|
||||
app.state.workspaces = vec![workspace];
|
||||
app.state.ensure_test_terminals();
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
|
||||
let response = app.handle_api_request(crate::api::schema::Request {
|
||||
id: "req_agent_start_focus".into(),
|
||||
method: crate::api::schema::Method::AgentStart(crate::api::schema::AgentStartParams {
|
||||
name: "worker".into(),
|
||||
cwd: None,
|
||||
workspace_id: None,
|
||||
tab_id: None,
|
||||
split: Some(crate::api::schema::SplitDirection::Right),
|
||||
focus: true,
|
||||
argv: vec!["/usr/bin/true".into()],
|
||||
}),
|
||||
});
|
||||
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
|
||||
|
||||
assert_eq!(response["result"]["type"], "agent_started");
|
||||
assert_ne!(app.state.workspaces[0].focused_pane_id(), Some(root));
|
||||
|
||||
app.state.last_pane();
|
||||
|
||||
assert_eq!(app.state.active, Some(0));
|
||||
assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root));
|
||||
|
||||
let runtimes: Vec<_> = app.terminal_runtimes.drain().collect();
|
||||
for (_terminal_id, runtime) in runtimes {
|
||||
runtime.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_close_request_closes_only_the_target_tab_when_other_tabs_exist() {
|
||||
let mut app = test_app();
|
||||
|
|
@ -2984,6 +3034,45 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_client_input_prefix_tab_dispatches_global_last_pane() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[keys]
|
||||
last_pane = "prefix+tab"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut app = test_app();
|
||||
let mut first = Workspace::test_new("one");
|
||||
let first_second_tab = first.test_add_tab(Some("logs"));
|
||||
let first_second_root = first.tabs[first_second_tab].root_pane;
|
||||
let second = Workspace::test_new("two");
|
||||
let second_root = second.tabs[0].root_pane;
|
||||
app.state.workspaces = vec![first, second];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.keybinds = config.keybinds();
|
||||
app.state.mode = Mode::Terminal;
|
||||
app.state.switch_workspace_tab(0, first_second_tab);
|
||||
app.state.switch_workspace_tab(1, 0);
|
||||
|
||||
app.route_client_input(vec![0x02, b'\t']);
|
||||
|
||||
assert_eq!(app.state.mode, Mode::Terminal);
|
||||
assert_eq!(app.state.active, Some(0));
|
||||
assert_eq!(app.state.workspaces[0].active_tab, first_second_tab);
|
||||
assert_eq!(
|
||||
app.state.workspaces[0].focused_pane_id(),
|
||||
Some(first_second_root)
|
||||
);
|
||||
|
||||
app.route_client_input(vec![0x02, b'\t']);
|
||||
|
||||
assert_eq!(app.state.active, Some(1));
|
||||
assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(second_root));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn route_client_input_double_prefix_passes_prefix_through_to_focused_pane() {
|
||||
let mut app = test_app();
|
||||
|
|
|
|||
|
|
@ -1028,6 +1028,12 @@ pub enum SidebarWidthSource {
|
|||
Manual,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct PaneFocusTarget {
|
||||
pub workspace_id: String,
|
||||
pub pane_id: PaneId,
|
||||
}
|
||||
|
||||
/// All application state — pure data, no channels or async runtime.
|
||||
/// Testable without PTYs or a tokio runtime.
|
||||
pub struct AppState {
|
||||
|
|
@ -1037,6 +1043,7 @@ pub struct AppState {
|
|||
pub direct_attach_resize_locks: std::collections::HashSet<crate::terminal::TerminalId>,
|
||||
pub workspaces: Vec<Workspace>,
|
||||
pub active: Option<usize>,
|
||||
pub(crate) previous_pane_focus: Option<PaneFocusTarget>,
|
||||
pub selected: usize,
|
||||
pub mode: Mode,
|
||||
pub should_quit: bool,
|
||||
|
|
@ -1337,6 +1344,7 @@ impl AppState {
|
|||
direct_attach_resize_locks: std::collections::HashSet::new(),
|
||||
workspaces: Vec::new(),
|
||||
active: None,
|
||||
previous_pane_focus: None,
|
||||
selected: 0,
|
||||
mode: Mode::Navigate,
|
||||
should_quit: false,
|
||||
|
|
|
|||
|
|
@ -288,6 +288,7 @@ pub struct Keybinds {
|
|||
pub focus_pane_right: ActionKeybinds,
|
||||
pub cycle_pane_next: ActionKeybinds,
|
||||
pub cycle_pane_previous: ActionKeybinds,
|
||||
pub last_pane: ActionKeybinds,
|
||||
pub split_vertical: ActionKeybinds,
|
||||
pub split_horizontal: ActionKeybinds,
|
||||
pub close_pane: ActionKeybinds,
|
||||
|
|
@ -463,6 +464,7 @@ impl Config {
|
|||
focus_pane_down: action!("keys.focus_pane_down", &self.keys.focus_pane_down),
|
||||
focus_pane_up: action!("keys.focus_pane_up", &self.keys.focus_pane_up),
|
||||
focus_pane_right: action!("keys.focus_pane_right", &self.keys.focus_pane_right),
|
||||
last_pane: action!("keys.last_pane", &self.keys.last_pane),
|
||||
cycle_pane_next: action!("keys.cycle_pane_next", &self.keys.cycle_pane_next),
|
||||
cycle_pane_previous: action!(
|
||||
"keys.cycle_pane_previous",
|
||||
|
|
@ -1264,6 +1266,12 @@ next_tab = "prefix+n"
|
|||
assert!(kb.remove_worktree.bindings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn back_and_forth_keybinds_are_unset_by_default() {
|
||||
let kb = Config::default().keybinds();
|
||||
assert!(kb.last_pane.bindings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_bindings_allow_prefix_and_modified_direct() {
|
||||
let config: Config = toml::from_str(
|
||||
|
|
|
|||
|
|
@ -211,6 +211,8 @@ pub struct KeysConfig {
|
|||
pub cycle_pane_next: BindingConfig,
|
||||
/// Cycle to the previous pane. Default: "prefix+shift+tab".
|
||||
pub cycle_pane_previous: BindingConfig,
|
||||
/// Focus the last focused pane across workspaces and tabs. Unset by default.
|
||||
pub last_pane: BindingConfig,
|
||||
/// Split pane vertically (side by side). Default: "prefix+v"
|
||||
pub split_vertical: BindingConfig,
|
||||
/// Split pane horizontally (stacked). Default: "prefix+minus"
|
||||
|
|
@ -391,6 +393,7 @@ impl Default for KeysConfig {
|
|||
focus_pane_right: BindingConfig::one("prefix+l"),
|
||||
cycle_pane_next: BindingConfig::one("prefix+tab"),
|
||||
cycle_pane_previous: BindingConfig::one("prefix+shift+tab"),
|
||||
last_pane: BindingConfig::empty(),
|
||||
split_vertical: BindingConfig::one("prefix+v"),
|
||||
split_horizontal: BindingConfig::one("prefix+minus"),
|
||||
close_pane: BindingConfig::one("prefix+x"),
|
||||
|
|
|
|||
|
|
@ -147,20 +147,6 @@ impl TileLayout {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn focus_next(&mut self) {
|
||||
let ids = self.pane_ids();
|
||||
if let Some(pos) = ids.iter().position(|id| *id == self.focus) {
|
||||
self.focus = ids[(pos + 1) % ids.len()];
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focus_prev(&mut self) {
|
||||
let ids = self.pane_ids();
|
||||
if let Some(pos) = ids.iter().position(|id| *id == self.focus) {
|
||||
self.focus = ids[(pos + ids.len() - 1) % ids.len()];
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focus_pane(&mut self, id: PaneId) {
|
||||
if self.pane_ids().contains(&id) {
|
||||
self.focus = id;
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
|
|||
# focus_pane_right = "prefix+l"
|
||||
# cycle_pane_next = "prefix+tab"
|
||||
# cycle_pane_previous = "prefix+shift+tab"
|
||||
# last_pane = "" # optional, unset by default; bind e.g. "prefix+tab" for global back-and-forth
|
||||
# split_vertical = "prefix+v"
|
||||
# split_horizontal = "prefix+minus"
|
||||
# close_pane = "prefix+x"
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ pub(super) fn keybind_help_groups(
|
|||
keybind_label(&kb.cycle_pane_previous),
|
||||
"cycle pane previous",
|
||||
),
|
||||
(keybind_label(&kb.last_pane), "last pane"),
|
||||
];
|
||||
groups.push(("panes", panes));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue