v0.21.0 73bf644

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

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