fresh-editor 0.3.9

A lightweight, fast terminal-based text editor with LSP support and TypeScript plugins
Documentation
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
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
//! Buffer group management.
//!
//! A buffer group presents multiple splits/buffers as a single tab.
//! Each panel is a real buffer with its own viewport and scrollbar.
//! The group appears as one entry in the tab bar and buffer list.

use crate::app::types::{BufferGroup, BufferGroupId, GroupLayoutNode};
use crate::model::event::{BufferId, LeafId, SplitDirection};
use crate::view::split::SplitViewState;
use fresh_core::api::BufferGroupResult;
use std::collections::HashMap;

/// Layout description deserialized from plugin JSON.
#[derive(Debug, serde::Deserialize)]
#[serde(tag = "type")]
enum LayoutDesc {
    #[serde(rename = "scrollable")]
    Scrollable {
        id: String,
        /// Whether this panel responds to scroll events. Defaults to true
        /// for scrollable panels.
        scrollable: Option<bool>,
    },
    #[serde(rename = "fixed")]
    Fixed {
        id: String,
        height: u16,
        /// Whether this panel responds to scroll events. Defaults to false
        /// for fixed-height panels — their content is pinned to the panel
        /// size, so mouse-wheel scroll is a no-op and no scrollbar is drawn.
        /// Callers can override by passing `"scrollable": true`.
        scrollable: Option<bool>,
    },
    #[serde(rename = "split")]
    Split {
        direction: String, // "h" or "v"
        ratio: f32,
        first: Box<LayoutDesc>,
        second: Box<LayoutDesc>,
    },
}

