v0.20.0 c741a39

tree.go RawHistoryFolder

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

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