v0.14.0 76d1892

tab_bar.go RawHistoryFolder

1package browser
2
3import (
4	"fmt"
5	"sync/atomic"
6
7	"github.com/bnema/dumber/internal/logging"
8	"github.com/bnema/dumber/pkg/webkit"
9	"github.com/diamondburned/gotk4/pkg/gtk/v4"
10)
11
12// createTabBar creates the horizontal tab bar container.
13// Returns the tab bar widget.
14func (tm *TabManager) createTabBar() gtk.Widgetter {
15	logging.Debug("[tabs] Creating tab bar container")
16
17	tabBar := gtk.NewBox(gtk.OrientationHorizontal, 0)
18	if tabBar == nil {
19		logging.Error("[tabs] Failed to create tab bar box")
20		return nil
21	}
22
23	tabBar.AddCSSClass("tab-bar")
24	tabBar.SetHExpand(true)
25	tabBar.SetVExpand(false)
26
27	tm.tabBar = tabBar
28	logging.Debug("[tabs] Tab bar container created")
29	return tabBar
30}
31
32// createTabButton creates a simple GTK button widget for a tab with just a text label.
33func (tm *TabManager) createTabButton(tab *Tab) gtk.Widgetter {
34	logging.Debug(fmt.Sprintf("[tabs] Creating button for tab %s", tab.id))
35
36	// Create button
37	button := gtk.NewButton()
38	if button == nil {
39		logging.Error("[tabs] Failed to create tab button")
40		return nil
41	}
42
43	// CRITICAL: Disable focus-on-click to prevent GTK focus system from interfering
44	// Tab buttons should be clickable without grabbing focus from the WebView
45	button.SetFocusOnClick(false)
46	button.SetCanFocus(false)
47
48	// Apply CSS classes
49	button.AddCSSClass("tab-button")
50
51	// Get display title (custom name or default name)
52	displayTitle := tab.title
53	if tab.customTitle != "" {
54		displayTitle = tab.customTitle
55	}
56
57	// Create title label
58	titleLabel := gtk.NewLabel(displayTitle)
59	if titleLabel != nil {
60		titleLabel.AddCSSClass("tab-title")
61		titleLabel.SetEllipsize(2) // Pango ellipsize mode: middle
62		titleLabel.SetMaxWidthChars(20)
63		webkit.WidgetSetVisible(titleLabel, true)
64	}
65
66	// Set button child
67	button.SetChild(titleLabel)
68
69	// Store button reference for later updates
70	tab.titleButton = button
71
72	// Note: Click handler will be attached in createTabInternal after tab is added to slice
73
74	// Set active class if this is the active tab
75	if tab.isActive {
76		webkit.WidgetAddCSSClass(button, "tab-button-active")
77	}
78
79	webkit.WidgetSetVisible(button, true)
80	logging.Debug(fmt.Sprintf("[tabs] Button created for tab %s", tab.id))
81	return button
82}
83
84// attachTabClickHandler attaches a click handler to switch to a tab when clicked.
85// Uses the same pattern as StackedPaneManager: maps unique button ID -> tab,
86// then looks up current index at click time to handle tab reordering correctly.
87func (tm *TabManager) attachTabClickHandler(button gtk.Widgetter, tab *Tab) {
88	if button == nil || tab == nil {
89		logging.Error("[tabs] Cannot attach click handler: nil button or tab")
90		return
91	}
92
93	gtkButton, ok := button.(*gtk.Button)
94	if !ok {
95		logging.Error(fmt.Sprintf("[tabs] Cannot attach click handler: widget is not *gtk.Button for tab %s", tab.id))
96		return
97	}
98
99	// Generate unique button ID and store mapping (pattern from stacked panes)
100	buttonID := atomic.AddUint64(&tm.nextButtonID, 1)
101	tm.buttonToTab[buttonID] = tab
102
103	gtkButton.ConnectClicked(func() {
104		tm.handleTabButtonClick(buttonID)
105	})
106
107	logging.Debug(fmt.Sprintf("[tabs] Click handler attached to tab %s (buttonID=%d)", tab.id, buttonID))
108}
109
110// handleTabButtonClick handles clicks on tab buttons to switch tabs.
111// Looks up the current index dynamically to handle tab reordering (pattern from stacked panes).
112func (tm *TabManager) handleTabButtonClick(buttonID uint64) {
113	tm.mu.Lock()
114	defer tm.mu.Unlock()
115
116	// Look up tab from button ID
117	tab, exists := tm.buttonToTab[buttonID]
118	if !exists || tab == nil {
119		logging.Warn(fmt.Sprintf("[tabs] Tab button click for unknown ID: %d", buttonID))
120		return
121	}
122
123	// Find current index of this tab
124	targetIndex := -1
125	for i, t := range tm.tabs {
126		if t == tab {
127			targetIndex = i
128			break
129		}
130	}
131
132	if targetIndex < 0 {
133		logging.Error(fmt.Sprintf("[tabs] Tab %s not found in tabs slice", tab.id))
134		return
135	}
136
137	// Only switch if not already active
138	if targetIndex == tm.activeIndex {
139		logging.Debug(fmt.Sprintf("[tabs] Tab %s already active (index %d)", tab.id, targetIndex))
140		return
141	}
142
143	logging.Info(fmt.Sprintf("[tabs] Tab button clicked: switching from tab %d to %d (%s)", tm.activeIndex, targetIndex, tab.id))
144
145	// Switch to this tab
146	if err := tm.switchToTabInternal(targetIndex); err != nil {
147		logging.Error(fmt.Sprintf("[tabs] Failed to switch to tab %d: %v", targetIndex, err))
148	}
149}
150
151// updateTabButton updates the tab button's title label.
152func (tm *TabManager) updateTabButton(tab *Tab) {
153	if tab.titleButton == nil {
154		logging.Warn(fmt.Sprintf("[tabs] Cannot update button for tab %s: button is nil", tab.id))
155		return
156	}
157
158	webkit.RunOnMainThread(func() {
159		button, ok := tab.titleButton.(*gtk.Button)
160		if !ok {
161			logging.Error(fmt.Sprintf("[tabs] Tab button is not a *gtk.Button: %s", tab.id))
162			return
163		}
164
165		// Get button child (the label)
166		child := button.Child()
167		if child == nil {
168			logging.Warn(fmt.Sprintf("[tabs] Tab button has no child: %s", tab.id))
169			return
170		}
171
172		label, ok := child.(*gtk.Label)
173		if !ok {
174			logging.Error(fmt.Sprintf("[tabs] Tab button child is not a *gtk.Label: %s", tab.id))
175			return
176		}
177
178		// Update label text
179		displayTitle := tab.title
180		if tab.customTitle != "" {
181			displayTitle = tab.customTitle
182		}
183		label.SetLabel(displayTitle)
184		logging.Debug(fmt.Sprintf("[tabs] Updated tab button label: %s -> %s", tab.id, displayTitle))
185	})
186}
187
188// addTabToBar adds a tab button to the tab bar.
189func (tm *TabManager) addTabToBar(tab *Tab) {
190	if tm.tabBar == nil {
191		logging.Error("[tabs] Cannot add tab to bar: tab bar is nil")
192		return
193	}
194
195	if tab.titleButton == nil {
196		logging.Error(fmt.Sprintf("[tabs] Cannot add tab %s to bar: button is nil", tab.id))
197		return
198	}
199
200	webkit.RunOnMainThread(func() {
201		if tabBarBox, ok := tm.tabBar.(*gtk.Box); ok {
202			tabBarBox.Append(tab.titleButton)
203			logging.Debug(fmt.Sprintf("[tabs] Added tab %s to tab bar", tab.id))
204		} else {
205			logging.Error("[tabs] Tab bar is not a *gtk.Box")
206		}
207	})
208}
209
210// removeTabFromBar removes a tab button from the tab bar.
211func (tm *TabManager) removeTabFromBar(tab *Tab) {
212	if tm.tabBar == nil || tab.titleButton == nil {
213		return
214	}
215
216	webkit.RunOnMainThread(func() {
217		if tabBarBox, ok := tm.tabBar.(*gtk.Box); ok {
218			tabBarBox.Remove(tab.titleButton)
219			logging.Debug(fmt.Sprintf("[tabs] Removed tab %s from tab bar", tab.id))
220		}
221	})
222}
223
224// setTabActiveStyle applies or removes the active CSS class on a tab button.
225func (tm *TabManager) setTabActiveStyle(tab *Tab, active bool) {
226	if tab.titleButton == nil {
227		logging.Warn(fmt.Sprintf("[tabs] Cannot set active style: button is nil for tab %s", tab.id))
228		return
229	}
230
231	webkit.RunOnMainThread(func() {
232		if active {
233			webkit.WidgetAddCSSClass(tab.titleButton, "tab-button-active")
234			logging.Debug(fmt.Sprintf("[tabs] Added active class to tab %s", tab.id))
235		} else {
236			webkit.WidgetRemoveCSSClass(tab.titleButton, "tab-button-active")
237			logging.Debug(fmt.Sprintf("[tabs] Removed active class from tab %s", tab.id))
238		}
239	})
240}

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