impl super::Editor {
    /// Create a buffer group from a layout description.
    ///
    /// Builds a `SplitNode::Grouped` wrapping the panel layout and stores
    /// it in `grouped_subtrees`, then adds a `TabTarget::Group(group_leaf_id)`
    /// entry to the current split's tab bar. The main split tree is NOT
    /// modified — the group's subtree is dispatched to at render time when
    /// the current split's active target is this group.
    pub(super) fn create_buffer_group(
        &mut self,
        name: String,
        mode: String,
        layout_json: String,
    ) -> Result<BufferGroupResult, String> {
        use crate::view::split::{SplitNode, TabTarget};

        // Parse layout
        let desc: LayoutDesc =
            serde_json::from_str(&layout_json).map_err(|e| format!("Invalid layout: {}", e))?;

        // Allocate group ID
        let group_id = BufferGroupId(self.active_window_mut().next_buffer_group_id);
        self.active_window_mut().next_buffer_group_id += 1;

        // Build buffers for each leaf in the layout
        let mut panel_buffers: HashMap<String, BufferId> = HashMap::new();
        let mut panel_splits: HashMap<String, LeafId> = HashMap::new();
        let layout = self.build_group_layout(&desc, &mode, &mut panel_buffers)?;

        // Build the inner split tree for the group
        let inner_tree = self.build_split_tree(&layout, &mut panel_splits)?;

        // Determine the active inner leaf (first scrollable panel, fallback to any leaf)
        let active_inner_leaf = find_first_scrollable_leaf(&layout, &panel_splits)
            .or_else(|| panel_splits.values().next().copied())
            .ok_or("No panels in layout")?;

        // Allocate a LeafId for the Grouped node itself. This is what the
        // tab bar uses to reference this group (`TabTarget::Group(group_leaf_id)`).
        let group_leaf_id = LeafId(
            self.windows
                .get_mut(&self.active_window)
                .and_then(|w| w.split_manager_mut())
                .expect("active window must have a populated split layout")
                .allocate_split_id(),
        );

        // Build the Grouped SplitNode and stash it in the side map.
        let grouped_node = SplitNode::Grouped {
            split_id: group_leaf_id,
            name: name.clone(),
            layout: Box::new(inner_tree),
            active_inner_leaf,
        };
        self.active_window_mut()
            .grouped_subtrees
            .insert(group_leaf_id, grouped_node);

        // Create SplitViewState for each inner panel leaf
        let (tw, th) = (self.terminal_width, self.terminal_height);
        for (panel_name, leaf_id) in &panel_splits {
            let buffer_id = *panel_buffers
                .get(panel_name)
                .ok_or(format!("Panel '{}' has no buffer", panel_name))?;
            let mut vs = SplitViewState::with_buffer(tw, th, buffer_id);
            // All panels inside a group suppress chrome — the parent split's
            // tab bar is the only tab bar shown.
            vs.suppress_chrome = true;
            vs.hide_tilde = true;
            if let Some(bs) = vs.keyed_states.get_mut(&buffer_id) {
                bs.show_line_numbers = false;
                bs.highlight_current_line = false;
            }
            self.windows
                .get_mut(&self.active_window)
                .and_then(|w| w.split_view_states_mut())
                .expect("active window must have a populated split layout")
                .insert(*leaf_id, vs);
        }

        // Mark all panel buffers as hidden from tabs so they don't appear
        // in quick-switch or the buffer list.
        for buffer_id in panel_buffers.values() {
            if let Some(meta) = self.active_window_mut().buffer_metadata.get_mut(buffer_id) {
                meta.hidden_from_tabs = true;
            }
        }

        // Remove panel buffers from every OTHER split's open_buffers AND
        // keyed_states. create_virtual_buffer adds them to the active split
        // when each was created; leaving them there makes the outer split
        // carry a stale cursor entry for the panel buffer, which later
        // collides with the panel's own view state in any lookup that
        // scans split_view_states by buffer id.
        let hidden_panel_ids: Vec<BufferId> = panel_buffers.values().copied().collect();
        let panel_leaf_ids: std::collections::HashSet<LeafId> =
            panel_splits.values().copied().collect();
        for (leaf_id, vs) in self
            .windows
            .get_mut(&self.active_window)
            .and_then(|w| w.split_view_states_mut())
            .expect("active window must have a populated split layout")
            .iter_mut()
        {
            if panel_leaf_ids.contains(leaf_id) {
                // The panel's own view state needs its buffer.
                continue;
            }
            vs.open_buffers.retain(|t| match t {
                TabTarget::Buffer(b) => !hidden_panel_ids.contains(b),
                TabTarget::Group(_) => true,
            });
            vs.keyed_states
                .retain(|bid, _| !hidden_panel_ids.contains(bid));
        }

        // Add the group as a tab in the CURRENT split's tab bar and make it
        // the active tab. (The main split tree is untouched — the group's
        // layout lives in `grouped_subtrees` and is dispatched at render time.)
        let current_split_id = self
            .windows
            .get(&self.active_window)
            .and_then(|w| w.buffers.splits())
            .map(|(mgr, _)| mgr)
            .expect("active window must have a populated split layout")
            .active_split();
        if let Some(current_vs) = self
            .windows
            .get_mut(&self.active_window)
            .and_then(|w| w.split_view_states_mut())
            .expect("active window must have a populated split layout")
            .get_mut(&current_split_id)
        {
            current_vs.add_group(group_leaf_id);
            current_vs.set_active_group_tab(group_leaf_id);
            current_vs.focused_group_leaf = Some(active_inner_leaf);
        }

        // Register the group metadata
        let group = BufferGroup {
            id: group_id,
            name: name.clone(),
            mode,
            layout,
            panel_buffers: panel_buffers.clone(),
            panel_splits,
            representative_split: Some(group_leaf_id),
        };

        // Register reverse mapping
        for buffer_id in panel_buffers.values() {
            self.active_window_mut()
                .buffer_to_group
                .insert(*buffer_id, group_id);
        }

        self.active_window_mut()
            .buffer_groups
            .insert(group_id, group);

        // Build result
        let panels: HashMap<String, u64> = panel_buffers
            .iter()
            .map(|(name, bid)| (name.clone(), bid.0 as u64))
            .collect();

        Ok(BufferGroupResult {
            group_id: group_id.0 as u64,
            panels,
        })
    }

