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
//! Active-buffer / active-split focus management on `Editor`.
//!
//! `set_active_buffer` and `focus_split` are the centralized methods for
//! switching what the user is looking at. Both fire several invariants:
//! split manager updates, tab list updates, file-explorer sync, terminal
//! buffer resume, semantic-token cleanup for deleted buffers, etc.
//!
//! ## Pane-buffer invariant
//!
//! "Which buffer is displayed in leaf split S" is stored in two places
//! for historical reasons: `split_manager`'s tree (as the leaf node's
//! `buffer_id`) and `split_view_states[S]` (as `active_buffer` plus an
//! entry in `keyed_states`). These must agree — callers downstream
//! (notably `apply_event_to_active_buffer`) index one using the other
//! without re-validating.
//!
//! All writes to this fact MUST go through [`Editor::set_pane_buffer`]
//! (or the higher-level wrappers `set_active_buffer` / `focus_split`
//! that call it). Raw `split_manager.set_split_buffer` /
//! `split_manager.set_active_buffer_id` calls updated only one side,
//! which caused issue #1620 (a `None.unwrap()` panic when clicking
//! after a buffer was closed from another split).
use super::*;
impl Editor {
/// Atomically update both sides of the pane-buffer invariant for a
/// given leaf split: the split tree's stored buffer AND the matching
/// `SplitViewState.active_buffer` / `keyed_states` map.
///
/// This is the one place that is allowed to change "which buffer is
/// shown in pane `leaf`". Every call site that used to poke
/// `split_manager.set_split_buffer` or
/// `split_manager.set_active_buffer_id` directly should go through
/// here instead, so the two stores can never drift (see the
/// module-level note and issue #1620).
///
/// If the leaf has no `SplitViewState` yet (e.g. mid-session-restore,
/// when the SVS is registered later), the tree is still updated and
/// the SVS sync is skipped — the caller is responsible for ensuring
/// the SVS exists by the time any input is routed.
pub(super) fn set_pane_buffer(&mut self, leaf: LeafId, buffer_id: BufferId) {
self.split_manager.set_split_buffer(leaf, buffer_id);
if let Some(view_state) = self.split_view_states.get_mut(&leaf) {
view_state.switch_buffer(buffer_id);
}
}
/// Set the active buffer and trigger all necessary side effects
///
/// This is the centralized method for switching buffers. It:
/// - Updates split manager (single source of truth for active buffer)
/// - Adds buffer to active split's tabs (if not already there)
/// - Syncs file explorer to the new active file (if visible)
///
/// Use this instead of directly calling split_manager.set_active_buffer_id()
/// to ensure all side effects happen consistently.
pub(super) fn set_active_buffer(&mut self, buffer_id: BufferId) {
if self.active_buffer() == buffer_id {
return; // No change
}
// Dismiss transient popups and clear hover state when switching buffers
self.on_editor_focus_lost();
// Cancel search/replace prompts when switching buffers
// (they are buffer-specific and don't make sense across buffers)
self.cancel_search_prompt_if_active();
// Track the previous buffer for "Switch to Previous Tab" command
let previous = self.active_buffer();
// If leaving a terminal buffer while in terminal mode, remember it should resume
if self.terminal_mode && self.is_terminal_buffer(previous) {
self.terminal_mode_resume.insert(previous);
self.terminal_mode = false;
self.key_context = crate::input::keybindings::KeyContext::Normal;
}
// Capture the previous focus target BEFORE set_pane_buffer runs,
// so the LRU records the right thing.
let active_split = self.split_manager.active_split();
let previous_target = self
.split_view_states
.get(&active_split)
.map(|vs| vs.active_target());
// Atomic pane-buffer update: tree + SVS in lockstep.
self.set_pane_buffer(active_split, buffer_id);
if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
view_state.add_buffer(buffer_id);
view_state.active_group_tab = None;
view_state.focused_group_leaf = None;
if let Some(previous_target) = previous_target {
view_state.push_focus(previous_target);
}
}
// If switching to a terminal buffer that should resume terminal mode, re-enter it
if self.terminal_mode_resume.contains(&buffer_id) && self.is_terminal_buffer(buffer_id) {
self.terminal_mode = true;
self.key_context = crate::input::keybindings::KeyContext::Terminal;
} else if self.is_terminal_buffer(buffer_id) {
// Switching to terminal in read-only mode - sync buffer to show current terminal content
// This ensures the backing file content and cursor position are up to date
self.sync_terminal_to_buffer(buffer_id);
}
// Ensure the newly active tab is visible
self.ensure_active_tab_visible(active_split, buffer_id, self.effective_tabs_width());
// Note: We don't sync file explorer here to avoid flicker during tab switches.
// File explorer syncs when explicitly focused via focus_file_explorer().
// Update plugin state snapshot BEFORE firing the hook so that
// the handler sees the new active buffer, not the old one.
#[cfg(feature = "plugins")]
self.update_plugin_state_snapshot();
// Emit buffer_activated hook for plugins
self.plugin_manager.run_hook(
"buffer_activated",
crate::services::plugins::hooks::HookArgs::BufferActivated { buffer_id },
);
}
/// Focus a split and its buffer, handling all side effects including terminal mode.
///
/// This is the primary method for switching focus between splits via mouse clicks.
/// It handles:
/// - Exiting terminal mode when leaving a terminal buffer
/// - Updating split manager state
/// - Managing tab state and previous buffer tracking
/// - Syncing file explorer
///
/// Use this instead of calling set_active_split directly when switching focus.
pub(super) fn focus_split(&mut self, split_id: LeafId, buffer_id: BufferId) {
// Fixed buffer-group panels (toolbars, headers, footers) aren't focus
// targets: focusing them would route keyboard input at an invisible
// cursor. Plugins can still detect clicks via the mouse_click hook,
// which fires in the click handlers before reaching here. Scrollable
// panels still receive focus even with a hidden cursor.
if self.is_non_scrollable_buffer(buffer_id) {
return;
}
// Clicking a buffer pane (e.g. a tab) explicitly moves focus to
// the editor. If the key context was still on the file explorer
// (because the user's previous click landed there), reset it so
// subsequent keystrokes target the buffer. The terminal branch
// below can still upgrade to KeyContext::Terminal when needed.
// Issue #1540.
if self.key_context == crate::input::keybindings::KeyContext::FileExplorer {
self.key_context = crate::input::keybindings::KeyContext::Normal;
}
let previous_split = self.split_manager.active_split();
let previous_buffer = self.active_buffer(); // Get BEFORE changing split
let split_changed = previous_split != split_id;
// Preview is anchored to the split it was opened in. Moving focus to
// a different split commits the preview — walking away is commitment.
if split_changed {
self.promote_preview_if_not_in_split(split_id);
}
// If `split_id` is not in the main split tree, it must be an inner
// leaf of a Grouped subtree stashed in `grouped_subtrees`. For those
// we don't change `split_manager.active_split` (the group's host
// split remains active). Instead, find the host split and update
// its `focused_group_leaf` marker so `active_buffer()` routes to
// the clicked inner panel buffer.
if !self
.split_manager
.root()
.leaf_split_ids()
.contains(&split_id)
{
// Find which group contains this inner leaf.
let host_split = self
.grouped_subtrees
.iter()
.find(|(_, node)| {
if let crate::view::split::SplitNode::Grouped { layout, .. } = node {
layout.find(split_id.into()).is_some()
} else {
false
}
})
.map(|(group_leaf_id, _)| *group_leaf_id)
.and_then(|group_leaf_id| {
// Find the split whose open_buffers has this group tab.
self.split_view_states
.iter()
.find(|(_, vs)| vs.has_group(group_leaf_id))
.map(|(sid, _)| (*sid, group_leaf_id))
});
if let Some((host, group_leaf_id)) = host_split {
self.split_manager.set_active_split(host);
if let Some(vs) = self.split_view_states.get_mut(&host) {
vs.active_group_tab = Some(group_leaf_id);
vs.focused_group_leaf = Some(split_id);
}
if let Some(inner_vs) = self.split_view_states.get_mut(&split_id) {
inner_vs.switch_buffer(buffer_id);
}
self.key_context = crate::input::keybindings::KeyContext::Normal;
return;
}
// Fall through: we couldn't find the group; the original path
// will set_active_split which will fail silently.
}
if split_changed {
// Switching to a different split - exit terminal mode if active
if self.terminal_mode && self.is_terminal_buffer(previous_buffer) {
self.terminal_mode = false;
self.key_context = crate::input::keybindings::KeyContext::Normal;
}
// Update split manager to focus this split
self.split_manager.set_active_split(split_id);
// Atomic pane-buffer update: tree + SVS in lockstep. Replaces
// the previous pair of split_manager.set_active_buffer_id +
// view_state.switch_buffer that could desync if either leg
// silently no-op'd (issue #1620).
self.set_pane_buffer(split_id, buffer_id);
// Set key context based on target buffer type
if self.is_terminal_buffer(buffer_id) {
self.terminal_mode = true;
self.key_context = crate::input::keybindings::KeyContext::Terminal;
} else {
// Ensure key context is Normal when focusing a non-terminal buffer
// This handles the case of clicking on editor from FileExplorer context
self.key_context = crate::input::keybindings::KeyContext::Normal;
}
// Handle buffer change side effects
if previous_buffer != buffer_id {
self.position_history.commit_pending_movement();
if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
view_state.add_buffer(buffer_id);
view_state.push_focus(crate::view::split::TabTarget::Buffer(previous_buffer));
}
// Note: We don't sync file explorer here to avoid flicker during split focus changes.
// File explorer syncs when explicitly focused via focus_file_explorer().
}
} else {
// Same split, different buffer (tab switch) - use set_active_buffer for terminal resume
self.set_active_buffer(buffer_id);
}
}
}