Skip to main content

fresh/app/
buffer_groups.rs

1//! Buffer group management.
2//!
3//! A buffer group presents multiple splits/buffers as a single tab.
4//! Each panel is a real buffer with its own viewport and scrollbar.
5//! The group appears as one entry in the tab bar and buffer list.
6
7use crate::app::types::BufferGroupId;
8#[cfg(feature = "plugins")]
9use crate::app::types::{BufferGroup, GroupLayoutNode};
10#[cfg(feature = "plugins")]
11use crate::model::event::SplitDirection;
12use crate::model::event::{BufferId, LeafId};
13#[cfg(feature = "plugins")]
14use crate::view::split::SplitViewState;
15#[cfg(feature = "plugins")]
16use fresh_core::api::BufferGroupResult;
17#[cfg(feature = "plugins")]
18use std::collections::HashMap;
19
20/// Layout description deserialized from plugin JSON.
21#[cfg(feature = "plugins")]
22#[derive(Debug, serde::Deserialize)]
23#[serde(tag = "type")]
24enum LayoutDesc {
25    #[serde(rename = "scrollable")]
26    Scrollable {
27        id: String,
28        /// Whether this panel responds to scroll events. Defaults to true
29        /// for scrollable panels.
30        scrollable: Option<bool>,
31    },
32    #[serde(rename = "fixed")]
33    Fixed {
34        id: String,
35        height: u16,
36        /// Whether this panel responds to scroll events. Defaults to false
37        /// for fixed-height panels — their content is pinned to the panel
38        /// size, so mouse-wheel scroll is a no-op and no scrollbar is drawn.
39        /// Callers can override by passing `"scrollable": true`.
40        scrollable: Option<bool>,
41    },
42    #[serde(rename = "split")]
43    Split {
44        direction: String, // "h" or "v"
45        ratio: f32,
46        first: Box<LayoutDesc>,
47        second: Box<LayoutDesc>,
48    },
49}
50
51impl super::Editor {
52    /// Create a buffer group from a layout description.
53    ///
54    /// Builds a `SplitNode::Grouped` wrapping the panel layout and stores
55    /// it in `grouped_subtrees`, then adds a `TabTarget::Group(group_leaf_id)`
56    /// entry to the current split's tab bar. The main split tree is NOT
57    /// modified — the group's subtree is dispatched to at render time when
58    /// the current split's active target is this group.
59    #[cfg(feature = "plugins")]
60    pub(super) fn create_buffer_group(
61        &mut self,
62        name: String,
63        mode: String,
64        layout_json: String,
65    ) -> Result<BufferGroupResult, String> {
66        use crate::view::split::{SplitNode, TabTarget};
67
68        // Parse layout
69        let desc: LayoutDesc =
70            serde_json::from_str(&layout_json).map_err(|e| format!("Invalid layout: {}", e))?;
71
72        // Allocate group ID
73        let group_id = BufferGroupId(self.active_window_mut().next_buffer_group_id);
74        self.active_window_mut().next_buffer_group_id += 1;
75
76        // Build buffers for each leaf in the layout
77        let mut panel_buffers: HashMap<String, BufferId> = HashMap::new();
78        let mut panel_splits: HashMap<String, LeafId> = HashMap::new();
79        let layout = self.build_group_layout(&desc, &mode, &mut panel_buffers)?;
80
81        // Build the inner split tree for the group
82        let inner_tree = self.build_split_tree(&layout, &mut panel_splits)?;
83
84        // Determine the active inner leaf (first scrollable panel, fallback to any leaf)
85        let active_inner_leaf = find_first_scrollable_leaf(&layout, &panel_splits)
86            .or_else(|| panel_splits.values().next().copied())
87            .ok_or("No panels in layout")?;
88
89        // Allocate a LeafId for the Grouped node itself. This is what the
90        // tab bar uses to reference this group (`TabTarget::Group(group_leaf_id)`).
91        let group_leaf_id = LeafId(
92            self.windows
93                .get_mut(&self.active_window)
94                .and_then(|w| w.split_manager_mut())
95                .expect("active window must have a populated split layout")
96                .allocate_split_id(),
97        );
98
99        // Build the Grouped SplitNode and stash it in the side map.
100        let grouped_node = SplitNode::Grouped {
101            split_id: group_leaf_id,
102            name: name.clone(),
103            layout: Box::new(inner_tree),
104            active_inner_leaf,
105        };
106        self.active_window_mut()
107            .grouped_subtrees
108            .insert(group_leaf_id, grouped_node);
109
110        // Create SplitViewState for each inner panel leaf
111        let (tw, th) = (self.terminal_width, self.terminal_height);
112        for (panel_name, leaf_id) in &panel_splits {
113            let buffer_id = *panel_buffers
114                .get(panel_name)
115                .ok_or(format!("Panel '{}' has no buffer", panel_name))?;
116            let mut vs = SplitViewState::with_buffer(tw, th, buffer_id);
117            // All panels inside a group suppress chrome — the parent split's
118            // tab bar is the only tab bar shown.
119            vs.suppress_chrome = true;
120            vs.hide_tilde = true;
121            if let Some(bs) = vs.keyed_states.get_mut(&buffer_id) {
122                bs.show_line_numbers = false;
123                bs.highlight_current_line = false;
124            }
125            self.windows
126                .get_mut(&self.active_window)
127                .and_then(|w| w.split_view_states_mut())
128                .expect("active window must have a populated split layout")
129                .insert(*leaf_id, vs);
130        }
131
132        // Mark all panel buffers as hidden from tabs so they don't appear
133        // in quick-switch or the buffer list.
134        for buffer_id in panel_buffers.values() {
135            if let Some(meta) = self.active_window_mut().buffer_metadata.get_mut(buffer_id) {
136                meta.hidden_from_tabs = true;
137            }
138        }
139
140        // Remove panel buffers from every OTHER split's open_buffers AND
141        // keyed_states. create_virtual_buffer adds them to the active split
142        // when each was created; leaving them there makes the outer split
143        // carry a stale cursor entry for the panel buffer, which later
144        // collides with the panel's own view state in any lookup that
145        // scans split_view_states by buffer id.
146        let hidden_panel_ids: Vec<BufferId> = panel_buffers.values().copied().collect();
147        let panel_leaf_ids: std::collections::HashSet<LeafId> =
148            panel_splits.values().copied().collect();
149        for (leaf_id, vs) in self
150            .windows
151            .get_mut(&self.active_window)
152            .and_then(|w| w.split_view_states_mut())
153            .expect("active window must have a populated split layout")
154            .iter_mut()
155        {
156            if panel_leaf_ids.contains(leaf_id) {
157                // The panel's own view state needs its buffer.
158                continue;
159            }
160            vs.open_buffers.retain(|t| match t {
161                TabTarget::Buffer(b) => !hidden_panel_ids.contains(b),
162                TabTarget::Group(_) => true,
163            });
164            vs.keyed_states
165                .retain(|bid, _| !hidden_panel_ids.contains(bid));
166        }
167
168        // Add the group as a tab in the CURRENT split's tab bar and make it
169        // the active tab. (The main split tree is untouched — the group's
170        // layout lives in `grouped_subtrees` and is dispatched at render time.)
171        let current_split_id = self
172            .windows
173            .get(&self.active_window)
174            .and_then(|w| w.buffers.splits())
175            .map(|(mgr, _)| mgr)
176            .expect("active window must have a populated split layout")
177            .active_split();
178        if let Some(current_vs) = self
179            .windows
180            .get_mut(&self.active_window)
181            .and_then(|w| w.split_view_states_mut())
182            .expect("active window must have a populated split layout")
183            .get_mut(&current_split_id)
184        {
185            current_vs.add_group(group_leaf_id);
186            current_vs.set_active_group_tab(group_leaf_id);
187            current_vs.focused_group_leaf = Some(active_inner_leaf);
188        }
189
190        // Register the group metadata
191        let group = BufferGroup {
192            id: group_id,
193            name: name.clone(),
194            mode,
195            layout,
196            panel_buffers: panel_buffers.clone(),
197            panel_splits,
198            representative_split: Some(group_leaf_id),
199        };
200
201        // Register reverse mapping
202        for buffer_id in panel_buffers.values() {
203            self.active_window_mut()
204                .buffer_to_group
205                .insert(*buffer_id, group_id);
206        }
207
208        self.active_window_mut()
209            .buffer_groups
210            .insert(group_id, group);
211
212        // Build result
213        let panels: HashMap<String, u64> = panel_buffers
214            .iter()
215            .map(|(name, bid)| (name.clone(), bid.0 as u64))
216            .collect();
217
218        Ok(BufferGroupResult {
219            group_id: group_id.0 as u64,
220            panels,
221        })
222    }
223
224    /// Build a SplitNode tree directly from a GroupLayoutNode.
225    /// Populates panel_splits with leaf_id for each panel.
226    #[cfg(feature = "plugins")]
227    fn build_split_tree(
228        &mut self,
229        node: &GroupLayoutNode,
230        panel_splits: &mut HashMap<String, crate::model::event::LeafId>,
231    ) -> Result<crate::view::split::SplitNode, String> {
232        use crate::model::event::LeafId;
233        use crate::view::split::SplitNode;
234
235        match node {
236            GroupLayoutNode::Scrollable {
237                id,
238                buffer_id: Some(bid),
239                ..
240            }
241            | GroupLayoutNode::Fixed {
242                id,
243                buffer_id: Some(bid),
244                ..
245            } => {
246                let split_id = self
247                    .windows
248                    .get_mut(&self.active_window)
249                    .and_then(|w| w.split_manager_mut())
250                    .expect("active window must have a populated split layout")
251                    .allocate_split_id();
252                panel_splits.insert(id.clone(), LeafId(split_id));
253                Ok(SplitNode::leaf(*bid, split_id))
254            }
255            GroupLayoutNode::Scrollable {
256                buffer_id: None, ..
257            }
258            | GroupLayoutNode::Fixed {
259                buffer_id: None, ..
260            } => Err("Layout leaf has no buffer_id".to_string()),
261            GroupLayoutNode::Split {
262                direction,
263                ratio,
264                first,
265                second,
266            } => {
267                let first_node = self.build_split_tree(first, panel_splits)?;
268                let second_node = self.build_split_tree(second, panel_splits)?;
269                let split_id = self
270                    .windows
271                    .get_mut(&self.active_window)
272                    .and_then(|w| w.split_manager_mut())
273                    .expect("active window must have a populated split layout")
274                    .allocate_split_id();
275                let mut split =
276                    SplitNode::split(*direction, first_node, second_node, *ratio, split_id);
277                // Apply fixed sizes from children
278                let fixed_first_size = fixed_height_of(first);
279                let fixed_second_size = fixed_height_of(second);
280                if let SplitNode::Split {
281                    fixed_first,
282                    fixed_second,
283                    ..
284                } = &mut split
285                {
286                    *fixed_first = fixed_first_size;
287                    *fixed_second = fixed_second_size;
288                }
289                Ok(split)
290            }
291        }
292    }
293
294    /// Build a GroupLayoutNode from a LayoutDesc, creating buffers for each leaf.
295    #[cfg(feature = "plugins")]
296    fn build_group_layout(
297        &mut self,
298        desc: &LayoutDesc,
299        mode: &str,
300        panel_buffers: &mut HashMap<String, BufferId>,
301    ) -> Result<GroupLayoutNode, String> {
302        match desc {
303            LayoutDesc::Scrollable { id, scrollable } => {
304                let scrollable = scrollable.unwrap_or(true);
305                let buffer_id = self.active_window_mut().create_virtual_buffer(
306                    format!("*{}*", id),
307                    mode.to_string(),
308                    true,
309                );
310                if let Some(state) = self
311                    .windows
312                    .get_mut(&self.active_window)
313                    .map(|w| &mut w.buffers)
314                    .expect("active window present")
315                    .get_mut(&buffer_id)
316                {
317                    state.show_cursors = false;
318                    state.editing_disabled = true;
319                    state.scrollable = scrollable;
320                    state.margins.configure_for_line_numbers(false);
321                }
322                panel_buffers.insert(id.clone(), buffer_id);
323                Ok(GroupLayoutNode::Scrollable {
324                    id: id.clone(),
325                    buffer_id: Some(buffer_id),
326                    split_id: None,
327                })
328            }
329            LayoutDesc::Fixed {
330                id,
331                height,
332                scrollable,
333            } => {
334                let scrollable = scrollable.unwrap_or(false);
335                let buffer_id = self.active_window_mut().create_virtual_buffer(
336                    format!("*{}*", id),
337                    mode.to_string(),
338                    true,
339                );
340                if let Some(state) = self
341                    .windows
342                    .get_mut(&self.active_window)
343                    .map(|w| &mut w.buffers)
344                    .expect("active window present")
345                    .get_mut(&buffer_id)
346                {
347                    state.show_cursors = false;
348                    state.editing_disabled = true;
349                    state.scrollable = scrollable;
350                    state.margins.configure_for_line_numbers(false);
351                }
352                panel_buffers.insert(id.clone(), buffer_id);
353                Ok(GroupLayoutNode::Fixed {
354                    id: id.clone(),
355                    height: *height,
356                    buffer_id: Some(buffer_id),
357                    split_id: None,
358                })
359            }
360            LayoutDesc::Split {
361                direction,
362                ratio,
363                first,
364                second,
365            } => {
366                let dir = if direction == "h" {
367                    SplitDirection::Vertical // "h" = horizontal layout = vertical split line
368                } else {
369                    SplitDirection::Horizontal
370                };
371                let first_node = self.build_group_layout(first, mode, panel_buffers)?;
372                let second_node = self.build_group_layout(second, mode, panel_buffers)?;
373                Ok(GroupLayoutNode::Split {
374                    direction: dir,
375                    ratio: *ratio,
376                    first: Box::new(first_node),
377                    second: Box::new(second_node),
378                })
379            }
380        }
381    }
382
383    /// Set content on a panel within a buffer group.
384    #[cfg(feature = "plugins")]
385    pub(super) fn set_panel_content(
386        &mut self,
387        group_id: usize,
388        panel_name: String,
389        entries: Vec<fresh_core::text_property::TextPropertyEntry>,
390    ) {
391        let bg_id = BufferGroupId(group_id);
392        let buffer_id = self
393            .active_window_mut()
394            .buffer_groups
395            .get(&bg_id)
396            .and_then(|g| g.panel_buffers.get(&panel_name).copied());
397
398        if let Some(buffer_id) = buffer_id {
399            if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
400                tracing::error!("Failed to set panel '{}' content: {}", panel_name, e);
401            }
402        } else {
403            tracing::warn!("Panel '{}' not found in group {}", panel_name, group_id);
404        }
405    }
406
407    /// Close a buffer group — remove the Grouped subtree, close all panel
408    /// buffers, and remove the group tab from any split's tab bar.
409    pub(super) fn close_buffer_group(&mut self, group_id: usize) {
410        use crate::view::split::TabTarget;
411        let bg_id = BufferGroupId(group_id);
412        if let Some(group) = self.active_window_mut().buffer_groups.remove(&bg_id) {
413            // Remove reverse mappings
414            for buffer_id in group.panel_buffers.values() {
415                self.active_window_mut().buffer_to_group.remove(buffer_id);
416            }
417
418            // Find the group_leaf_id (it's the `representative_split` now).
419            if let Some(group_leaf_id) = group.representative_split {
420                // Remove the Grouped subtree from the side map
421                self.active_window_mut()
422                    .grouped_subtrees
423                    .remove(&group_leaf_id);
424                // Remove the group tab from all splits' tab bars and clear
425                // any active/focused group markers that point at this group.
426                for vs in self
427                    .windows
428                    .get_mut(&self.active_window)
429                    .and_then(|w| w.split_view_states_mut())
430                    .expect("active window must have a populated split layout")
431                    .values_mut()
432                {
433                    vs.open_buffers
434                        .retain(|t| *t != TabTarget::Group(group_leaf_id));
435                    vs.remove_group_from_history(group_leaf_id);
436                    if vs.active_group_tab == Some(group_leaf_id) {
437                        vs.active_group_tab = None;
438                    }
439                    if let Some(focused) = vs.focused_group_leaf {
440                        if group.panel_splits.values().any(|&l| l == focused) {
441                            vs.focused_group_leaf = None;
442                        }
443                    }
444                }
445            }
446
447            // Clean up SplitViewState for inner panel leaves
448            for split_id in group.panel_splits.values() {
449                self.windows
450                    .get_mut(&self.active_window)
451                    .and_then(|w| w.split_view_states_mut())
452                    .expect("active window must have a populated split layout")
453                    .remove(split_id);
454            }
455
456            // Close all panel buffers
457            for buffer_id in group.panel_buffers.values() {
458                if let Err(e) = self.close_buffer(*buffer_id) {
459                    tracing::warn!("Failed to close panel buffer {:?}: {}", buffer_id, e);
460                }
461            }
462
463            // Ensure the active split now has a valid active_target.
464            // If it was the group's tab, switch to the first available buffer tab.
465            let active_split = self
466                .windows
467                .get(&self.active_window)
468                .and_then(|w| w.buffers.splits())
469                .map(|(mgr, _)| mgr)
470                .expect("active window must have a populated split layout")
471                .active_split();
472            if let Some(vs) = self
473                .windows
474                .get(&self.active_window)
475                .and_then(|w| w.buffers.splits())
476                .map(|(_, vs)| vs)
477                .expect("active window must have a populated split layout")
478                .get(&active_split)
479            {
480                if let Some(first_buf) = vs.buffer_tab_ids().next() {
481                    let _ = first_buf; // active_buffer is per-leaf; already set
482                }
483            }
484        }
485    }
486
487    /// Focus a specific panel in a buffer group.
488    ///
489    /// If the panel's inner leaf is not in the main split tree (side-map
490    /// approach), this activates the group tab on whichever split hosts it
491    /// and marks the panel's leaf as the focused inner leaf.
492    #[cfg(feature = "plugins")]
493    pub(super) fn focus_panel(&mut self, group_id: usize, panel_name: String) {
494        let bg_id = BufferGroupId(group_id);
495        let (group_leaf_id, inner_leaf) = match self.active_window_mut().buffer_groups.get(&bg_id) {
496            Some(group) => {
497                let Some(&inner) = group.panel_splits.get(&panel_name) else {
498                    return;
499                };
500                let Some(leaf) = group.representative_split else {
501                    return;
502                };
503                (leaf, inner)
504            }
505            None => return,
506        };
507
508        // Find the host split whose open_buffers contains this group tab.
509        let host_split = self
510            .windows
511            .get(&self.active_window)
512            .and_then(|w| w.buffers.splits())
513            .map(|(_, vs)| vs)
514            .expect("active window must have a populated split layout")
515            .iter()
516            .find(|(_, vs)| vs.has_group(group_leaf_id))
517            .map(|(sid, _)| *sid);
518
519        if let Some(host_split) = host_split {
520            // Ensure the host split is the active one.
521            self.windows
522                .get_mut(&self.active_window)
523                .and_then(|w| w.split_manager_mut())
524                .expect("active window must have a populated split layout")
525                .set_active_split(host_split);
526            if let Some(vs) = self
527                .windows
528                .get_mut(&self.active_window)
529                .and_then(|w| w.split_view_states_mut())
530                .expect("active window must have a populated split layout")
531                .get_mut(&host_split)
532            {
533                vs.active_group_tab = Some(group_leaf_id);
534                vs.focused_group_leaf = Some(inner_leaf);
535            }
536            // Persist the choice on the SplitNode so a tab-away/back round
537            // trip restores the same panel — `activate_group_tab` reads
538            // this field when re-focusing the group.
539            if let Some(crate::view::split::SplitNode::Grouped {
540                active_inner_leaf, ..
541            }) = self
542                .active_window_mut()
543                .grouped_subtrees
544                .get_mut(&group_leaf_id)
545            {
546                *active_inner_leaf = inner_leaf;
547            }
548            // Transfer focus away from File Explorer (or any other context)
549            // to the editor, since we're explicitly focusing a panel.
550            self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Normal;
551        }
552    }
553
554    /// Re-point a buffer group's panel at a different buffer id.
555    ///
556    /// Updates two places: `group.panel_buffers[panel_name]` (the
557    /// authoritative name → buffer mapping for the group) and the
558    /// panel split's `SplitViewState.active_buffer` (which buffer the
559    /// panel actually renders). Marks the split's layout dirty so the
560    /// next render sees the swap.
561    ///
562    /// Designed for streaming plugins that allocate one file-backed
563    /// buffer per item and re-target the panel on navigation, instead
564    /// of mutating a single shared buffer's contents.
565    ///
566    /// Returns `true` on success, `false` if the group, panel, or
567    /// buffer was missing.
568    #[cfg(feature = "plugins")]
569    pub(super) fn set_buffer_group_panel_buffer(
570        &mut self,
571        group_id: usize,
572        panel_name: String,
573        new_buffer_id: BufferId,
574    ) -> bool {
575        let bg_id = BufferGroupId(group_id);
576
577        // Validate the buffer exists before touching anything.
578        let buffer_exists = self
579            .windows
580            .get(&self.active_window)
581            .map(|w| &w.buffers)
582            .map(|b| b.get(&new_buffer_id).is_some())
583            .unwrap_or(false);
584        if !buffer_exists {
585            tracing::warn!(
586                "setBufferGroupPanelBuffer: buffer {:?} not found",
587                new_buffer_id
588            );
589            return false;
590        }
591
592        // Look up the panel's inner leaf id, and the prior buffer id
593        // we're replacing.
594        let (panel_leaf, prior_buffer_id) =
595            match self.active_window_mut().buffer_groups.get_mut(&bg_id) {
596                Some(group) => {
597                    let Some(&leaf) = group.panel_splits.get(&panel_name) else {
598                        tracing::warn!(
599                            "setBufferGroupPanelBuffer: panel '{}' missing in group {}",
600                            panel_name,
601                            group_id
602                        );
603                        return false;
604                    };
605                    let prior = group
606                        .panel_buffers
607                        .insert(panel_name.clone(), new_buffer_id);
608                    (leaf, prior)
609                }
610                None => {
611                    tracing::warn!("setBufferGroupPanelBuffer: group {} not found", group_id);
612                    return false;
613                }
614            };
615
616        // Maintain the reverse mapping `buffer_to_group` so the new
617        // buffer is recognised as part of this group everywhere mode
618        // resolution / close handling looks it up. The prior panel
619        // buffer is only de-registered if it isn't still pointed at
620        // by some other panel in the same group (rare, but possible
621        // with custom layouts).
622        if let Some(prior) = prior_buffer_id {
623            let still_panel = self
624                .active_window()
625                .buffer_groups
626                .get(&bg_id)
627                .map(|g| g.panel_buffers.values().any(|b| *b == prior))
628                .unwrap_or(false);
629            if !still_panel {
630                self.active_window_mut().buffer_to_group.remove(&prior);
631            }
632        }
633        self.active_window_mut()
634            .buffer_to_group
635            .insert(new_buffer_id, bg_id);
636
637        // The buffer needs the same per-buffer presentation flags
638        // that `build_group_layout` applies to virtual panel buffers
639        // (scrollable, no line-number margins, editing disabled).
640        // Without these, a freshly-attached file-backed buffer
641        // renders with the wrong margins/wrap and overflows the
642        // panel's allotted width.
643        if let Some(state) = self
644            .windows
645            .get_mut(&self.active_window)
646            .map(|w| &mut w.buffers)
647            .expect("active window present")
648            .get_mut(&new_buffer_id)
649        {
650            state.scrollable = true;
651            state.editing_disabled = true;
652            state.margins.configure_for_line_numbers(false);
653        }
654
655        // Walk the grouped subtree and update the SplitNode::Leaf's
656        // `buffer_id`. The renderer reads this — not the
657        // SplitViewState — when collecting which buffer to draw in
658        // each panel rect (see `get_leaves_with_rects`). Without
659        // this, retargeting only updates focus state and the panel
660        // keeps drawing the prior (now-empty) buffer.
661        for node in self.active_window_mut().grouped_subtrees.values_mut() {
662            if let Some(found) = node.find_mut(panel_leaf.into()) {
663                if let crate::view::split::SplitNode::Leaf { buffer_id, .. } = found {
664                    *buffer_id = new_buffer_id;
665                    break;
666                }
667            }
668        }
669
670        // Update the panel split's view state: ensure a per-buffer
671        // state entry for the new id BEFORE swapping active_buffer
672        // (otherwise the next `active_state()` panics because the
673        // freshly-set active_buffer has no keyed_states entry yet).
674        let line_wrap = self
675            .active_window()
676            .resolve_line_wrap_for_buffer(new_buffer_id);
677        let wrap_column = self
678            .active_window()
679            .resolve_wrap_column_for_buffer(new_buffer_id);
680        let cfg = self.config.editor.clone();
681        if let Some(vs) = self
682            .windows
683            .get_mut(&self.active_window)
684            .and_then(|w| w.split_view_states_mut())
685            .expect("active window must have a populated split layout")
686            .get_mut(&panel_leaf)
687        {
688            // 1) Allocate the keyed state for the new buffer first.
689            //    This call internally reads `active_state()` to copy
690            //    viewport dims; calling it while active_buffer is
691            //    still the prior id is safe.
692            {
693                let buf_state = vs.ensure_buffer_state(new_buffer_id);
694                buf_state.apply_config_defaults(
695                    cfg.line_numbers,
696                    cfg.highlight_current_line,
697                    line_wrap,
698                    cfg.wrap_indent,
699                    wrap_column,
700                    cfg.rulers,
701                    cfg.scroll_offset,
702                );
703                // Match the panel-buffer presentation set in
704                // `build_group_layout` (no line numbers, no current-
705                // line highlight inside grouped panels).
706                buf_state.show_line_numbers = false;
707                buf_state.highlight_current_line = false;
708            }
709            // 2) Now flip the active pointer.
710            vs.active_buffer = new_buffer_id;
711            vs.layout_dirty = true;
712        }
713
714        // Mark the new buffer as hidden from tabs (panel buffers
715        // shouldn't show in quick-switch) — matches create-time logic.
716        if let Some(meta) = self
717            .active_window_mut()
718            .buffer_metadata
719            .get_mut(&new_buffer_id)
720        {
721            meta.hidden_from_tabs = true;
722        }
723
724        tracing::info!(
725            "setBufferGroupPanelBuffer: group {} panel '{}' {:?} -> {:?}",
726            group_id,
727            panel_name,
728            prior_buffer_id,
729            new_buffer_id
730        );
731        true
732    }
733
734    /// Activate a group tab by its Grouped-node LeafId in the given split.
735    /// Records the group as the split's active tab so the group's layout
736    /// becomes visible in that split's content area, and moves keyboard
737    /// focus to the group's active inner leaf. If `split_id` is not the
738    /// currently active split (e.g. the user clicked a group tab in a
739    /// non-focused pane), focus is transferred to it — tab clicks are
740    /// commitment gestures pointing at the clicked pane.
741    pub(crate) fn activate_group_tab(&mut self, split_id: LeafId, group_leaf: LeafId) {
742        // Find the inner active leaf and its buffer from the stored Grouped node.
743        let Some(crate::view::split::SplitNode::Grouped {
744            active_inner_leaf, ..
745        }) = self.active_window().grouped_subtrees.get(&group_leaf)
746        else {
747            return;
748        };
749        let inner_leaf = *active_inner_leaf;
750
751        // If activating a group tab in a non-focused split, transfer focus
752        // to that split first so subsequent keyboard input routes to the
753        // group's inner panel rather than the previously-active pane. This
754        // mirrors how clicking a buffer tab in another split moves focus.
755        if self
756            .windows
757            .get(&self.active_window)
758            .and_then(|w| w.buffers.splits())
759            .map(|(mgr, _)| mgr)
760            .expect("active window must have a populated split layout")
761            .active_split()
762            != split_id
763        {
764            self.active_window_mut()
765                .promote_preview_if_not_in_split(split_id);
766            if self.active_window_mut().key_context
767                == crate::input::keybindings::KeyContext::FileExplorer
768            {
769                self.active_window_mut().key_context =
770                    crate::input::keybindings::KeyContext::Normal;
771            }
772            self.windows
773                .get_mut(&self.active_window)
774                .and_then(|w| w.split_manager_mut())
775                .expect("active window must have a populated split layout")
776                .set_active_split(split_id);
777        }
778
779        // Record the group as the active-tab and focused inner leaf for
780        // this split. The inner leaf is NOT in the main split tree — it
781        // only exists inside the stashed Grouped subtree — so focus is
782        // routed via `focused_group_leaf` rather than `focus_split`.
783        if let Some(vs) = self
784            .windows
785            .get_mut(&self.active_window)
786            .and_then(|w| w.split_view_states_mut())
787            .expect("active window must have a populated split layout")
788            .get_mut(&split_id)
789        {
790            vs.active_group_tab = Some(group_leaf);
791            vs.focused_group_leaf = Some(inner_leaf);
792        }
793    }
794
795    /// Look up the ratio of a split container that lives inside one of the
796    /// stashed Grouped subtrees (i.e. not in the main split tree). Returns
797    /// `None` if no grouped subtree contains this container.
798    pub(crate) fn grouped_split_ratio(
799        &self,
800        container: crate::model::event::ContainerId,
801    ) -> Option<f32> {
802        self.active_window().grouped_split_ratio(container)
803    }
804
805    /// Set the ratio of a split container that lives inside a stashed
806    /// Grouped subtree. Returns `true` if the container was found and
807    /// updated.
808    pub(crate) fn set_grouped_split_ratio(
809        &mut self,
810        container: crate::model::event::ContainerId,
811        new_ratio: f32,
812    ) -> bool {
813        self.active_window_mut()
814            .set_grouped_split_ratio(container, new_ratio)
815    }
816
817    /// Close a buffer group by its Grouped-node LeafId (used by tab close button).
818    pub(crate) fn close_buffer_group_by_leaf(&mut self, group_leaf: LeafId) {
819        // Find the BufferGroupId whose stored representative_split matches
820        // this Grouped node's LeafId.
821        let bg_id_opt = self
822            .active_window_mut()
823            .buffer_groups
824            .iter()
825            .find(|(_, g)| g.representative_split == Some(group_leaf))
826            .map(|(id, _)| id.0);
827
828        if let Some(bg_id) = bg_id_opt {
829            self.close_buffer_group(bg_id);
830        }
831    }
832}
833
834impl crate::app::window::Window {
835    /// Look up the ratio of a split container that lives inside one of the
836    /// stashed Grouped subtrees (i.e. not in the main split tree). Returns
837    /// `None` if no grouped subtree contains this container.
838    pub fn grouped_split_ratio(&self, container: crate::model::event::ContainerId) -> Option<f32> {
839        use crate::view::split::SplitNode;
840        for node in self.grouped_subtrees.values() {
841            if let Some(SplitNode::Split { ratio, .. }) = node.find(container.into()) {
842                return Some(*ratio);
843            }
844        }
845        None
846    }
847
848    /// Set the ratio of a split container that lives inside a stashed
849    /// Grouped subtree. Returns `true` if the container was found and
850    /// updated.
851    pub fn set_grouped_split_ratio(
852        &mut self,
853        container: crate::model::event::ContainerId,
854        new_ratio: f32,
855    ) -> bool {
856        use crate::view::split::SplitNode;
857        for node in self.grouped_subtrees.values_mut() {
858            if let Some(SplitNode::Split { ratio, .. }) = node.find_mut(container.into()) {
859                *ratio = new_ratio.clamp(0.1, 0.9);
860                return true;
861            }
862        }
863        false
864    }
865
866    /// Whether the given buffer is marked non-scrollable. Buffer-group
867    /// panels can set `scrollable: false` (and Fixed panels default to
868    /// it) so the mouse wheel is a no-op and no scrollbar is drawn.
869    pub fn is_non_scrollable_buffer(&self, buffer_id: BufferId) -> bool {
870        self.buffers.get(&buffer_id).is_some_and(|s| !s.scrollable)
871    }
872}
873
874/// Get the fixed height of a layout node if it's a Fixed leaf.
875#[cfg(feature = "plugins")]
876fn fixed_height_of(node: &GroupLayoutNode) -> Option<u16> {
877    match node {
878        GroupLayoutNode::Fixed { height, .. } => Some(*height),
879        _ => None,
880    }
881}
882
883// `is_non_scrollable_buffer` moved to `impl Window` above. Editor
884// callers reach it via `self.active_window().is_non_scrollable_buffer(...)`.
885
886/// Find the first scrollable leaf in the layout tree.
887#[cfg(feature = "plugins")]
888fn find_first_scrollable_name(node: &GroupLayoutNode) -> Option<String> {
889    match node {
890        GroupLayoutNode::Scrollable { id, .. } => Some(id.clone()),
891        GroupLayoutNode::Fixed { .. } => None,
892        GroupLayoutNode::Split { first, second, .. } => {
893            find_first_scrollable_name(first).or_else(|| find_first_scrollable_name(second))
894        }
895    }
896}
897
898/// Find the first scrollable leaf's LeafId from the panel_splits map.
899#[cfg(feature = "plugins")]
900fn find_first_scrollable_leaf(
901    node: &GroupLayoutNode,
902    panel_splits: &HashMap<String, LeafId>,
903) -> Option<LeafId> {
904    find_first_scrollable_name(node).and_then(|name| panel_splits.get(&name).copied())
905}