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