v0.20.1 d9a391f

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, paneID, 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 pane ID, title, and favicon from pane if available
189		paneID := ""
190		title := "Untitled"
191		favicon := ""
192		if child.Pane != nil {
193			paneID = string(child.Pane.ID)
194			if child.Pane.Title != "" {
195				title = child.Pane.Title
196			}
197			favicon = child.Pane.FaviconURL
198		}
199
200		stackedView.AddPane(ctx, paneID, title, favicon, childWidget)
201
202		// Override paneToStack to point to THIS stacked view, not the child's wrapper.
203		// This ensures geometric navigation and stack sync work correctly.
204		if child.Pane != nil {
205			tr.paneToStack[paneID] = stackedView
206		}
207	}
208
209	// Set active index
210	if node.ActiveStackIndex >= 0 && node.ActiveStackIndex < stackedView.Count() {
211		_ = stackedView.SetActive(ctx, node.ActiveStackIndex)
212	}
213
214	return stackedView.Widget(), nil
215}
216
217// Lookup finds the widget associated with a node ID.
218// Returns nil if the node was not found or hasn't been rendered.
219func (tr *TreeRenderer) Lookup(nodeID string) Widget {
220	tr.mu.RLock()
221	defer tr.mu.RUnlock()
222
223	return tr.nodeToWidget[nodeID]
224}
225
226// LookupNode finds both the widget and verifies the node ID exists.
227// Returns the widget and true if found, nil and false otherwise.
228func (tr *TreeRenderer) LookupNode(nodeID string) (Widget, bool) {
229	tr.mu.RLock()
230	defer tr.mu.RUnlock()
231
232	widget, ok := tr.nodeToWidget[nodeID]
233	return widget, ok
234}
235
236// NodeCount returns the number of nodes currently tracked.
237func (tr *TreeRenderer) NodeCount() int {
238	tr.mu.RLock()
239	defer tr.mu.RUnlock()
240
241	return len(tr.nodeToWidget)
242}
243
244// Clear removes all node-to-widget mappings.
245func (tr *TreeRenderer) Clear() {
246	tr.mu.Lock()
247	defer tr.mu.Unlock()
248
249	tr.nodeToWidget = make(map[string]Widget)
250}
251
252// RegisterWidget adds or updates a node-to-widget mapping.
253// Use this for incremental updates when you don't want to rebuild the entire tree.
254func (tr *TreeRenderer) RegisterWidget(nodeID string, widget Widget) {
255	tr.mu.Lock()
256	defer tr.mu.Unlock()
257
258	tr.nodeToWidget[nodeID] = widget
259}
260
261// UnregisterWidget removes a node-to-widget mapping.
262// Use this when a node is removed or replaced during incremental operations.
263func (tr *TreeRenderer) UnregisterWidget(nodeID string) {
264	tr.mu.Lock()
265	defer tr.mu.Unlock()
266
267	delete(tr.nodeToWidget, nodeID)
268}
269
270// UpdateSplitRatio updates the ratio of a split view by node ID.
271// Returns an error if the node is not found or is not a split.
272func (tr *TreeRenderer) UpdateSplitRatio(nodeID string, ratio float64) error {
273	tr.mu.RLock()
274	widget, ok := tr.nodeToWidget[nodeID]
275	tr.mu.RUnlock()
276
277	if !ok {
278		return ErrNodeNotFound
279	}
280
281	// The widget should be a PanedWidget from a SplitView
282	// We can't directly update the ratio since we only store the Widget interface
283	// The caller would need to track SplitView instances separately or
284	// use the underlying PanedWidget's SetPosition method
285
286	// For now, we can at least verify the widget exists
287	_ = widget
288	return nil
289}
290
291// GetNodeIDs returns all tracked node IDs.
292func (tr *TreeRenderer) GetNodeIDs() []string {
293	tr.mu.RLock()
294	defer tr.mu.RUnlock()
295
296	ids := make([]string, 0, len(tr.nodeToWidget))
297	for id := range tr.nodeToWidget {
298		ids = append(ids, id)
299	}
300	return ids
301}
302
303// GetStackedViewForPane returns the StackedView containing the given pane.
304// Returns nil if the pane is not found.
305func (tr *TreeRenderer) GetStackedViewForPane(paneID string) *StackedView {
306	tr.mu.RLock()
307	defer tr.mu.RUnlock()
308
309	return tr.paneToStack[paneID]
310}
311
312// RegisterPaneInStack adds a pane to the paneToStack mapping.
313// Use this when adding a pane to an existing stack without rebuild.
314func (tr *TreeRenderer) RegisterPaneInStack(paneID string, stackedView *StackedView) {
315	tr.mu.Lock()
316	defer tr.mu.Unlock()
317
318	tr.paneToStack[paneID] = stackedView
319}
320
321// UnregisterPane removes a pane from the paneToStack mapping.
322func (tr *TreeRenderer) UnregisterPane(paneID string) {
323	tr.mu.Lock()
324	defer tr.mu.Unlock()
325
326	delete(tr.paneToStack, paneID)
327}

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