v0.14.0 76d1892

tab_manager.go RawHistoryFolder

1package browser
2
3import (
4	"fmt"
5	"sync"
6	"time"
7
8	"github.com/bnema/dumber/internal/config"
9	"github.com/bnema/dumber/internal/logging"
10	"github.com/bnema/dumber/pkg/webkit"
11	"github.com/diamondburned/gotk4/pkg/gtk/v4"
12)
13
14// TabManager manages the global tab system for the browser.
15// Each tab contains an independent workspace with its own pane tree.
16type TabManager struct {
17	tabs        []*Tab
18	activeIndex int
19	window      *webkit.Window
20	app         *BrowserApp
21
22	// GTK widgets
23	rootContainer gtk.Widgetter // Main vertical box containing tab bar + content
24	tabBar        gtk.Widgetter // Horizontal tab bar container
25	ContentArea   gtk.Widgetter // Container for active workspace (exported for border styling)
26	tabModeTarget gtk.Widgetter // Widget currently holding tab mode border margins
27
28	// Modal state
29	tabModeActive bool
30	tabModeTimer  *time.Timer
31
32	// Tab button click handling (pattern from StackedPaneManager)
33	buttonToTab   map[uint64]*Tab // Maps button ID to tab for click handling
34	nextButtonID  uint64          // Atomic counter for generating unique button IDs
35
36	// Synchronization
37	mu sync.RWMutex
38}
39
40// Tab represents a single browser tab containing a complete workspace.
41type Tab struct {
42	id          string
43	title       string
44	customTitle string // User-provided custom name (persists across page loads)
45	workspace   *WorkspaceManager
46	titleButton gtk.Widgetter
47	isActive    bool
48}
49
50// NewTabManager creates a new tab manager instance.
51func NewTabManager(app *BrowserApp, window *webkit.Window) *TabManager {
52	tm := &TabManager{
53		tabs:        make([]*Tab, 0),
54		activeIndex: -1,
55		window:      window,
56		app:         app,
57		buttonToTab: make(map[uint64]*Tab),
58	}
59
60	logging.Info("[tabs] Tab manager initialized")
61	return tm
62}
63
64// Initialize sets up the tab bar and creates the initial tab.
65func (tm *TabManager) Initialize(initialURL string) error {
66	tm.mu.Lock()
67	defer tm.mu.Unlock()
68
69	logging.Info("[tabs] Initializing tab system")
70
71	// Create root container layout
72	if err := tm.createRootContainer(); err != nil {
73		return fmt.Errorf("failed to create root container: %w", err)
74	}
75
76	// Create initial tab
77	if err := tm.createTabInternal(initialURL); err != nil {
78		return fmt.Errorf("failed to create initial tab: %w", err)
79	}
80
81	logging.Info("[tabs] Tab system initialized with 1 tab")
82	return nil
83}
84
85// createRootContainer creates the main vertical container with tab bar and content area.
86func (tm *TabManager) createRootContainer() error {
87	logging.Info("[tabs] Creating root container with tab bar")
88
89	// Get tab bar position from config
90	cfg := tm.getConfig()
91	position := cfg.Workspace.TabBarPosition
92	if position != "top" && position != "bottom" {
93		position = "bottom" // Fallback to default
94	}
95
96	logging.Debug(fmt.Sprintf("[tabs] Tab bar position: %s", position))
97
98	// Create main vertical box (tab bar + content area)
99	rootBox := gtk.NewBox(gtk.OrientationVertical, 0)
100	if rootBox == nil {
101		return fmt.Errorf("failed to create root container box")
102	}
103
104	rootBox.SetHExpand(true)
105	rootBox.SetVExpand(true)
106
107	// Create tab bar
108	tabBar := tm.createTabBar()
109	if tabBar == nil {
110		return fmt.Errorf("failed to create tab bar")
111	}
112
113	// Create content area for active workspace
114	contentArea := gtk.NewBox(gtk.OrientationVertical, 0)
115	if contentArea == nil {
116		return fmt.Errorf("failed to create content area")
117	}
118	contentArea.SetHExpand(true)
119	contentArea.SetVExpand(true)
120	contentArea.AddCSSClass("tab-content-area")
121
122	// Add widgets in order based on position
123	if position == "top" {
124		rootBox.Append(tabBar)
125		rootBox.Append(contentArea)
126	} else {
127		rootBox.Append(contentArea)
128		rootBox.Append(tabBar)
129	}
130
131	// Store references
132	tm.rootContainer = rootBox
133	tm.tabBar = tabBar
134	tm.ContentArea = contentArea
135
136	logging.Info(fmt.Sprintf("[tabs] Root container created with tab bar at %s", position))
137	return nil
138}
139
140// CreateTab creates a new tab and switches to it.
141// URL can be empty string to use default homepage.
142func (tm *TabManager) CreateTab(url string) error {
143	tm.mu.Lock()
144	defer tm.mu.Unlock()
145
146	return tm.createTabInternal(url)
147}
148
149// createTabInternal creates a new tab (must be called with lock held).
150func (tm *TabManager) createTabInternal(url string) error {
151	logging.Info(fmt.Sprintf("[tabs] Creating new tab with URL: %s", url))
152
153	// Use default URL if empty
154	if url == "" {
155		url = "about:blank"
156	}
157
158	// Generate unique tab ID and default name
159	tabNumber := len(tm.tabs) + 1
160	tabID := fmt.Sprintf("tab-%d-%d", tabNumber, time.Now().Unix())
161	defaultTitle := fmt.Sprintf("Tab %d", tabNumber)
162
163	// Create tab structure
164	tab := &Tab{
165		id:       tabID,
166		title:    defaultTitle,
167		isActive: false,
168	}
169
170	// Create WebView for this tab
171	cfg, err := tm.app.buildWebkitConfig()
172	if err != nil {
173		return fmt.Errorf("failed to build webkit config: %w", err)
174	}
175	cfg.CreateWindow = false // Tab webviews are embedded, don't create their own window
176
177	view, err := webkit.NewWebView(cfg)
178	if err != nil {
179		return fmt.Errorf("failed to create webview: %w", err)
180	}
181
182	// Create pane for this WebView
183	pane, err := tm.app.createPaneForView(view)
184	if err != nil {
185		return fmt.Errorf("failed to create pane: %w", err)
186	}
187
188	// Create workspace for this tab
189	workspace := NewWorkspaceManager(tm.app, pane)
190	tab.workspace = workspace
191
192	// Verify workspace root exists
193	rootWidget := workspace.GetRootWidget()
194	if rootWidget == nil {
195		return fmt.Errorf("workspace root widget is nil")
196	}
197	logging.Info(fmt.Sprintf("[tabs] New tab created with root container: %p (workspace %p)", rootWidget, workspace))
198
199	// Load the URL in the workspace's pane
200	if url != "" {
201		if err := view.LoadURL(url); err != nil {
202			logging.Warn(fmt.Sprintf("[tabs] Failed to load URL %s: %v", url, err))
203		}
204	}
205
206	// Create tab button in tab bar
207	button := tm.createTabButton(tab)
208	if button == nil {
209		return fmt.Errorf("failed to create tab button")
210	}
211	tab.titleButton = button
212
213	// Add button to tab bar
214	tm.addTabToBar(tab)
215
216	// Add to tabs slice
217	tm.tabs = append(tm.tabs, tab)
218
219	// Attach click handler with unique ID (pattern from stacked panes)
220	tm.attachTabClickHandler(tab.titleButton, tab)
221
222	// Switch to new tab
223	newIndex := len(tm.tabs) - 1
224	if err := tm.switchToTabInternal(newIndex); err != nil {
225		return fmt.Errorf("failed to switch to new tab: %w", err)
226	}
227
228	// Update tab bar visibility (hide if only 1 tab)
229	tm.updateTabBarVisibility()
230
231	logging.Info(fmt.Sprintf("[tabs] Created tab %s (total: %d)", tabID, len(tm.tabs)))
232	return nil
233}
234
235// SwitchToTab switches to the tab at the given index.
236func (tm *TabManager) SwitchToTab(index int) error {
237	tm.mu.Lock()
238	defer tm.mu.Unlock()
239
240	return tm.switchToTabInternal(index)
241}
242
243// switchToTabInternal switches to a tab (must be called with lock held).
244func (tm *TabManager) switchToTabInternal(index int) error {
245	// Validate index
246	if index < 0 || index >= len(tm.tabs) {
247		logging.Warn(fmt.Sprintf("[tabs] Invalid tab index: %d (valid: 0-%d)", index, len(tm.tabs)-1))
248		return fmt.Errorf("tab index %d out of range", index)
249	}
250
251	// Check if already active
252	if index == tm.activeIndex {
253		logging.Debug(fmt.Sprintf("[tabs] Tab %d already active", index))
254		return nil
255	}
256
257	logging.Info(fmt.Sprintf("[tabs] Switching from tab %d to %d", tm.activeIndex, index))
258
259	// Hide current tab if any
260	if tm.activeIndex >= 0 && tm.activeIndex < len(tm.tabs) {
261		oldTab := tm.tabs[tm.activeIndex]
262		oldTab.isActive = false
263
264		// Clear GTK focus from old tab's workspace to prevent it from catching keyboard events
265		if oldTab.workspace != nil {
266			oldTab.workspace.ClearFocus()
267		}
268
269		// Hide old workspace container and remove from content area
270		// CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container,
271		// because splits/stacks change the workspace root
272		if oldTab.workspace != nil {
273			currentRoot := oldTab.workspace.GetRootWidget()
274			if currentRoot != nil {
275				webkit.RunOnMainThread(func() {
276					if contentBox, ok := tm.ContentArea.(*gtk.Box); ok {
277						contentBox.Remove(currentRoot)
278						logging.Info(fmt.Sprintf("[tabs] Removed tab %d container %p from ContentArea (current root)", tm.activeIndex, currentRoot))
279					}
280				})
281			}
282		}
283
284		// Remove active CSS class from old button
285		tm.setTabActiveStyle(oldTab, false)
286	}
287
288	// Show new tab
289	newTab := tm.tabs[index]
290	newTab.isActive = true
291	tm.activeIndex = index
292
293	// Add new workspace container to content area
294	// CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container
295	if newTab.workspace != nil {
296		currentRoot := newTab.workspace.GetRootWidget()
297		if currentRoot != nil {
298			webkit.RunOnMainThread(func() {
299				if contentBox, ok := tm.ContentArea.(*gtk.Box); ok {
300					contentBox.Append(currentRoot)
301					webkit.WidgetSetVisible(currentRoot, true)
302					logging.Info(fmt.Sprintf("[tabs] Added tab %d container %p to ContentArea (current root)", index, currentRoot))
303				}
304			})
305		}
306	}
307
308	// Add active CSS class to new button
309	tm.setTabActiveStyle(newTab, true)
310
311	// Update app-level workspace/panes references for compatibility
312	if newTab.workspace != nil {
313		tm.app.workspace = newTab.workspace
314		tm.app.panes = newTab.workspace.GetAllPanes()
315		tm.app.activePane = newTab.workspace.GetActivePane()
316
317		// Focus the new workspace's active pane
318		newTab.workspace.RestoreFocus()
319	}
320
321	logging.Info(fmt.Sprintf("[tabs] Switched to tab %d (%s)", index, newTab.id))
322	return nil
323}
324
325// CloseTab closes the tab at the given index.
326func (tm *TabManager) CloseTab(index int) error {
327	tm.mu.Lock()
328	defer tm.mu.Unlock()
329
330	return tm.closeTabInternal(index)
331}
332
333// closeTabInternal closes a tab (must be called with lock held).
334func (tm *TabManager) closeTabInternal(index int) error {
335	// Validate index
336	if index < 0 || index >= len(tm.tabs) {
337		logging.Warn(fmt.Sprintf("[tabs] Cannot close: invalid tab index %d", index))
338		return fmt.Errorf("tab index %d out of range", index)
339	}
340
341	// Prevent closing last tab
342	if len(tm.tabs) == 1 {
343		logging.Warn("[tabs] Cannot close last tab")
344		return fmt.Errorf("cannot close the last tab")
345	}
346
347	tab := tm.tabs[index]
348	logging.Info(fmt.Sprintf("[tabs] Closing tab %d (%s)", index, tab.id))
349
350	// Cleanup workspace (WorkspaceManager cleanup is handled automatically when tabs are removed)
351
352	// Clean up button mapping (find and remove this tab's button ID)
353	for buttonID, mappedTab := range tm.buttonToTab {
354		if mappedTab == tab {
355			delete(tm.buttonToTab, buttonID)
356			logging.Debug(fmt.Sprintf("[tabs] Cleaned up button mapping for closed tab %s (buttonID=%d)", tab.id, buttonID))
357			break
358		}
359	}
360
361	// Remove tab button from tab bar
362	tm.removeTabFromBar(tab)
363
364	// Remove workspace container from content area if it's currently visible
365	// CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container
366	if tab.isActive && tab.workspace != nil {
367		currentRoot := tab.workspace.GetRootWidget()
368		if currentRoot != nil {
369			webkit.RunOnMainThread(func() {
370				if contentBox, ok := tm.ContentArea.(*gtk.Box); ok {
371					contentBox.Remove(currentRoot)
372					logging.Info(fmt.Sprintf("[tabs] Removed closing tab %d container %p from ContentArea (current root)", index, currentRoot))
373				}
374			})
375		}
376	}
377
378	// Remove from slice
379	tm.tabs = append(tm.tabs[:index], tm.tabs[index+1:]...)
380
381	// Determine which tab to switch to
382	newIndex := index
383	if index >= len(tm.tabs) {
384		newIndex = len(tm.tabs) - 1
385	}
386
387	// Update active index and switch
388	if index == tm.activeIndex {
389		tm.activeIndex = -1 // Temporarily invalid
390		if err := tm.switchToTabInternal(newIndex); err != nil {
391			return fmt.Errorf("failed to switch after close: %w", err)
392		}
393	} else if index < tm.activeIndex {
394		// Adjust active index if a tab before it was closed
395		tm.activeIndex--
396	}
397
398	// Update tab bar visibility (hide if only 1 tab)
399	tm.updateTabBarVisibility()
400
401	logging.Info(fmt.Sprintf("[tabs] Tab closed (remaining: %d)", len(tm.tabs)))
402	return nil
403}
404
405// RenameTab sets a custom title for the tab at the given index.
406func (tm *TabManager) RenameTab(index int, customTitle string) error {
407	tm.mu.Lock()
408	defer tm.mu.Unlock()
409
410	if index < 0 || index >= len(tm.tabs) {
411		return fmt.Errorf("tab index %d out of range", index)
412	}
413
414	tab := tm.tabs[index]
415	tab.customTitle = customTitle
416
417	logging.Info(fmt.Sprintf("[tabs] Renamed tab %d to '%s'", index, customTitle))
418
419	// Update tab button label
420	tm.updateTabButton(tab)
421
422	return nil
423}
424
425// Note: Tab titles are static (Tab 1, Tab 2, etc.) and don't change based on page titles.
426// This is different from stacked panes which show page titles.
427// Users can only change tab names via explicit rename action (Alt+T → r)
428
429// GetActiveTab returns the currently active tab.
430func (tm *TabManager) GetActiveTab() *Tab {
431	tm.mu.RLock()
432	defer tm.mu.RUnlock()
433
434	if tm.activeIndex >= 0 && tm.activeIndex < len(tm.tabs) {
435		return tm.tabs[tm.activeIndex]
436	}
437	return nil
438}
439
440// GetTabCount returns the number of open tabs.
441func (tm *TabManager) GetTabCount() int {
442	tm.mu.RLock()
443	defer tm.mu.RUnlock()
444
445	return len(tm.tabs)
446}
447
448// IsTabModeActive returns whether tab mode is currently active.
449func (tm *TabManager) IsTabModeActive() bool {
450	tm.mu.RLock()
451	defer tm.mu.RUnlock()
452
453	return tm.tabModeActive
454}
455
456// NextTab switches to the next tab (wraps around).
457func (tm *TabManager) NextTab() error {
458	tm.mu.Lock()
459	defer tm.mu.Unlock()
460
461	if len(tm.tabs) <= 1 {
462		return nil // No other tab to switch to
463	}
464
465	nextIndex := (tm.activeIndex + 1) % len(tm.tabs)
466	return tm.switchToTabInternal(nextIndex)
467}
468
469// PreviousTab switches to the previous tab (wraps around).
470func (tm *TabManager) PreviousTab() error {
471	tm.mu.Lock()
472	defer tm.mu.Unlock()
473
474	if len(tm.tabs) <= 1 {
475		return nil // No other tab to switch to
476	}
477
478	prevIndex := tm.activeIndex - 1
479	if prevIndex < 0 {
480		prevIndex = len(tm.tabs) - 1
481	}
482	return tm.switchToTabInternal(prevIndex)
483}
484
485// GetRootContainer returns the main container widget that should be set as window child.
486func (tm *TabManager) GetRootContainer() gtk.Widgetter {
487	tm.mu.RLock()
488	defer tm.mu.RUnlock()
489
490	return tm.rootContainer
491}
492
493// GetWindow returns the GTK window backing the tab manager.
494func (tm *TabManager) GetWindow() *webkit.Window {
495	tm.mu.RLock()
496	defer tm.mu.RUnlock()
497
498	return tm.window
499}
500
501// Cleanup performs cleanup of all tabs and resources.
502func (tm *TabManager) Cleanup() {
503	tm.mu.Lock()
504	defer tm.mu.Unlock()
505
506	logging.Info("[tabs] Cleaning up tab manager")
507
508	// Stop tab mode timer if active
509	if tm.tabModeTimer != nil {
510		tm.tabModeTimer.Stop()
511	}
512
513	// Cleanup all tabs (workspaces cleanup automatically when dereferenced)
514
515	logging.Info("[tabs] Tab manager cleanup complete")
516}
517
518// updateTabBarVisibility shows or hides the tab bar based on tab count.
519// Tab bar is hidden when there's only 1 tab (no need to show it).
520func (tm *TabManager) updateTabBarVisibility() {
521	if tm.tabBar == nil {
522		return
523	}
524
525	tabCount := len(tm.tabs)
526	shouldShow := tabCount > 1
527
528	webkit.WidgetSetVisible(tm.tabBar, shouldShow)
529
530	if shouldShow {
531		logging.Debug(fmt.Sprintf("[tabs] Tab bar visible (%d tabs)", tabCount))
532	} else {
533		logging.Debug("[tabs] Tab bar hidden (only 1 tab)")
534	}
535}
536
537// GetConfig is a helper to get the current configuration.
538func (tm *TabManager) getConfig() *config.Config {
539	return config.Get()
540}

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