tab.go RawHistoryFolder

1package coordinator
2
3import (
4	"context"
5	"fmt"
6
7	"github.com/bnema/dumber/internal/application/port"
8	"github.com/bnema/dumber/internal/application/usecase"
9	"github.com/bnema/dumber/internal/domain/entity"
10	"github.com/bnema/dumber/internal/logging"
11	"github.com/bnema/dumber/internal/ui/component"
12	"github.com/bnema/dumber/internal/ui/window"
13)
14
15// TabTarget identifies which TabList and MainWindow a tab operation applies to.
16// Each browser window owns a dedicated TabTarget; operations are scoped to that target.
17type TabTarget struct {
18	Tabs       *entity.TabList
19	MainWindow *window.MainWindow
20}
21
22// TabCoordinator manages tab lifecycle operations within a target window.
23// Public methods accept an explicit TabTarget so callers control which window's
24// tabs are mutated. The currentTarget field provides a fallback for callers that
25// operate on the "currently active" browser window (e.g., the keyboard dispatcher).
26type TabCoordinator struct {
27	tabsUC                  *usecase.ManageTabsUseCase
28	currentTarget           TabTarget
29	mainWindow              *window.MainWindow // retained for GetTabBar and SetMainWindow
30	hideTabBarWhenSingleTab bool
31
32	// Callbacks to avoid circular dependencies
33	onTabCreated         func(ctx context.Context, target TabTarget, tab *entity.Tab)
34	onTabSwitched        func(ctx context.Context, target TabTarget, tab *entity.Tab)
35	onTabClosed          func(ctx context.Context, target TabTarget, tab *entity.Tab)
36	onQuit               func()
37	onCurrentWindowEmpty func(ctx context.Context, target TabTarget)
38	onAttachPopupToTab   func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) error // For popup tabs
39	onStateChanged       func()                                                                                  // For session snapshots
40}
41
42// TabCoordinatorConfig holds configuration for TabCoordinator.
43type TabCoordinatorConfig struct {
44	TabsUC                  *usecase.ManageTabsUseCase
45	Tabs                    *entity.TabList // Initial tab list for the default target
46	MainWindow              *window.MainWindow
47	HideTabBarWhenSingleTab bool
48}
49
50// NewTabCoordinator creates a new TabCoordinator.
51func NewTabCoordinator(ctx context.Context, cfg TabCoordinatorConfig) *TabCoordinator {
52	log := logging.FromContext(ctx)
53	log.Debug().Msg("creating tab coordinator")
54
55	return &TabCoordinator{
56		tabsUC: cfg.TabsUC,
57		currentTarget: TabTarget{
58			Tabs:       cfg.Tabs,
59			MainWindow: cfg.MainWindow,
60		},
61		mainWindow:              cfg.MainWindow,
62		hideTabBarWhenSingleTab: cfg.HideTabBarWhenSingleTab,
63	}
64}
65
66// SetCurrentTarget updates the fallback target used by methods that don't receive
67// an explicit TabTarget. This should be called whenever the active browser window changes.
68func (c *TabCoordinator) SetCurrentTarget(target TabTarget) {
69	c.currentTarget = target
70	if target.MainWindow != nil {
71		c.mainWindow = target.MainWindow
72	}
73}
74
75// CurrentTarget returns the currently active fallback target.
76func (c *TabCoordinator) CurrentTarget() TabTarget {
77	return c.currentTarget
78}
79
80// SetOnTabCreated sets the callback for when a tab is created.
81func (c *TabCoordinator) SetOnTabCreated(fn func(ctx context.Context, target TabTarget, tab *entity.Tab)) {
82	c.onTabCreated = fn
83}
84
85// SetOnTabSwitched sets the callback for when a tab switch occurs.
86// This is used to swap workspace views in the content area.
87func (c *TabCoordinator) SetOnTabSwitched(fn func(ctx context.Context, target TabTarget, tab *entity.Tab)) {
88	c.onTabSwitched = fn
89}
90
91// SetOnTabClosed sets the callback for when a tab is removed from its target.
92func (c *TabCoordinator) SetOnTabClosed(fn func(ctx context.Context, target TabTarget, tab *entity.Tab)) {
93	c.onTabClosed = fn
94}
95
96// SetOnQuit sets the callback for when the last tab is closed.
97func (c *TabCoordinator) SetOnQuit(fn func()) {
98	c.onQuit = fn
99}
100
101// SetOnStateChanged sets the callback for when tab state changes (for session snapshots).
102func (c *TabCoordinator) SetOnStateChanged(fn func()) {
103	c.onStateChanged = fn
104}
105
106// SetOnCurrentWindowEmpty sets the callback for when the last tab in the current
107// window is closed. This allows the app to close the window gracefully.
108func (c *TabCoordinator) SetOnCurrentWindowEmpty(fn func(ctx context.Context, target TabTarget)) {
109	c.onCurrentWindowEmpty = fn
110}
111
112// SetMainWindow updates only the main window reference for coordinator setup.
113//
114// Deprecated: Use SetCurrentTarget for runtime targeting. This method preserves
115// currentTarget.Tabs and is safe only during app-level setup.
116func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) {
117	oldMainWindow := c.mainWindow
118	c.mainWindow = mainWindow
119	if c.currentTarget.MainWindow != mainWindow {
120		c.currentTarget = TabTarget{
121			Tabs:       c.currentTarget.Tabs,
122			MainWindow: mainWindow,
123		}
124	}
125	if c.currentTarget.Tabs != nil && c.currentTarget.Tabs.Count() > 0 && oldMainWindow != mainWindow {
126		logging.FromContext(context.Background()).Debug().
127			Int("tab_count", c.currentTarget.Tabs.Count()).
128			Str("old_main_window", fmt.Sprintf("%p", oldMainWindow)).
129			Str("new_main_window", fmt.Sprintf("%p", mainWindow)).
130			Msg("SetMainWindow: changing mainWindow while tabs exist")
131	}
132}
133
134// SetOnAttachPopupToTab sets the callback for attaching popup WebViews to tabs.
135func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) error) {
136	c.onAttachPopupToTab = fn
137}
138
139// notifyStateChanged triggers the state changed callback if set.
140func (c *TabCoordinator) notifyStateChanged() {
141	if c.onStateChanged != nil {
142		c.onStateChanged()
143	}
144}
145
146// indexOfTab returns the 0-based index of the given tab ID in a slice of tabs,
147// or -1 if not found.
148func indexOfTab(tabs []*entity.Tab, id entity.TabID) int {
149	for i, tab := range tabs {
150		if tab != nil && tab.ID == id {
151			return i
152		}
153	}
154	return -1
155}
156
157// Create creates a new tab in the given target with the given initial URL.
158func (c *TabCoordinator) Create(ctx context.Context, target TabTarget, initialURL string) (*entity.Tab, error) {
159	return c.create(ctx, target, initialURL, true)
160}
161
162func (c *TabCoordinator) create(ctx context.Context, target TabTarget, initialURL string, activate bool) (*entity.Tab, error) {
163	log := logging.FromContext(ctx)
164
165	if target.Tabs == nil {
166		return nil, fmt.Errorf("target.Tabs is nil")
167	}
168
169	output, err := c.tabsUC.Create(ctx, usecase.CreateTabInput{
170		TabList:    target.Tabs,
171		Name:       "",
172		InitialURL: initialURL,
173	})
174	if err != nil {
175		log.Error().Err(err).Msg("failed to create tab")
176		return nil, err
177	}
178
179	if activate {
180		// Set new tab as active (updates domain state and tracks previous)
181		target.Tabs.SetActive(output.Tab.ID)
182	}
183
184	// Notify app before adding the tab to the visible tab bar.
185	// The app assigns window ownership and the window-scoped default name here,
186	// and TabBar.AddTab reads tab.Title() immediately when creating the button.
187	if c.onTabCreated != nil {
188		c.onTabCreated(ctx, target, output.Tab)
189	}
190
191	// Update tab bar
192	if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
193		target.MainWindow.TabBar().AddTab(output.Tab)
194		if activate {
195			target.MainWindow.TabBar().SetActive(output.Tab.ID)
196		}
197	}
198
199	// Update tab bar visibility
200	c.UpdateBarVisibility(ctx, target)
201
202	// Switch to the new tab's workspace view when activation is requested.
203	if activate && c.onTabSwitched != nil {
204		c.onTabSwitched(ctx, target, output.Tab)
205	}
206
207	// Notify state change for session snapshots
208	c.notifyStateChanged()
209
210	log.Debug().Str("tab_id", string(output.Tab.ID)).Msg("tab created")
211	return output.Tab, nil
212}
213
214// Close closes the active tab in the given target.
215// When the target's TabList becomes empty, fires onCurrentWindowEmpty.
216// The caller (App) is responsible for deciding whether all windows are empty
217// and the process should quit. onQuit is NOT fired by Close.
218func (c *TabCoordinator) Close(ctx context.Context, target TabTarget) error {
219	log := logging.FromContext(ctx)
220
221	if target.Tabs == nil {
222		return fmt.Errorf("target.Tabs is nil")
223	}
224
225	activeID := target.Tabs.ActiveTabID
226	if activeID == "" {
227		log.Debug().Msg("no active tab to close")
228		return nil
229	}
230
231	tabs := target.Tabs.Tabs
232
233	// Compute the next tab to activate BEFORE closing
234	activeIdx := indexOfTab(tabs, activeID)
235
236	var nextID entity.TabID
237	if activeIdx >= 0 && len(tabs) > 1 {
238		if activeIdx < len(tabs)-1 {
239			nextID = tabs[activeIdx+1].ID
240		} else {
241			nextID = tabs[activeIdx-1].ID
242		}
243	}
244
245	closedTab := target.Tabs.Find(activeID)
246	wasLast, err := c.tabsUC.Close(ctx, target.Tabs, activeID)
247	if err != nil {
248		log.Error().Err(err).Msg("failed to close tab")
249		return err
250	}
251
252	if c.onTabClosed != nil && closedTab != nil {
253		c.onTabClosed(ctx, target, closedTab)
254	}
255
256	if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
257		target.MainWindow.TabBar().RemoveTab(activeID)
258	}
259
260	// Update tab bar visibility
261	c.UpdateBarVisibility(ctx, target)
262
263	// Target TabList became empty — signal window-empty.
264	// App decides whether to quit (all windows empty) or only close this window.
265	if wasLast {
266		c.notifyStateChanged()
267		if c.onCurrentWindowEmpty != nil {
268			c.onCurrentWindowEmpty(ctx, target)
269		}
270		log.Debug().Str("tab_id", string(activeID)).Msg("tab closed, target window empty")
271		return nil
272	}
273
274	// If there's a sibling tab in the same window, switch to it
275	if nextID != "" {
276		if err := c.tabsUC.Switch(ctx, target.Tabs, nextID); err != nil {
277			log.Error().Err(err).Str("tab_id", string(nextID)).Msg("failed to switch to sibling")
278			return err
279		}
280
281		if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
282			target.MainWindow.TabBar().SetActive(nextID)
283		}
284
285		if c.onTabSwitched != nil {
286			if tab := target.Tabs.Find(nextID); tab != nil {
287				c.onTabSwitched(ctx, target, tab)
288			}
289		}
290
291		// Notify state change for session snapshots
292		c.notifyStateChanged()
293
294		log.Debug().Str("tab_id", string(activeID)).Str("next", string(nextID)).Msg("tab closed, switched to sibling")
295		return nil
296	}
297
298	return c.recoverCloseWithoutSibling(ctx, target, activeID)
299}
300
301func (c *TabCoordinator) recoverCloseWithoutSibling(ctx context.Context, target TabTarget, closedID entity.TabID) error {
302	log := logging.FromContext(ctx)
303	// Defensive corrupted-state guard: only treat the target as empty if it is
304	// actually empty after close; otherwise recover by activating a surviving tab.
305	c.notifyStateChanged()
306	if len(target.Tabs.Tabs) == 0 {
307		if c.onCurrentWindowEmpty != nil {
308			c.onCurrentWindowEmpty(ctx, target)
309		}
310		log.Debug().Str("tab_id", string(closedID)).Msg("tab closed, target window empty")
311		return nil
312	}
313	for _, tab := range target.Tabs.Tabs {
314		if tab != nil {
315			return c.Switch(ctx, target, tab.ID)
316		}
317	}
318	return fmt.Errorf("tab closed but no surviving tab could be activated")
319}
320
321// Switch switches to a specific tab by ID in the given target.
322func (c *TabCoordinator) Switch(ctx context.Context, target TabTarget, tabID entity.TabID) error {
323	log := logging.FromContext(ctx)
324
325	if target.Tabs == nil {
326		return fmt.Errorf("target.Tabs is nil")
327	}
328
329	// Skip if already active
330	if tabID == target.Tabs.ActiveTabID {
331		return nil
332	}
333
334	// Verify the tab exists in this target
335	tab := target.Tabs.Find(tabID)
336	if tab == nil {
337		log.Debug().Str("tab_id", string(tabID)).Msg("tab not found in target")
338		return nil
339	}
340
341	log.Debug().
342		Str("from", string(target.Tabs.ActiveTabID)).
343		Str("to", string(tabID)).
344		Msg("switching tab")
345
346	// Update domain state
347	if err := c.tabsUC.Switch(ctx, target.Tabs, tabID); err != nil {
348		log.Error().Err(err).Str("tab_id", string(tabID)).Msg("failed to switch tab")
349		return err
350	}
351
352	// Update tab bar UI
353	if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
354		target.MainWindow.TabBar().SetActive(tabID)
355	}
356
357	// Invoke callback for workspace view switching
358	if c.onTabSwitched != nil {
359		c.onTabSwitched(ctx, target, tab)
360	}
361
362	c.notifyStateChanged()
363
364	return nil
365}
366
367// switchRelative switches to the tab delta positions away within the target.
368// delta of +1 switches next, -1 switches previous, wrapping within the list.
369func (c *TabCoordinator) switchRelative(ctx context.Context, target TabTarget, delta int) error {
370	if target.Tabs == nil {
371		return fmt.Errorf("target.Tabs is nil")
372	}
373	tabs := target.Tabs.Tabs
374	if len(tabs) <= 1 {
375		return nil
376	}
377
378	current := indexOfTab(tabs, target.Tabs.ActiveTabID)
379	if current < 0 {
380		current = 0
381	} else {
382		current = (current + delta + len(tabs)) % len(tabs)
383	}
384
385	return c.Switch(ctx, target, tabs[current].ID)
386}
387
388// SwitchNext switches to the next tab within the given target.
389func (c *TabCoordinator) SwitchNext(ctx context.Context, target TabTarget) error {
390	return c.switchRelative(ctx, target, 1)
391}
392
393// SwitchPrev switches to the previous tab within the given target.
394func (c *TabCoordinator) SwitchPrev(ctx context.Context, target TabTarget) error {
395	return c.switchRelative(ctx, target, -1)
396}
397
398// SwitchByIndexCreating switches to a tab by 0-based index within the given target,
399// creating new tabs as needed to reach the requested index.
400// If the requested index is beyond the current tab count and initialURL is empty,
401// it returns an error without mutating tabs. If index < 0, it logs debug and returns nil.
402func (c *TabCoordinator) SwitchByIndexCreating(ctx context.Context, target TabTarget, index int, initialURL string) error {
403	log := logging.FromContext(ctx)
404
405	if target.Tabs == nil {
406		return fmt.Errorf("target.Tabs is nil")
407	}
408	if index < 0 {
409		log.Debug().Int("index", index).Msg("invalid tab index, ignoring")
410		return nil
411	}
412	if target.Tabs.Count() <= index && initialURL == "" {
413		return fmt.Errorf("initial URL is required to create missing tabs")
414	}
415	// Intermediate tabs are created inactive (create(..., false)) and the final
416	// missing tab is created active (create(..., true)) before SwitchByIndex.
417	for target.Tabs.Count() < index {
418		if _, err := c.create(ctx, target, initialURL, false); err != nil {
419			return err
420		}
421	}
422	if target.Tabs.Count() <= index {
423		if _, err := c.create(ctx, target, initialURL, true); err != nil {
424			return err
425		}
426	}
427	return c.SwitchByIndex(ctx, target, index)
428}
429
430// SwitchByIndex switches to a tab by 0-based index within the given target.
431func (c *TabCoordinator) SwitchByIndex(ctx context.Context, target TabTarget, index int) error {
432	log := logging.FromContext(ctx)
433
434	if target.Tabs == nil {
435		return fmt.Errorf("target.Tabs is nil")
436	}
437	tabs := target.Tabs.Tabs
438	if index < 0 || index >= len(tabs) {
439		log.Debug().Int("index", index).Int("count", len(tabs)).Msg("invalid tab index")
440		return nil
441	}
442
443	return c.Switch(ctx, target, tabs[index].ID)
444}
445
446// SwitchToLastActive switches to the previously active tab (Alt+Tab style)
447// within the given target.
448func (c *TabCoordinator) SwitchToLastActive(ctx context.Context, target TabTarget) error {
449	if target.Tabs == nil {
450		return nil
451	}
452
453	prevID := target.Tabs.PreviousActiveTabID
454	if prevID == "" || prevID == target.Tabs.ActiveTabID {
455		return nil
456	}
457
458	tab := target.Tabs.Find(prevID)
459	if tab == nil {
460		target.Tabs.PreviousActiveTabID = ""
461		return nil
462	}
463
464	return c.Switch(ctx, target, prevID)
465}
466
467// UpdateBarVisibility shows or hides the tab bar based on tab count in the target.
468func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context, target TabTarget) {
469	log := logging.FromContext(ctx)
470
471	if target.MainWindow == nil || target.MainWindow.TabBar() == nil {
472		log.Debug().Msg("mainWindow or tabBar is nil, skipping visibility update")
473		return
474	}
475
476	// Check if feature is enabled
477	if !c.hideTabBarWhenSingleTab {
478		log.Debug().Msg("tab bar auto-hide disabled by config")
479		target.MainWindow.TabBar().SetAutoHidden(false)
480		target.MainWindow.SetTabBarContentInsetVisible(true)
481		return
482	}
483
484	tabCount := target.MainWindow.TabBar().Count()
485	shouldShow := tabCount > 1
486
487	log.Debug().Int("tab_count", tabCount).Bool("should_show", shouldShow).Msg("setting tab bar visibility")
488	target.MainWindow.TabBar().SetAutoHidden(!shouldShow)
489	target.MainWindow.SetTabBarContentInsetVisible(shouldShow)
490}
491
492// GetTabBar returns the tab bar component from the current main window.
493func (c *TabCoordinator) GetTabBar() *component.TabBar {
494	if c.mainWindow != nil {
495		return c.mainWindow.TabBar()
496	}
497	return nil
498}
499
500// CreateWithPane creates a new tab with a pre-created pane and WebView in the given target.
501// This is used for tabbed popup behavior where the popup pane already exists.
502func (c *TabCoordinator) CreateWithPane(
503	ctx context.Context,
504	target TabTarget,
505	pane *entity.Pane,
506	wv port.WebView,
507	initialURL string,
508) (*entity.Tab, error) {
509	log := logging.FromContext(ctx)
510
511	if target.Tabs == nil {
512		return nil, fmt.Errorf("target.Tabs is nil")
513	}
514
515	output, err := c.tabsUC.CreateWithPane(ctx, usecase.CreateTabWithPaneInput{
516		TabList:    target.Tabs,
517		Name:       pane.Title,
518		Pane:       pane,
519		InitialURL: initialURL,
520	})
521	if err != nil {
522		log.Error().Err(err).Msg("failed to create tab with pane")
523		return nil, err
524	}
525
526	// Set new tab as active
527	target.Tabs.SetActive(output.Tab.ID)
528
529	// Notify app before adding tab to the visible tab bar.
530	if c.onTabCreated != nil {
531		c.onTabCreated(ctx, target, output.Tab)
532	}
533
534	// Update tab bar
535	if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
536		target.MainWindow.TabBar().AddTab(output.Tab)
537		target.MainWindow.TabBar().SetActive(output.Tab.ID)
538	}
539
540	// Update tab bar visibility
541	c.UpdateBarVisibility(ctx, target)
542
543	// Attach the popup WebView to the new tab's workspace. The caller retains
544	// WebView ownership until this succeeds.
545	if c.onAttachPopupToTab != nil {
546		if err := c.onAttachPopupToTab(ctx, output.Tab.ID, pane, wv); err != nil {
547			target.Tabs.Remove(output.Tab.ID)
548			if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
549				target.MainWindow.TabBar().RemoveTab(output.Tab.ID)
550			}
551			c.UpdateBarVisibility(ctx, target)
552			if c.onTabClosed != nil {
553				c.onTabClosed(ctx, target, output.Tab)
554			}
555			return nil, fmt.Errorf("attach popup webview: %w", err)
556		}
557	}
558
559	// Switch to the new tab's workspace view
560	if c.onTabSwitched != nil {
561		c.onTabSwitched(ctx, target, output.Tab)
562	}
563
564	// Notify state change for session snapshots
565	c.notifyStateChanged()
566
567	log.Debug().
568		Str("tab_id", string(output.Tab.ID)).
569		Str("pane_id", string(pane.ID)).
570		Msg("tab created with popup pane")
571	return output.Tab, nil
572}

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