v0.14.1 0da30ba

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

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