v0.20.0 c741a39

stacked.go RawHistoryFolder

1package layout
2
3import (
4	"context"
5	"errors"
6	"sync"
7
8	"github.com/bnema/dumber/internal/logging"
9)
10
11// ErrStackEmpty is returned when operating on an empty stack.
12var ErrStackEmpty = errors.New("stack is empty")
13
14// ErrIndexOutOfBounds is returned when an index is out of range.
15var ErrIndexOutOfBounds = errors.New("index out of bounds")
16
17// ErrCannotRemoveLastPane is returned when trying to remove the last pane from a stack.
18var ErrCannotRemoveLastPane = errors.New("cannot remove last pane from stack")
19
20const stackedTitleMaxWidthChars = 30
21
22// stackedPane represents a single pane within a stacked container.
23type stackedPane struct {
24	titleBar  BoxWidget // horizontal box with favicon + title label
25	container Widget    // the pane view widget
26	title     string
27	favicon   ImageWidget
28	label     LabelWidget
29	isActive  bool
30}
31
32// StackedView manages a stack of panes where only one is visible at a time.
33// Inactive panes show title bars that can be clicked to activate them.
34type StackedView struct {
35	factory     WidgetFactory
36	box         BoxWidget // vertical container for all content
37	panes       []*stackedPane
38	activeIndex int
39
40	onActivate func(index int) // called when a pane is activated via title bar click
41
42	mu sync.RWMutex
43}
44
45// NewStackedView creates a new stacked pane container.
46func NewStackedView(factory WidgetFactory) *StackedView {
47	box := factory.NewBox(OrientationVertical, 0)
48	box.SetHexpand(true)
49	box.SetVexpand(true)
50
51	return &StackedView{
52		factory:     factory,
53		box:         box,
54		panes:       make([]*stackedPane, 0),
55		activeIndex: -1,
56	}
57}
58
59// AddPane adds a new pane to the stack.
60// The new pane becomes active (visible).
61func (sv *StackedView) AddPane(ctx context.Context, title, faviconIconName string, container Widget) int {
62	log := logging.FromContext(ctx)
63	sv.mu.Lock()
64	defer sv.mu.Unlock()
65
66	log.Debug().
67		Str("title", title).
68		Bool("container_nil", container == nil).
69		Int("current_count", len(sv.panes)).
70		Msg("StackedView.AddPane called")
71
72	// Create title bar - must not expand vertically
73	titleBar := sv.factory.NewBox(OrientationHorizontal, 4)
74	titleBar.AddCssClass("stacked-pane-titlebar")
75	titleBar.SetVexpand(false)
76
77	// Create favicon image
78	favicon := sv.factory.NewImage()
79	if faviconIconName != "" {
80		favicon.SetFromIconName(faviconIconName)
81	} else {
82		favicon.SetFromIconName("web-browser-symbolic")
83	}
84	favicon.SetPixelSize(16)
85	titleBar.Append(favicon)
86
87	// Create title label
88	label := sv.factory.NewLabel(title)
89	label.SetEllipsize(EllipsizeEnd)
90	label.SetMaxWidthChars(stackedTitleMaxWidthChars)
91	label.SetHexpand(true)
92	label.SetXalign(0.0)
93	titleBar.Append(label)
94
95	// Make title bar clickable - ensure it doesn't expand vertically
96	titleButton := sv.factory.NewButton()
97	titleButton.SetChild(titleBar)
98	titleButton.AddCssClass("stacked-pane-title-button")
99	titleButton.SetFocusOnClick(false)
100	titleButton.SetVexpand(false) // Critical: don't let title bar expand vertically
101	titleButton.SetHexpand(true)  // But fill horizontal space
102
103	pane := &stackedPane{
104		titleBar:  titleBar,
105		container: container,
106		title:     title,
107		favicon:   favicon,
108		label:     label,
109		isActive:  false,
110	}
111
112	index := len(sv.panes)
113	sv.panes = append(sv.panes, pane)
114
115	// Connect click handler
116	titleButton.ConnectClicked(func() {
117		sv.mu.RLock()
118		callback := sv.onActivate
119		sv.mu.RUnlock()
120
121		if callback != nil {
122			callback(index)
123		}
124	})
125
126	// Add to container
127	log.Debug().
128		Int("index", index).
129		Msg("StackedView: appending titleButton and container to box")
130	sv.box.Append(titleButton)
131	if container != nil {
132		sv.box.Append(container)
133	}
134
135	// Set this pane as active
136	sv.setActiveInternal(ctx, index)
137
138	log.Debug().
139		Int("new_index", index).
140		Int("new_count", len(sv.panes)).
141		Msg("StackedView.AddPane completed")
142
143	return index
144}
145
146// InsertPaneAfter inserts a new pane after the specified index position.
147// Use afterIndex=-1 to insert at the beginning.
148// The new pane becomes active (visible).
149// Returns the index where the pane was inserted.
150func (sv *StackedView) InsertPaneAfter(
151	ctx context.Context, afterIndex int, title, faviconIconName string, container Widget,
152) int {
153	log := logging.FromContext(ctx)
154	sv.mu.Lock()
155	defer sv.mu.Unlock()
156
157	// Validate afterIndex - clamp to valid range
158	if afterIndex < -1 {
159		afterIndex = -1
160	}
161	if afterIndex >= len(sv.panes) {
162		afterIndex = len(sv.panes) - 1
163	}
164	insertIndex := afterIndex + 1
165
166	log.Debug().
167		Str("title", title).
168		Int("after_index", afterIndex).
169		Int("insert_index", insertIndex).
170		Int("current_count", len(sv.panes)).
171		Msg("StackedView.InsertPaneAfter called")
172
173	// Create title bar - must not expand vertically
174	titleBar := sv.factory.NewBox(OrientationHorizontal, 4)
175	titleBar.AddCssClass("stacked-pane-titlebar")
176	titleBar.SetVexpand(false)
177
178	// Create favicon image
179	favicon := sv.factory.NewImage()
180	if faviconIconName != "" {
181		favicon.SetFromIconName(faviconIconName)
182	} else {
183		favicon.SetFromIconName("web-browser-symbolic")
184	}
185	favicon.SetPixelSize(16)
186	titleBar.Append(favicon)
187
188	// Create title label
189	label := sv.factory.NewLabel(title)
190	label.SetEllipsize(EllipsizeEnd)
191	label.SetMaxWidthChars(stackedTitleMaxWidthChars)
192	label.SetHexpand(true)
193	label.SetXalign(0.0)
194	titleBar.Append(label)
195
196	// Make title bar clickable - ensure it doesn't expand vertically
197	titleButton := sv.factory.NewButton()
198	titleButton.SetChild(titleBar)
199	titleButton.AddCssClass("stacked-pane-title-button")
200	titleButton.SetFocusOnClick(false)
201	titleButton.SetVexpand(false)
202	titleButton.SetHexpand(true)
203
204	pane := &stackedPane{
205		titleBar:  titleBar,
206		container: container,
207		title:     title,
208		favicon:   favicon,
209		label:     label,
210		isActive:  false,
211	}
212
213	// Insert into slice at correct position
214	sv.panes = append(sv.panes, nil)
215	copy(sv.panes[insertIndex+1:], sv.panes[insertIndex:])
216	sv.panes[insertIndex] = pane
217
218	// Connect click handler - use captured index
219	capturedIndex := insertIndex
220	titleButton.ConnectClicked(func() {
221		sv.mu.RLock()
222		callback := sv.onActivate
223		sv.mu.RUnlock()
224
225		if callback != nil {
226			callback(capturedIndex)
227		}
228	})
229
230	// Insert widgets at correct position in GTK box
231	if insertIndex > 0 && sv.panes[insertIndex-1] != nil {
232		// Insert after the previous pane's container
233		prevPane := sv.panes[insertIndex-1]
234		if prevPane.container != nil {
235			sv.box.InsertChildAfter(titleButton, prevPane.container)
236			if container != nil {
237				sv.box.InsertChildAfter(container, titleButton)
238			}
239		} else {
240			// No container, insert after title bar's parent (the button)
241			prevTitleParent := prevPane.titleBar.GetParent()
242			if prevTitleParent != nil {
243				sv.box.InsertChildAfter(titleButton, prevTitleParent)
244				if container != nil {
245					sv.box.InsertChildAfter(container, titleButton)
246				}
247			} else {
248				// Fallback to append
249				sv.box.Append(titleButton)
250				if container != nil {
251					sv.box.Append(container)
252				}
253			}
254		}
255	} else {
256		// Insert at beginning
257		if container != nil {
258			sv.box.Prepend(container)
259		}
260		sv.box.Prepend(titleButton)
261	}
262
263	// Set this pane as active
264	sv.setActiveInternal(ctx, insertIndex)
265
266	log.Debug().
267		Int("insert_index", insertIndex).
268		Int("new_count", len(sv.panes)).
269		Msg("StackedView.InsertPaneAfter completed")
270
271	return insertIndex
272}
273
274// RemovePane removes a pane from the stack by index.
275// Returns an error if trying to remove the last pane.
276func (sv *StackedView) RemovePane(ctx context.Context, index int) error {
277	log := logging.FromContext(ctx)
278	sv.mu.Lock()
279	defer sv.mu.Unlock()
280
281	log.Debug().
282		Int("index", index).
283		Int("pane_count", len(sv.panes)).
284		Msg("StackedView.RemovePane called")
285
286	if len(sv.panes) == 0 {
287		return ErrStackEmpty
288	}
289	if index < 0 || index >= len(sv.panes) {
290		return ErrIndexOutOfBounds
291	}
292	if len(sv.panes) == 1 {
293		return ErrCannotRemoveLastPane
294	}
295
296	pane := sv.panes[index]
297
298	// Remove from container
299	if pane.titleBar != nil {
300		// The title bar is wrapped in a button, need to get parent
301		parent := pane.titleBar.GetParent()
302		if parent != nil {
303			sv.box.Remove(parent)
304		}
305	}
306	if pane.container != nil {
307		sv.box.Remove(pane.container)
308	}
309
310	// Remove from slice
311	sv.panes = append(sv.panes[:index], sv.panes[index+1:]...)
312
313	// Adjust active index
314	if sv.activeIndex >= len(sv.panes) {
315		sv.activeIndex = len(sv.panes) - 1
316	} else if sv.activeIndex > index {
317		sv.activeIndex--
318	}
319
320	// Update visibility
321	if sv.activeIndex >= 0 {
322		sv.updateVisibilityInternal(ctx)
323	}
324
325	return nil
326}
327
328// SetActive activates the pane at the given index.
329// The active pane's container is shown; inactive panes show only title bars.
330func (sv *StackedView) SetActive(ctx context.Context, index int) error {
331	log := logging.FromContext(ctx)
332	sv.mu.Lock()
333	defer sv.mu.Unlock()
334
335	log.Debug().
336		Int("index", index).
337		Int("pane_count", len(sv.panes)).
338		Msg("StackedView.SetActive called")
339
340	if len(sv.panes) == 0 {
341		return ErrStackEmpty
342	}
343	if index < 0 || index >= len(sv.panes) {
344		return ErrIndexOutOfBounds
345	}
346
347	sv.setActiveInternal(ctx, index)
348	return nil
349}
350
351// setActiveInternal sets the active pane without locking.
352func (sv *StackedView) setActiveInternal(ctx context.Context, index int) {
353	sv.activeIndex = index
354	sv.updateVisibilityInternal(ctx)
355}
356
357// updateVisibilityInternal updates visibility of all panes based on activeIndex.
358func (sv *StackedView) updateVisibilityInternal(ctx context.Context) {
359	log := logging.FromContext(ctx)
360
361	log.Debug().
362		Int("active_index", sv.activeIndex).
363		Int("pane_count", len(sv.panes)).
364		Msg("StackedView.updateVisibilityInternal called")
365
366	for i, pane := range sv.panes {
367		isActive := i == sv.activeIndex
368		pane.isActive = isActive
369
370		// Title bar is always visible for inactive panes, hidden for active
371		if pane.titleBar != nil {
372			parent := pane.titleBar.GetParent()
373			if parent != nil {
374				parent.SetVisible(!isActive)
375				log.Debug().
376					Int("pane_index", i).
377					Str("title", pane.title).
378					Bool("is_active", isActive).
379					Bool("titlebar_visible", !isActive).
380					Msg("StackedView: set titlebar visibility")
381			}
382		}
383
384		// Container is visible only for active pane
385		if pane.container != nil {
386			pane.container.SetVisible(isActive)
387			log.Debug().
388				Int("pane_index", i).
389				Str("title", pane.title).
390				Bool("is_active", isActive).
391				Bool("container_visible", isActive).
392				Msg("StackedView: set container visibility")
393		}
394
395		// Update CSS classes
396		if pane.titleBar != nil {
397			if isActive {
398				pane.titleBar.AddCssClass("active")
399			} else {
400				pane.titleBar.RemoveCssClass("active")
401			}
402		}
403	}
404}
405
406// ActiveIndex returns the index of the currently active pane.
407// Returns -1 if the stack is empty.
408func (sv *StackedView) ActiveIndex() int {
409	sv.mu.RLock()
410	defer sv.mu.RUnlock()
411
412	return sv.activeIndex
413}
414
415// Count returns the number of panes in the stack.
416func (sv *StackedView) Count() int {
417	sv.mu.RLock()
418	defer sv.mu.RUnlock()
419
420	return len(sv.panes)
421}
422
423// SetOnActivate sets the callback for when a pane is activated via title bar click.
424func (sv *StackedView) SetOnActivate(fn func(index int)) {
425	sv.mu.Lock()
426	defer sv.mu.Unlock()
427
428	sv.onActivate = fn
429}
430
431// UpdateTitle updates the title of a pane at the given index.
432func (sv *StackedView) UpdateTitle(index int, title string) error {
433	sv.mu.Lock()
434	defer sv.mu.Unlock()
435
436	if index < 0 || index >= len(sv.panes) {
437		return ErrIndexOutOfBounds
438	}
439
440	sv.panes[index].title = title
441	if sv.panes[index].label != nil {
442		sv.panes[index].label.SetText(title)
443	}
444
445	return nil
446}
447
448// UpdateFavicon updates the favicon of a pane at the given index using an icon name.
449func (sv *StackedView) UpdateFavicon(index int, iconName string) error {
450	sv.mu.Lock()
451	defer sv.mu.Unlock()
452
453	if index < 0 || index >= len(sv.panes) {
454		return ErrIndexOutOfBounds
455	}
456
457	if sv.panes[index].favicon != nil {
458		if iconName != "" {
459			sv.panes[index].favicon.SetFromIconName(iconName)
460		} else {
461			sv.panes[index].favicon.SetFromIconName("web-browser-symbolic")
462		}
463	}
464
465	return nil
466}
467
468// UpdateFaviconTexture updates the favicon of a pane at the given index using a texture.
469// If texture is nil, falls back to the default web-browser-symbolic icon.
470func (sv *StackedView) UpdateFaviconTexture(index int, texture Paintable) error {
471	sv.mu.Lock()
472	defer sv.mu.Unlock()
473
474	if index < 0 || index >= len(sv.panes) {
475		return ErrIndexOutOfBounds
476	}
477
478	if sv.panes[index].favicon != nil {
479		if texture != nil {
480			sv.panes[index].favicon.SetFromPaintable(texture)
481		} else {
482			sv.panes[index].favicon.SetFromIconName("web-browser-symbolic")
483		}
484	}
485
486	return nil
487}
488
489// GetContainer returns the container widget for the pane at the given index.
490func (sv *StackedView) GetContainer(index int) (Widget, error) {
491	sv.mu.RLock()
492	defer sv.mu.RUnlock()
493
494	if index < 0 || index >= len(sv.panes) {
495		return nil, ErrIndexOutOfBounds
496	}
497
498	return sv.panes[index].container, nil
499}
500
501// Widget returns the underlying BoxWidget for embedding in containers.
502func (sv *StackedView) Widget() Widget {
503	return sv.box
504}
505
506// Box returns the underlying BoxWidget for direct access.
507func (sv *StackedView) Box() BoxWidget {
508	return sv.box
509}
510
511// NavigateNext moves to the next pane in the stack (wraps around).
512func (sv *StackedView) NavigateNext(ctx context.Context) error {
513	log := logging.FromContext(ctx)
514	sv.mu.Lock()
515	defer sv.mu.Unlock()
516
517	if len(sv.panes) == 0 {
518		return ErrStackEmpty
519	}
520
521	newIndex := (sv.activeIndex + 1) % len(sv.panes)
522	log.Debug().
523		Int("old_index", sv.activeIndex).
524		Int("new_index", newIndex).
525		Msg("StackedView.NavigateNext")
526	sv.setActiveInternal(ctx, newIndex)
527	return nil
528}
529
530// NavigatePrevious moves to the previous pane in the stack (wraps around).
531func (sv *StackedView) NavigatePrevious(ctx context.Context) error {
532	log := logging.FromContext(ctx)
533	sv.mu.Lock()
534	defer sv.mu.Unlock()
535
536	if len(sv.panes) == 0 {
537		return ErrStackEmpty
538	}
539
540	newIndex := (sv.activeIndex - 1 + len(sv.panes)) % len(sv.panes)
541	log.Debug().
542		Int("old_index", sv.activeIndex).
543		Int("new_index", newIndex).
544		Msg("StackedView.NavigatePrevious")
545	sv.setActiveInternal(ctx, newIndex)
546	return nil
547}

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