v0.33.1 9577fee

tree.go RawHistoryFolder

1package layout
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"sync"
8
9	"github.com/bnema/dumber/internal/domain/entity"
10	"github.com/bnema/dumber/internal/logging"
11	"github.com/rs/zerolog"
12)
13
14// ErrNilRoot is returned when attempting to build from a nil root node.
15var ErrNilRoot = errors.New("root node is nil")
16
17// ErrNodeNotFound is returned when a node lookup fails.
18var ErrNodeNotFound = errors.New("node not found")
19
20// PaneViewFactory creates pane view widgets from pane nodes.
21// This decouples the TreeRenderer from concrete PaneView implementation.
22type PaneViewFactory interface {
23	// CreatePaneView creates a widget for a leaf pane node.
24	CreatePaneView(node *entity.PaneNode) Widget
25}
26
27// TreeRenderer builds GTK widget trees from domain PaneNode trees.
28// It maps each node to its corresponding GTK widget and maintains
29// a lookup table for finding widgets by node ID.
30type TreeRenderer struct {
31	factory          WidgetFactory
32	paneViewFactory  PaneViewFactory
33	logger           zerolog.Logger
34	nodeToWidget     map[string]Widget
35	splitOrientation map[string]Orientation
36	// splitViews owns the SplitView callback registrations created while
37	// rendering. Widgets alone cannot release their tick callbacks.
38	splitViews map[string]*SplitView
39	// paneToStack maps pane IDs to their containing StackedView.
40	// Every leaf pane is wrapped in a StackedView for easy stacking later.
41	paneToStack map[string]*StackedView
42
43	onSplitRatioChanged func(nodeID string, ratio float64)
44	mu                  sync.RWMutex
45}
46
47// NewTreeRenderer creates a new tree renderer.
48func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory PaneViewFactory) *TreeRenderer {
49	log := logging.FromContext(ctx)
50	log.Debug().Msg("creating tree renderer")
51
52	return &TreeRenderer{
53		factory:          factory,
54		paneViewFactory:  paneViewFactory,
55		logger:           log.With().Str("component", "tree-renderer").Logger(),
56		nodeToWidget:     make(map[string]Widget),
57		splitOrientation: make(map[string]Orientation),
58		splitViews:       make(map[string]*SplitView),
59		paneToStack:      make(map[string]*StackedView),
60	}
61}
62
63func (tr *TreeRenderer) SetOnSplitRatioChanged(fn func(nodeID string, ratio float64)) {
64	tr.mu.Lock()
65	defer tr.mu.Unlock()
66
67	tr.onSplitRatioChanged = fn
68}
69
70// Build constructs the entire widget tree from a root PaneNode.
71// Returns the root widget that can be embedded in a container.
72func (tr *TreeRenderer) Build(ctx context.Context, root *entity.PaneNode) (Widget, error) {
73	if root == nil {
74		return nil, ErrNilRoot
75	}
76
77	tr.mu.Lock()
78	defer tr.mu.Unlock()
79
80	tr.clearInternal()
81
82	return tr.renderNode(ctx, root)
83}
84
85// renderNode recursively renders a single node and its children.
86// Must be called with lock held.
87func (tr *TreeRenderer) renderNode(ctx context.Context, node *entity.PaneNode) (Widget, error) {
88	if node == nil {
89		return nil, nil
90	}
91
92	var widget Widget
93	var err error
94
95	switch {
96	case node.IsLeaf():
97		widget = tr.renderLeaf(ctx, node)
98	case node.IsSplit():
99		widget, err = tr.renderSplit(ctx, node)
100	case node.IsStacked:
101		widget, err = tr.renderStacked(ctx, node)
102	default:
103		// Unknown node type - treat as leaf if it has a pane
104		if node.Pane != nil {
105			widget = tr.renderLeaf(ctx, node)
106		}
107	}
108
109	if err != nil {
110		return nil, err
111	}
112
113	// Store mapping
114	if widget != nil && node.ID != "" {
115		tr.nodeToWidget[node.ID] = widget
116	}
117
118	return widget, nil
119}
120
121// renderLeaf creates a PaneView widget wrapped in a StackedView.
122// Every pane is wrapped in a StackedView from the start, making stacking trivial.
123// When there's only 1 pane, the StackedView shows no title bar.
124func (tr *TreeRenderer) renderLeaf(ctx context.Context, node *entity.PaneNode) Widget {
125	if tr.paneViewFactory == nil {
126		return nil
127	}
128
129	paneWidget := tr.paneViewFactory.CreatePaneView(node)
130	if paneWidget == nil {
131		return nil
132	}
133
134	// Wrap the pane in a StackedView
135	stackedView := NewStackedView(tr.factory)
136
137	// Get title from pane if available
138	title := "Untitled"
139	paneID := ""
140	if node.Pane != nil {
141		paneID = string(node.Pane.ID)
142		if node.Pane.Title != "" {
143			title = node.Pane.Title
144		}
145	}
146
147	tr.logger.Debug().
148		Str("pane_id", paneID).
149		Str("title", title).
150		Msg("TreeRenderer.renderLeaf: wrapping pane in StackedView")
151
152	stackedView.AddPane(ctx, paneID, title, "", paneWidget)
153
154	// Track this pane's StackedView for later stacking operations
155	if node.Pane != nil {
156		tr.paneToStack[paneID] = stackedView
157	}
158
159	return stackedView.Widget()
160}
161
162// renderSplit creates a SplitView for a split node.
163func (tr *TreeRenderer) renderSplit(ctx context.Context, node *entity.PaneNode) (Widget, error) {
164	// Render children first
165	leftWidget, err := tr.renderNode(ctx, node.Left())
166	if err != nil {
167		return nil, err
168	}
169
170	rightWidget, err := tr.renderNode(ctx, node.Right())
171	if err != nil {
172		return nil, err
173	}
174
175	// Determine orientation
176	orientation := OrientationHorizontal
177	if node.SplitDir == entity.SplitVertical {
178		orientation = OrientationVertical
179	}
180
181	// Create split view
182	splitView := NewSplitView(ctx, tr.factory, orientation, leftWidget, rightWidget, node.SplitRatio)
183	nodeID := node.ID
184	splitView.SetOnRatioChanged(func(ratio float64) {
185		tr.mu.RLock()
186		cb := tr.onSplitRatioChanged
187		tr.mu.RUnlock()
188		if cb != nil {
189			cb(nodeID, ratio)
190		}
191	})
192	tr.splitOrientation[node.ID] = orientation
193	tr.splitViews[node.ID] = splitView
194
195	return splitView.Widget(), nil
196}
197
198// renderStacked creates a StackedView for a stacked node.
199func (tr *TreeRenderer) renderStacked(ctx context.Context, node *entity.PaneNode) (Widget, error) {
200	stackedView := NewStackedView(tr.factory)
201
202	tr.logger.Debug().
203		Int("child_count", len(node.Children)).
204		Msg("TreeRenderer.renderStacked: creating stacked view")
205
206	// Add each child pane to the stack
207	for _, child := range node.Children {
208		childWidget, err := tr.renderNode(ctx, child)
209		if err != nil {
210			return nil, err
211		}
212
213		// Get pane ID, title, and favicon from pane if available
214		paneID := ""
215		title := "Untitled"
216		favicon := ""
217		if child.Pane != nil {
218			paneID = string(child.Pane.ID)
219			if child.Pane.Title != "" {
220				title = child.Pane.Title
221			}
222			favicon = child.Pane.FaviconURL
223		}
224
225		stackedView.AddPane(ctx, paneID, title, favicon, childWidget)
226
227		// Override paneToStack to point to THIS stacked view, not the child's wrapper.
228		// This ensures geometric navigation and stack sync work correctly.
229		if child.Pane != nil {
230			tr.paneToStack[paneID] = stackedView
231		}
232	}
233
234	// Set active index
235	if node.ActiveStackIndex >= 0 && node.ActiveStackIndex < stackedView.Count() {
236		_ = stackedView.SetActive(ctx, node.ActiveStackIndex)
237	}
238
239	return stackedView.Widget(), nil
240}
241
242// Lookup finds the widget associated with a node ID.
243// Returns nil if the node was not found or hasn't been rendered.
244func (tr *TreeRenderer) Lookup(nodeID string) Widget {
245	tr.mu.RLock()
246	defer tr.mu.RUnlock()
247
248	return tr.nodeToWidget[nodeID]
249}
250
251// LookupNode finds both the widget and verifies the node ID exists.
252// Returns the widget and true if found, nil and false otherwise.
253func (tr *TreeRenderer) LookupNode(nodeID string) (Widget, bool) {
254	tr.mu.RLock()
255	defer tr.mu.RUnlock()
256
257	widget, ok := tr.nodeToWidget[nodeID]
258	return widget, ok
259}
260
261// NodeCount returns the number of nodes currently tracked.
262func (tr *TreeRenderer) NodeCount() int {
263	tr.mu.RLock()
264	defer tr.mu.RUnlock()
265
266	return len(tr.nodeToWidget)
267}
268
269// Clear releases all renderer-owned split callbacks and removes mappings.
270func (tr *TreeRenderer) Clear() {
271	tr.mu.Lock()
272	defer tr.mu.Unlock()
273
274	tr.clearInternal()
275}
276
277// clearInternal releases renderer-owned views and resets the lookup maps.
278// The caller must hold tr.mu.
279func (tr *TreeRenderer) clearInternal() {
280	for _, splitView := range tr.splitViews {
281		if splitView != nil {
282			splitView.Cleanup()
283		}
284	}
285	tr.nodeToWidget = make(map[string]Widget)
286	tr.splitOrientation = make(map[string]Orientation)
287	tr.splitViews = make(map[string]*SplitView)
288	tr.paneToStack = make(map[string]*StackedView)
289}
290
291// RegisterWidget adds or updates a node-to-widget mapping.
292// Use this for incremental updates when you don't want to rebuild the entire tree.
293func (tr *TreeRenderer) RegisterWidget(nodeID string, widget Widget) {
294	tr.mu.Lock()
295	defer tr.mu.Unlock()
296
297	tr.nodeToWidget[nodeID] = widget
298}
299
300// RegisterSplit registers a split widget with its orientation.
301func (tr *TreeRenderer) RegisterSplit(nodeID string, widget Widget, orientation Orientation) {
302	tr.mu.Lock()
303	defer tr.mu.Unlock()
304
305	tr.nodeToWidget[nodeID] = widget
306	tr.splitOrientation[nodeID] = orientation
307}
308
309// UnregisterWidget removes a node-to-widget mapping.
310// Use this when a node is removed or replaced during incremental operations.
311func (tr *TreeRenderer) UnregisterWidget(nodeID string) {
312	tr.mu.Lock()
313	defer tr.mu.Unlock()
314
315	if splitView := tr.splitViews[nodeID]; splitView != nil {
316		splitView.Cleanup()
317	}
318	delete(tr.nodeToWidget, nodeID)
319	delete(tr.splitOrientation, nodeID)
320	delete(tr.splitViews, nodeID)
321}
322
323// UpdateSplitRatio updates the ratio of a split view by node ID.
324// Returns an error if the node is not found or is not a split.
325func (tr *TreeRenderer) UpdateSplitRatio(nodeID string, ratio float64) error {
326	tr.mu.RLock()
327	widget, ok := tr.nodeToWidget[nodeID]
328	orientation, okOrient := tr.splitOrientation[nodeID]
329	tr.mu.RUnlock()
330
331	if !ok {
332		return ErrNodeNotFound
333	}
334
335	paned, ok := widget.(PanedWidget)
336	if !ok {
337		return fmt.Errorf("widget is not a PanedWidget")
338	}
339	if !okOrient {
340		return fmt.Errorf("split orientation not found")
341	}
342
343	totalSize := 0
344	switch orientation {
345	case OrientationHorizontal:
346		totalSize = paned.GetAllocatedWidth()
347	case OrientationVertical:
348		totalSize = paned.GetAllocatedHeight()
349	}
350	if totalSize <= 0 {
351		return nil
352	}
353
354	paned.SetPosition(int(float64(totalSize) * ratio))
355	return nil
356}
357
358// GetNodeIDs returns all tracked node IDs.
359func (tr *TreeRenderer) GetNodeIDs() []string {
360	tr.mu.RLock()
361	defer tr.mu.RUnlock()
362
363	ids := make([]string, 0, len(tr.nodeToWidget))
364	for id := range tr.nodeToWidget {
365		ids = append(ids, id)
366	}
367	return ids
368}
369
370// GetStackedViewForPane returns the StackedView containing the given pane.
371// Returns nil if the pane is not found.
372func (tr *TreeRenderer) GetStackedViewForPane(paneID string) *StackedView {
373	tr.mu.RLock()
374	defer tr.mu.RUnlock()
375
376	return tr.paneToStack[paneID]
377}
378
379// RegisterPaneInStack adds a pane to the paneToStack mapping.
380// Use this when adding a pane to an existing stack without rebuild.
381func (tr *TreeRenderer) RegisterPaneInStack(paneID string, stackedView *StackedView) {
382	tr.mu.Lock()
383	defer tr.mu.Unlock()
384
385	tr.paneToStack[paneID] = stackedView
386}
387
388// UnregisterPane removes a pane from the paneToStack mapping.
389func (tr *TreeRenderer) UnregisterPane(paneID string) {
390	tr.mu.Lock()
391	defer tr.mu.Unlock()
392
393	delete(tr.paneToStack, paneID)
394}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close