v0.14.1 0da30ba

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/gdk/v4"
10	"github.com/diamondburned/gotk4/pkg/gtk/v4"
11)
12
13// createTabBar creates the horizontal tab bar container.
14// Returns the tab bar widget.
15func (tm *TabManager) createTabBar() gtk.Widgetter {
16	logging.Debug("[tabs] Creating tab bar container")
17
18	tabBar := gtk.NewBox(gtk.OrientationHorizontal, 0)
19	if tabBar == nil {
20		logging.Error("[tabs] Failed to create tab bar box")
21		return nil
22	}
23
24	tabBar.AddCSSClass("tab-bar")
25	tabBar.SetHExpand(true)
26	tabBar.SetVExpand(false)
27
28	tm.tabBar = tabBar
29	logging.Debug("[tabs] Tab bar container created")
30	return tabBar
31}
32
33// createTabButton creates a simple GTK button widget for a tab with just a text label.
34func (tm *TabManager) createTabButton(tab *Tab) gtk.Widgetter {
35	logging.Debug(fmt.Sprintf("[tabs] Creating button for tab %s", tab.id))
36
37	// Create button
38	button := gtk.NewButton()
39	if button == nil {
40		logging.Error("[tabs] Failed to create tab button")
41		return nil
42	}
43
44	// CRITICAL: Disable focus-on-click to prevent GTK focus system from interfering
45	// Tab buttons should be clickable without grabbing focus from the WebView
46	button.SetFocusOnClick(false)
47	button.SetCanFocus(false)
48
49	// Apply CSS classes
50	button.AddCSSClass("tab-button")
51
52	// Get display title (custom name or default name)
53	displayTitle := tab.title
54	if tab.customTitle != "" {
55		displayTitle = tab.customTitle
56	}
57
58	// Create title label
59	titleLabel := gtk.NewLabel(displayTitle)
60	if titleLabel != nil {
61		titleLabel.AddCSSClass("tab-title")
62		titleLabel.SetEllipsize(2) // Pango ellipsize mode: middle
63		titleLabel.SetMaxWidthChars(20)
64		webkit.WidgetSetVisible(titleLabel, true)
65	}
66
67	// Set button child
68	button.SetChild(titleLabel)
69
70	// Store button reference for later updates
71	tab.titleButton = button
72
73	// Note: Click handler will be attached in createTabInternal after tab is added to slice
74
75	// Set active class if this is the active tab
76	if tab.isActive {
77		webkit.WidgetAddCSSClass(button, "tab-button-active")
78	}
79
80	webkit.WidgetSetVisible(button, true)
81	logging.Debug(fmt.Sprintf("[tabs] Button created for tab %s", tab.id))
82	return button
83}
84
85// attachTabClickHandler attaches a click handler to switch to a tab when clicked.
86// Uses the same pattern as StackedPaneManager: maps unique button ID -> tab,
87// then looks up current index at click time to handle tab reordering correctly.
88func (tm *TabManager) attachTabClickHandler(button gtk.Widgetter, tab *Tab) {
89	if button == nil || tab == nil {
90		logging.Error("[tabs] Cannot attach click handler: nil button or tab")
91		return
92	}
93
94	gtkButton, ok := button.(*gtk.Button)
95	if !ok {
96		logging.Error(fmt.Sprintf("[tabs] Cannot attach click handler: widget is not *gtk.Button for tab %s", tab.id))
97		return
98	}
99
100	// Generate unique button ID and store mapping (pattern from stacked panes)
101	buttonID := atomic.AddUint64(&tm.nextButtonID, 1)
102	tm.buttonToTab[buttonID] = tab
103
104	gtkButton.ConnectClicked(func() {
105		tm.handleTabButtonClick(buttonID)
106	})
107
108	logging.Debug(fmt.Sprintf("[tabs] Click handler attached to tab %s (buttonID=%d)", tab.id, buttonID))
109}
110
111// handleTabButtonClick handles clicks on tab buttons to switch tabs.
112// Looks up the current index dynamically to handle tab reordering (pattern from stacked panes).
113func (tm *TabManager) handleTabButtonClick(buttonID uint64) {
114	tm.mu.Lock()
115	defer tm.mu.Unlock()
116
117	// Look up tab from button ID
118	tab, exists := tm.buttonToTab[buttonID]
119	if !exists || tab == nil {
120		logging.Warn(fmt.Sprintf("[tabs] Tab button click for unknown ID: %d", buttonID))
121		return
122	}
123
124	// Find current index of this tab
125	targetIndex := -1
126	for i, t := range tm.tabs {
127		if t == tab {
128			targetIndex = i
129			break
130		}
131	}
132
133	if targetIndex < 0 {
134		logging.Error(fmt.Sprintf("[tabs] Tab %s not found in tabs slice", tab.id))
135		return
136	}
137
138	// Only switch if not already active
139	if targetIndex == tm.activeIndex {
140		logging.Debug(fmt.Sprintf("[tabs] Tab %s already active (index %d)", tab.id, targetIndex))
141		return
142	}
143
144	logging.Info(fmt.Sprintf("[tabs] Tab button clicked: switching from tab %d to %d (%s)", tm.activeIndex, targetIndex, tab.id))
145
146	// Switch to this tab
147	if err := tm.switchToTabInternal(targetIndex); err != nil {
148		logging.Error(fmt.Sprintf("[tabs] Failed to switch to tab %d: %v", targetIndex, err))
149	}
150}
151
152// updateTabButton updates the tab button's title label.
153func (tm *TabManager) updateTabButton(tab *Tab) {
154	if tab.titleButton == nil {
155		logging.Warn(fmt.Sprintf("[tabs] Cannot update button for tab %s: button is nil", tab.id))
156		return
157	}
158
159	webkit.RunOnMainThread(func() {
160		button, ok := tab.titleButton.(*gtk.Button)
161		if !ok {
162			logging.Error(fmt.Sprintf("[tabs] Tab button is not a *gtk.Button: %s", tab.id))
163			return
164		}
165
166		// Get button child (the label)
167		child := button.Child()
168		if child == nil {
169			logging.Warn(fmt.Sprintf("[tabs] Tab button has no child: %s", tab.id))
170			return
171		}
172
173		label, ok := child.(*gtk.Label)
174		if !ok {
175			logging.Error(fmt.Sprintf("[tabs] Tab button child is not a *gtk.Label: %s", tab.id))
176			return
177		}
178
179		// Update label text
180		displayTitle := tab.title
181		if tab.customTitle != "" {
182			displayTitle = tab.customTitle
183		}
184		label.SetLabel(displayTitle)
185		logging.Debug(fmt.Sprintf("[tabs] Updated tab button label: %s -> %s", tab.id, displayTitle))
186	})
187}
188
189// addTabToBar adds a tab button to the tab bar.
190func (tm *TabManager) addTabToBar(tab *Tab) {
191	if tm.tabBar == nil {
192		logging.Error("[tabs] Cannot add tab to bar: tab bar is nil")
193		return
194	}
195
196	if tab.titleButton == nil {
197		logging.Error(fmt.Sprintf("[tabs] Cannot add tab %s to bar: button is nil", tab.id))
198		return
199	}
200
201	webkit.RunOnMainThread(func() {
202		if tabBarBox, ok := tm.tabBar.(*gtk.Box); ok {
203			tabBarBox.Append(tab.titleButton)
204			logging.Debug(fmt.Sprintf("[tabs] Added tab %s to tab bar", tab.id))
205		} else {
206			logging.Error("[tabs] Tab bar is not a *gtk.Box")
207		}
208	})
209}
210
211// removeTabFromBar removes a tab button from the tab bar.
212func (tm *TabManager) removeTabFromBar(tab *Tab) {
213	if tm.tabBar == nil || tab.titleButton == nil {
214		return
215	}
216
217	webkit.RunOnMainThread(func() {
218		if tabBarBox, ok := tm.tabBar.(*gtk.Box); ok {
219			tabBarBox.Remove(tab.titleButton)
220			logging.Debug(fmt.Sprintf("[tabs] Removed tab %s from tab bar", tab.id))
221		}
222	})
223}
224
225// setTabActiveStyle applies or removes the active CSS class on a tab button.
226func (tm *TabManager) setTabActiveStyle(tab *Tab, active bool) {
227	if tab.titleButton == nil {
228		logging.Warn(fmt.Sprintf("[tabs] Cannot set active style: button is nil for tab %s", tab.id))
229		return
230	}
231
232	webkit.RunOnMainThread(func() {
233		if active {
234			webkit.WidgetAddCSSClass(tab.titleButton, "tab-button-active")
235			logging.Debug(fmt.Sprintf("[tabs] Added active class to tab %s", tab.id))
236		} else {
237			webkit.WidgetRemoveCSSClass(tab.titleButton, "tab-button-active")
238			logging.Debug(fmt.Sprintf("[tabs] Removed active class from tab %s", tab.id))
239		}
240	})
241}
242
243// startInlineRename replaces the tab button's label with an entry widget for inline editing.
244func (tm *TabManager) startInlineRename(tab *Tab) {
245	if tab.titleButton == nil {
246		logging.Error(fmt.Sprintf("[tabs] Cannot start rename: button is nil for tab %s", tab.id))
247		return
248	}
249
250	// Check if rename already in progress
251	tm.mu.Lock()
252	if tm.renameInProgress {
253		logging.Debug("[tabs] Rename already in progress, ignoring")
254		tm.mu.Unlock()
255		return
256	}
257	tm.renameInProgress = true
258	tm.renamingTab = tab
259
260	// Stop tab mode timer during rename
261	if tm.tabModeTimer != nil {
262		tm.tabModeTimer.Stop()
263		tm.tabModeTimer = nil
264	}
265	tm.mu.Unlock()
266
267	webkit.RunOnMainThread(func() {
268		button, ok := tab.titleButton.(*gtk.Button)
269		if !ok {
270			logging.Error(fmt.Sprintf("[tabs] Tab button is not a *gtk.Button: %s", tab.id))
271			tm.cancelRename()
272			return
273		}
274
275		// Get current text
276		currentText := tab.title
277		if tab.customTitle != "" {
278			currentText = tab.customTitle
279		}
280
281		// Store original label for restoration
282		originalLabel := button.Child()
283
284		// Create entry widget
285		entry := gtk.NewEntry()
286		if entry == nil {
287			logging.Error("[tabs] Failed to create entry widget for rename")
288			tm.cancelRename()
289			return
290		}
291
292		// Store current focus behavior so we can restore it later
293		prevFocusOnClick := button.FocusOnClick()
294		prevCanFocus := button.CanFocus()
295		tm.mu.Lock()
296		tm.renamePrevFocusOnClick = prevFocusOnClick
297		tm.renamePrevCanFocus = prevCanFocus
298		tm.mu.Unlock()
299
300		// Allow the entry to receive focus while rename is active
301		button.SetFocusOnClick(true)
302		button.SetCanFocus(true)
303
304		// Configure entry BEFORE adding to widget tree
305		entry.SetText(currentText)
306		entry.AddCSSClass("tab-rename-entry")
307		entry.SetCanFocus(true)  // Explicitly allow focus
308		entry.SetFocusable(true) // GTK4 focusability
309
310		// Handle Enter key - save
311		entry.ConnectActivate(func() {
312			newName := entry.Text()
313			logging.Debug(fmt.Sprintf("[tabs] Rename activate (Enter): '%s' -> '%s'", currentText, newName))
314			tm.finishInlineRename(tab, newName, originalLabel)
315		})
316
317		// Handle Escape key - cancel
318		keyController := gtk.NewEventControllerKey()
319		keyController.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool {
320			const gdkKeyEscape = 0xff1b
321			if keyval == gdkKeyEscape {
322				logging.Debug("[tabs] Rename cancelled (Escape)")
323				tm.finishInlineRename(tab, "", originalLabel) // Empty = cancel
324				return true
325			}
326			return false
327		})
328		entry.AddController(keyController)
329
330		// Handle focus-out - cancel (safety)
331		focusController := gtk.NewEventControllerFocus()
332		focusController.ConnectLeave(func() {
333			logging.Debug("[tabs] Rename cancelled (focus-out)")
334			tm.finishInlineRename(tab, "", originalLabel)
335		})
336		entry.AddController(focusController)
337
338		// Replace label with entry
339		button.SetChild(entry)
340
341		// Make entry visible first
342		webkit.WidgetSetVisible(entry, true)
343
344		// Grab focus after widget is visible and realized
345		// Use idle callback to ensure widget is fully realized
346		entry.GrabFocus()
347
348		// Select all text after focus
349		entry.SelectRegion(0, -1)
350
351		logging.Debug(fmt.Sprintf("[tabs] Started inline rename for tab %s", tab.id))
352	})
353}
354
355// cancelRename clears the rename state without changing anything
356func (tm *TabManager) cancelRename() {
357	tm.mu.Lock()
358	defer tm.mu.Unlock()
359	tm.renameInProgress = false
360	tm.renamingTab = nil
361	tm.renamePrevFocusOnClick = false
362	tm.renamePrevCanFocus = false
363	logging.Debug("[tabs] Rename state cleared")
364}
365
366// finishInlineRename restores the label and saves the new name if provided.
367func (tm *TabManager) finishInlineRename(tab *Tab, newName string, originalLabel gtk.Widgetter) {
368	// Guard against multiple calls
369	tm.mu.Lock()
370	if !tm.renameInProgress {
371		logging.Debug("[tabs] finishInlineRename called but no rename in progress, ignoring")
372		tm.mu.Unlock()
373		return
374	}
375	if tm.renamingTab != tab {
376		logging.Debug(fmt.Sprintf("[tabs] finishInlineRename called for wrong tab (expected %s, got %s)",
377			tm.renamingTab.id, tab.id))
378		tm.mu.Unlock()
379		return
380	}
381
382	// Clear rename state immediately to prevent re-entry
383	tm.renameInProgress = false
384	tm.renamingTab = nil
385	tm.mu.Unlock()
386
387	webkit.RunOnMainThread(func() {
388		button, ok := tab.titleButton.(*gtk.Button)
389		if !ok {
390			logging.Error(fmt.Sprintf("[tabs] Tab button is not a *gtk.Button during finish: %s", tab.id))
391			return
392		}
393
394		// Restore original label
395		button.SetChild(originalLabel)
396
397		// Restore original focus behavior now that rename is finished
398		tm.mu.Lock()
399		prevFocusOnClick := tm.renamePrevFocusOnClick
400		prevCanFocus := tm.renamePrevCanFocus
401		tm.renamePrevFocusOnClick = false
402		tm.renamePrevCanFocus = false
403		tm.mu.Unlock()
404		button.SetFocusOnClick(prevFocusOnClick)
405		button.SetCanFocus(prevCanFocus)
406
407		// Save new name if provided and not just whitespace
408		newName = trimWhitespace(newName)
409		if newName != "" {
410			tab.customTitle = newName
411			logging.Info(fmt.Sprintf("[tabs] Tab %s renamed to '%s'", tab.id, newName))
412		} else {
413			logging.Debug(fmt.Sprintf("[tabs] Tab %s rename cancelled", tab.id))
414		}
415
416		// Update label text
417		tm.updateTabButton(tab)
418
419		// Exit tab mode
420		tm.ExitTabMode("rename-complete")
421	})
422}
423
424// trimWhitespace removes leading and trailing whitespace from a string.
425func trimWhitespace(s string) string {
426	start := 0
427	end := len(s)
428
429	// Trim leading whitespace
430	for start < end && isWhitespace(s[start]) {
431		start++
432	}
433
434	// Trim trailing whitespace
435	for end > start && isWhitespace(s[end-1]) {
436		end--
437	}
438
439	return s[start:end]
440}
441
442// isWhitespace checks if a byte is a whitespace character.
443func isWhitespace(b byte) bool {
444	return b == ' ' || b == '\t' || b == '\n' || b == '\r'
445}

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