    /// Build a SplitNode tree directly from a GroupLayoutNode.
    /// Populates panel_splits with leaf_id for each panel.
    fn build_split_tree(
        &mut self,
        node: &GroupLayoutNode,
        panel_splits: &mut HashMap<String, crate::model::event::LeafId>,
    ) -> Result<crate::view::split::SplitNode, String> {
        use crate::model::event::LeafId;
        use crate::view::split::SplitNode;

        match node {
            GroupLayoutNode::Scrollable {
                id,
                buffer_id: Some(bid),
                ..
            }
            | GroupLayoutNode::Fixed {
                id,
                buffer_id: Some(bid),
                ..
            } => {
                let split_id = self
                    .windows
                    .get_mut(&self.active_window)
                    .and_then(|w| w.split_manager_mut())
                    .expect("active window must have a populated split layout")
                    .allocate_split_id();
                panel_splits.insert(id.clone(), LeafId(split_id));
                Ok(SplitNode::leaf(*bid, split_id))
            }
            GroupLayoutNode::Scrollable {
                buffer_id: None, ..
            }
            | GroupLayoutNode::Fixed {
                buffer_id: None, ..
            } => Err("Layout leaf has no buffer_id".to_string()),
            GroupLayoutNode::Split {
                direction,
                ratio,
                first,
                second,
            } => {
                let first_node = self.build_split_tree(first, panel_splits)?;
                let second_node = self.build_split_tree(second, panel_splits)?;
                let split_id = self
                    .windows
                    .get_mut(&self.active_window)
                    .and_then(|w| w.split_manager_mut())
                    .expect("active window must have a populated split layout")
                    .allocate_split_id();
                let mut split =
                    SplitNode::split(*direction, first_node, second_node, *ratio, split_id);
                // Apply fixed sizes from children
                let fixed_first_size = fixed_height_of(first);
                let fixed_second_size = fixed_height_of(second);
                if let SplitNode::Split {
                    fixed_first,
                    fixed_second,
                    ..
                } = &mut split
                {
                    *fixed_first = fixed_first_size;
                    *fixed_second = fixed_second_size;
                }
                Ok(split)
            }
        }
    }

    /// Build a GroupLayoutNode from a LayoutDesc, creating buffers for each leaf.
    fn build_group_layout(
        &mut self,
        desc: &LayoutDesc,
        mode: &str,
        panel_buffers: &mut HashMap<String, BufferId>,
    ) -> Result<GroupLayoutNode, String> {
        match desc {
            LayoutDesc::Scrollable { id, scrollable } => {
                let scrollable = scrollable.unwrap_or(true);
                let buffer_id = self.active_window_mut().create_virtual_buffer(
                    format!("*{}*", id),
                    mode.to_string(),
                    true,
                );
                if let Some(state) = self
                    .windows
                    .get_mut(&self.active_window)
                    .map(|w| &mut w.buffers)
                    .expect("active window present")
                    .get_mut(&buffer_id)
                {
                    state.show_cursors = false;
                    state.editing_disabled = true;
                    state.scrollable = scrollable;
                    state.margins.configure_for_line_numbers(false);
                }
                panel_buffers.insert(id.clone(), buffer_id);
                Ok(GroupLayoutNode::Scrollable {
                    id: id.clone(),
                    buffer_id: Some(buffer_id),
                    split_id: None,
                })
            }
            LayoutDesc::Fixed {
                id,
                height,
                scrollable,
            } => {
                let scrollable = scrollable.unwrap_or(false);
                let buffer_id = self.active_window_mut().create_virtual_buffer(
                    format!("*{}*", id),
                    mode.to_string(),
                    true,
                );
                if let Some(state) = self
                    .windows
                    .get_mut(&self.active_window)
                    .map(|w| &mut w.buffers)
                    .expect("active window present")
                    .get_mut(&buffer_id)
                {
                    state.show_cursors = false;
                    state.editing_disabled = true;
                    state.scrollable = scrollable;
                    state.margins.configure_for_line_numbers(false);
                }
                panel_buffers.insert(id.clone(), buffer_id);
                Ok(GroupLayoutNode::Fixed {
                    id: id.clone(),
                    height: *height,
                    buffer_id: Some(buffer_id),
                    split_id: None,
                })
            }
            LayoutDesc::Split {
                direction,
                ratio,
                first,
                second,
            } => {
                let dir = if direction == "h" {
                    SplitDirection::Vertical // "h" = horizontal layout = vertical split line
                } else {
                    SplitDirection::Horizontal
                };
                let first_node = self.build_group_layout(first, mode, panel_buffers)?;
                let second_node = self.build_group_layout(second, mode, panel_buffers)?;
                Ok(GroupLayoutNode::Split {
                    direction: dir,
                    ratio: *ratio,
                    first: Box::new(first_node),
                    second: Box::new(second_node),
                })
            }
        }
    }

    /// Set content on a panel within a buffer group.
    pub(super) fn set_panel_content(
        &mut self,
        group_id: usize,
        panel_name: String,
        entries: Vec<fresh_core::text_property::TextPropertyEntry>,
    ) {
        let bg_id = BufferGroupId(group_id);
        let buffer_id = self
            .active_window_mut()
            .buffer_groups
            .get(&bg_id)
            .and_then(|g| g.panel_buffers.get(&panel_name).copied());

        if let Some(buffer_id) = buffer_id {
            if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
                tracing::error!("Failed to set panel '{}' content: {}", panel_name, e);
            }
        } else {
            tracing::warn!("Panel '{}' not found in group {}", panel_name, group_id);
        }
    }

    /// Close a buffer group — remove the Grouped subtree, close all panel
    /// buffers, and remove the group tab from any split's tab bar.
    pub(super) fn close_buffer_group(&mut self, group_id: usize) {
        use crate::view::split::TabTarget;
        let bg_id = BufferGroupId(group_id);
        if let Some(group) = self.active_window_mut().buffer_groups.remove(&bg_id) {
            // Remove reverse mappings
            for buffer_id in group.panel_buffers.values() {
                self.active_window_mut().buffer_to_group.remove(buffer_id);
            }

            // Find the group_leaf_id (it's the `representative_split` now).
            if let Some(group_leaf_id) = group.representative_split {
                // Remove the Grouped subtree from the side map
                self.active_window_mut()
                    .grouped_subtrees
                    .remove(&group_leaf_id);
                // Remove the group tab from all splits' tab bars and clear
                // any active/focused group markers that point at this group.
                for vs in self
                    .windows
                    .get_mut(&self.active_window)
                    .and_then(|w| w.split_view_states_mut())
                    .expect("active window must have a populated split layout")
                    .values_mut()
                {
                    vs.open_buffers
                        .retain(|t| *t != TabTarget::Group(group_leaf_id));
                    vs.remove_group_from_history(group_leaf_id);
                    if vs.active_group_tab == Some(group_leaf_id) {
                        vs.active_group_tab = None;
                    }
                    if let Some(focused) = vs.focused_group_leaf {
                        if group.panel_splits.values().any(|&l| l == focused) {
                            vs.focused_group_leaf = None;
                        }
                    }
                }
            }

            // Clean up SplitViewState for inner panel leaves
            for split_id in group.panel_splits.values() {
                self.windows
                    .get_mut(&self.active_window)
                    .and_then(|w| w.split_view_states_mut())
                    .expect("active window must have a populated split layout")
                    .remove(split_id);
            }

            // Close all panel buffers
            for buffer_id in group.panel_buffers.values() {
                if let Err(e) = self.close_buffer(*buffer_id) {
                    tracing::warn!("Failed to close panel buffer {:?}: {}", buffer_id, e);
                }
            }

            // Ensure the active split now has a valid active_target.
            // If it was the group's tab, switch to the first available buffer tab.
            let active_split = self
                .windows
                .get(&self.active_window)
                .and_then(|w| w.buffers.splits())
                .map(|(mgr, _)| mgr)
                .expect("active window must have a populated split layout")
                .active_split();
            if let Some(vs) = self
                .windows
                .get(&self.active_window)
                .and_then(|w| w.buffers.splits())
                .map(|(_, vs)| vs)
                .expect("active window must have a populated split layout")
                .get(&active_split)
            {
                if let Some(first_buf) = vs.buffer_tab_ids().next() {
                    let _ = first_buf; // active_buffer is per-leaf; already set
                }
            }
        }
    }

    /// Focus a specific panel in a buffer group.
    ///
    /// If the panel's inner leaf is not in the main split tree (side-map
    /// approach), this activates the group tab on whichever split hosts it
    /// and marks the panel's leaf as the focused inner leaf.
    pub(super) fn focus_panel(&mut self, group_id: usize, panel_name: String) {
        let bg_id = BufferGroupId(group_id);
        let (group_leaf_id, inner_leaf) = match self.active_window_mut().buffer_groups.get(&bg_id) {
            Some(group) => {
                let Some(&inner) = group.panel_splits.get(&panel_name) else {
                    return;
                };
                let Some(leaf) = group.representative_split else {
                    return;
                };
                (leaf, inner)
            }
            None => return,
        };

        // Find the host split whose open_buffers contains this group tab.
        let host_split = self
            .windows
            .get(&self.active_window)
            .and_then(|w| w.buffers.splits())
            .map(|(_, vs)| vs)
            .expect("active window must have a populated split layout")
            .iter()
            .find(|(_, vs)| vs.has_group(group_leaf_id))
            .map(|(sid, _)| *sid);

        if let Some(host_split) = host_split {
            // Ensure the host split is the active one.
            self.windows
                .get_mut(&self.active_window)
                .and_then(|w| w.split_manager_mut())
                .expect("active window must have a populated split layout")
                .set_active_split(host_split);
            if let Some(vs) = self
                .windows
                .get_mut(&self.active_window)
                .and_then(|w| w.split_view_states_mut())
                .expect("active window must have a populated split layout")
                .get_mut(&host_split)
            {
                vs.active_group_tab = Some(group_leaf_id);
                vs.focused_group_leaf = Some(inner_leaf);
            }
            // Persist the choice on the SplitNode so a tab-away/back round
            // trip restores the same panel — `activate_group_tab` reads
            // this field when re-focusing the group.
            if let Some(crate::view::split::SplitNode::Grouped {
                active_inner_leaf, ..
            }) = self
                .active_window_mut()
                .grouped_subtrees
                .get_mut(&group_leaf_id)
            {
                *active_inner_leaf = inner_leaf;
            }
            // Transfer focus away from File Explorer (or any other context)
            // to the editor, since we're explicitly focusing a panel.
            self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Normal;
        }
    }

    /// Re-point a buffer group's panel at a different buffer id.
    ///
    /// Updates two places: `group.panel_buffers[panel_name]` (the
    /// authoritative name → buffer mapping for the group) and the
    /// panel split's `SplitViewState.active_buffer` (which buffer the
    /// panel actually renders). Marks the split's layout dirty so the
    /// next render sees the swap.
    ///
    /// Designed for streaming plugins that allocate one file-backed
    /// buffer per item and re-target the panel on navigation, instead
    /// of mutating a single shared buffer's contents.
    ///
    /// Returns `true` on success, `false` if the group, panel, or
    /// buffer was missing.
    pub(super) fn set_buffer_group_panel_buffer(
        &mut self,
        group_id: usize,
        panel_name: String,
        new_buffer_id: BufferId,
    ) -> bool {
        let bg_id = BufferGroupId(group_id);

        // Validate the buffer exists before touching anything.
        let buffer_exists = self
            .windows
            .get(&self.active_window)
            .map(|w| &w.buffers)
            .map(|b| b.get(&new_buffer_id).is_some())
            .unwrap_or(false);
        if !buffer_exists {
            tracing::warn!(
                "setBufferGroupPanelBuffer: buffer {:?} not found",
                new_buffer_id
            );
            return false;
        }

        // Look up the panel's inner leaf id, and the prior buffer id
        // we're replacing.
        let (panel_leaf, prior_buffer_id) =
            match self.active_window_mut().buffer_groups.get_mut(&bg_id) {
                Some(group) => {
                    let Some(&leaf) = group.panel_splits.get(&panel_name) else {
                        tracing::warn!(
                            "setBufferGroupPanelBuffer: panel '{}' missing in group {}",
                            panel_name,
                            group_id
                        );
                        return false;
                    };
                    let prior = group
                        .panel_buffers
                        .insert(panel_name.clone(), new_buffer_id);
                    (leaf, prior)
                }
                None => {
                    tracing::warn!("setBufferGroupPanelBuffer: group {} not found", group_id);
                    return false;
                }
            };

        // Maintain the reverse mapping `buffer_to_group` so the new
        // buffer is recognised as part of this group everywhere mode
        // resolution / close handling looks it up. The prior panel
        // buffer is only de-registered if it isn't still pointed at
        // by some other panel in the same group (rare, but possible
        // with custom layouts).
        if let Some(prior) = prior_buffer_id {
            let still_panel = self
                .active_window()
                .buffer_groups
                .get(&bg_id)
                .map(|g| g.panel_buffers.values().any(|b| *b == prior))
                .unwrap_or(false);
            if !still_panel {
                self.active_window_mut().buffer_to_group.remove(&prior);
            }
        }
        self.active_window_mut()
            .buffer_to_group
            .insert(new_buffer_id, bg_id);

        // The buffer needs the same per-buffer presentation flags
        // that `build_group_layout` applies to virtual panel buffers
        // (scrollable, no line-number margins, editing disabled).
        // Without these, a freshly-attached file-backed buffer
        // renders with the wrong margins/wrap and overflows the
        // panel's allotted width.
        if let Some(state) = self
            .windows
            .get_mut(&self.active_window)
            .map(|w| &mut w.buffers)
            .expect("active window present")
            .get_mut(&new_buffer_id)
        {
            state.scrollable = true;
            state.editing_disabled = true;
            state.margins.configure_for_line_numbers(false);
        }

        // Walk the grouped subtree and update the SplitNode::Leaf's
        // `buffer_id`. The renderer reads this — not the
        // SplitViewState — when collecting which buffer to draw in
        // each panel rect (see `get_leaves_with_rects`). Without
        // this, retargeting only updates focus state and the panel
        // keeps drawing the prior (now-empty) buffer.
        for node in self.active_window_mut().grouped_subtrees.values_mut() {
            if let Some(found) = node.find_mut(panel_leaf.into()) {
                if let crate::view::split::SplitNode::Leaf { buffer_id, .. } = found {
                    *buffer_id = new_buffer_id;
                    break;
                }
            }
        }

        // Update the panel split's view state: ensure a per-buffer
        // state entry for the new id BEFORE swapping active_buffer
        // (otherwise the next `active_state()` panics because the
        // freshly-set active_buffer has no keyed_states entry yet).
        let line_wrap = self
            .active_window()
            .resolve_line_wrap_for_buffer(new_buffer_id);
        let wrap_column = self
            .active_window()
            .resolve_wrap_column_for_buffer(new_buffer_id);
        let cfg = self.config.editor.clone();
        if let Some(vs) = self
            .windows
            .get_mut(&self.active_window)
            .and_then(|w| w.split_view_states_mut())
            .expect("active window must have a populated split layout")
            .get_mut(&panel_leaf)
        {
            // 1) Allocate the keyed state for the new buffer first.
            //    This call internally reads `active_state()` to copy
            //    viewport dims; calling it while active_buffer is
            //    still the prior id is safe.
            {
                let buf_state = vs.ensure_buffer_state(new_buffer_id);
                buf_state.apply_config_defaults(
                    cfg.line_numbers,
                    cfg.highlight_current_line,
                    line_wrap,
                    cfg.wrap_indent,
                    wrap_column,
                    cfg.rulers,
                );
                // Match the panel-buffer presentation set in
                // `build_group_layout` (no line numbers, no current-
                // line highlight inside grouped panels).
                buf_state.show_line_numbers = false;
                buf_state.highlight_current_line = false;
            }
            // 2) Now flip the active pointer.
            vs.active_buffer = new_buffer_id;
            vs.layout_dirty = true;
        }

        // Mark the new buffer as hidden from tabs (panel buffers
        // shouldn't show in quick-switch) — matches create-time logic.
        if let Some(meta) = self
            .active_window_mut()
            .buffer_metadata
            .get_mut(&new_buffer_id)
        {
            meta.hidden_from_tabs = true;
        }

        tracing::info!(
            "setBufferGroupPanelBuffer: group {} panel '{}' {:?} -> {:?}",
            group_id,
            panel_name,
            prior_buffer_id,
            new_buffer_id
        );
        true
    }

    /// Activate a group tab by its Grouped-node LeafId in the given split.
    /// Records the group as the split's active tab so the group's layout
    /// becomes visible in that split's content area, and moves keyboard
    /// focus to the group's active inner leaf. If `split_id` is not the
    /// currently active split (e.g. the user clicked a group tab in a
    /// non-focused pane), focus is transferred to it — tab clicks are
    /// commitment gestures pointing at the clicked pane.
    pub(crate) fn activate_group_tab(&mut self, split_id: LeafId, group_leaf: LeafId) {
        // Find the inner active leaf and its buffer from the stored Grouped node.
        let Some(crate::view::split::SplitNode::Grouped {
            active_inner_leaf, ..
        }) = self.active_window().grouped_subtrees.get(&group_leaf)
        else {
            return;
        };
        let inner_leaf = *active_inner_leaf;

        // If activating a group tab in a non-focused split, transfer focus
        // to that split first so subsequent keyboard input routes to the
        // group's inner panel rather than the previously-active pane. This
        // mirrors how clicking a buffer tab in another split moves focus.
        if self
            .windows
            .get(&self.active_window)
            .and_then(|w| w.buffers.splits())
            .map(|(mgr, _)| mgr)
            .expect("active window must have a populated split layout")
            .active_split()
            != split_id
        {
            self.active_window_mut()
                .promote_preview_if_not_in_split(split_id);
            if self.active_window_mut().key_context
                == crate::input::keybindings::KeyContext::FileExplorer
            {
                self.active_window_mut().key_context =
                    crate::input::keybindings::KeyContext::Normal;
            }
            self.windows
                .get_mut(&self.active_window)
                .and_then(|w| w.split_manager_mut())
                .expect("active window must have a populated split layout")
                .set_active_split(split_id);
        }

        // Record the group as the active-tab and focused inner leaf for
        // this split. The inner leaf is NOT in the main split tree — it
        // only exists inside the stashed Grouped subtree — so focus is
        // routed via `focused_group_leaf` rather than `focus_split`.
        if let Some(vs) = self
            .windows
            .get_mut(&self.active_window)
            .and_then(|w| w.split_view_states_mut())
            .expect("active window must have a populated split layout")
            .get_mut(&split_id)
        {
            vs.active_group_tab = Some(group_leaf);
            vs.focused_group_leaf = Some(inner_leaf);
        }
    }

    /// Look up the ratio of a split container that lives inside one of the
    /// stashed Grouped subtrees (i.e. not in the main split tree). Returns
    /// `None` if no grouped subtree contains this container.
    pub(crate) fn grouped_split_ratio(
        &self,
        container: crate::model::event::ContainerId,
    ) -> Option<f32> {
        self.active_window().grouped_split_ratio(container)
    }

    /// Set the ratio of a split container that lives inside a stashed
    /// Grouped subtree. Returns `true` if the container was found and
    /// updated.
    pub(crate) fn set_grouped_split_ratio(
        &mut self,
        container: crate::model::event::ContainerId,
        new_ratio: f32,
    ) -> bool {
        self.active_window_mut()
            .set_grouped_split_ratio(container, new_ratio)
    }

    /// Close a buffer group by its Grouped-node LeafId (used by tab close button).
    pub(crate) fn close_buffer_group_by_leaf(&mut self, group_leaf: LeafId) {
        // Find the BufferGroupId whose stored representative_split matches
        // this Grouped node's LeafId.
        let bg_id_opt = self
            .active_window_mut()
            .buffer_groups
            .iter()
            .find(|(_, g)| g.representative_split == Some(group_leaf))
            .map(|(id, _)| id.0);

        if let Some(bg_id) = bg_id_opt {
            self.close_buffer_group(bg_id);
        }
    }
}

impl crate::app::window::Window {
    /// Look up the ratio of a split container that lives inside one of the
    /// stashed Grouped subtrees (i.e. not in the main split tree). Returns
    /// `None` if no grouped subtree contains this container.
    pub fn grouped_split_ratio(&self, container: crate::model::event::ContainerId) -> Option<f32> {
        use crate::view::split::SplitNode;
        for node in self.grouped_subtrees.values() {
            if let Some(SplitNode::Split { ratio, .. }) = node.find(container.into()) {
                return Some(*ratio);
            }
        }
        None
    }

    /// Set the ratio of a split container that lives inside a stashed
    /// Grouped subtree. Returns `true` if the container was found and
    /// updated.
    pub fn set_grouped_split_ratio(
        &mut self,
        container: crate::model::event::ContainerId,
        new_ratio: f32,
    ) -> bool {
        use crate::view::split::SplitNode;
        for node in self.grouped_subtrees.values_mut() {
            if let Some(SplitNode::Split { ratio, .. }) = node.find_mut(container.into()) {
                *ratio = new_ratio.clamp(0.1, 0.9);
                return true;
            }
        }
        false
    }

    /// Whether the given buffer is marked non-scrollable. Buffer-group
    /// panels can set `scrollable: false` (and Fixed panels default to
    /// it) so the mouse wheel is a no-op and no scrollbar is drawn.
    pub fn is_non_scrollable_buffer(&self, buffer_id: BufferId) -> bool {
        self.buffers.get(&buffer_id).is_some_and(|s| !s.scrollable)
    }
}

/// Get the fixed height of a layout node if it's a Fixed leaf.
fn fixed_height_of(node: &GroupLayoutNode) -> Option<u16> {
    match node {
        GroupLayoutNode::Fixed { height, .. } => Some(*height),
        _ => None,
    }
}

// `is_non_scrollable_buffer` moved to `impl Window` above. Editor
// callers reach it via `self.active_window().is_non_scrollable_buffer(...)`.

/// Find the first scrollable leaf in the layout tree.
fn find_first_scrollable_name(node: &GroupLayoutNode) -> Option<String> {
    match node {
        GroupLayoutNode::Scrollable { id, .. } => Some(id.clone()),
        GroupLayoutNode::Fixed { .. } => None,
        GroupLayoutNode::Split { first, second, .. } => {
            find_first_scrollable_name(first).or_else(|| find_first_scrollable_name(second))
        }
    }
}

/// Find the first scrollable leaf's LeafId from the panel_splits map.
fn find_first_scrollable_leaf(
    node: &GroupLayoutNode,
    panel_splits: &HashMap<String, LeafId>,
) -> Option<LeafId> {
    find_first_scrollable_name(node).and_then(|name| panel_splits.get(&name).copied())
}