v0.28.0 3cb3770

tab.go RawHistoryFolder

1package coordinator
2
3import (
4	"context"
5
6	"github.com/bnema/dumber/internal/application/port"
7	"github.com/bnema/dumber/internal/application/usecase"
8	"github.com/bnema/dumber/internal/domain/entity"
9	"github.com/bnema/dumber/internal/logging"
10	"github.com/bnema/dumber/internal/ui/component"
11	"github.com/bnema/dumber/internal/ui/window"
12)
13
14// TabCoordinator manages tab lifecycle operations.
15type TabCoordinator struct {
16	tabsUC                  *usecase.ManageTabsUseCase
17	tabs                    *entity.TabList
18	mainWindow              *window.MainWindow
19	hideTabBarWhenSingleTab bool
20
21	// Callbacks to avoid circular dependencies
22	onTabCreated       func(ctx context.Context, tab *entity.Tab)
23	onTabSwitched      func(ctx context.Context, tab *entity.Tab)
24	onQuit             func()
25	onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) // For popup tabs
26	onStateChanged     func()                                                                            // For session snapshots
27}
28
29// TabCoordinatorConfig holds configuration for TabCoordinator.
30type TabCoordinatorConfig struct {
31	TabsUC                  *usecase.ManageTabsUseCase
32	Tabs                    *entity.TabList
33	MainWindow              *window.MainWindow
34	HideTabBarWhenSingleTab bool
35}
36
37// NewTabCoordinator creates a new TabCoordinator.
38func NewTabCoordinator(ctx context.Context, cfg TabCoordinatorConfig) *TabCoordinator {
39	log := logging.FromContext(ctx)
40	log.Debug().Msg("creating tab coordinator")
41
42	return &TabCoordinator{
43		tabsUC:                  cfg.TabsUC,
44		tabs:                    cfg.Tabs,
45		mainWindow:              cfg.MainWindow,
46		hideTabBarWhenSingleTab: cfg.HideTabBarWhenSingleTab,
47	}
48}
49
50// SetOnTabCreated sets the callback for when a tab is created.
51func (c *TabCoordinator) SetOnTabCreated(fn func(ctx context.Context, tab *entity.Tab)) {
52	c.onTabCreated = fn
53}
54
55// SetOnTabSwitched sets the callback for when a tab switch occurs.
56// This is used to swap workspace views in the content area.
57func (c *TabCoordinator) SetOnTabSwitched(fn func(ctx context.Context, tab *entity.Tab)) {
58	c.onTabSwitched = fn
59}
60
61// SetOnQuit sets the callback for when the last tab is closed.
62func (c *TabCoordinator) SetOnQuit(fn func()) {
63	c.onQuit = fn
64}
65
66// SetOnStateChanged sets the callback for when tab state changes (for session snapshots).
67func (c *TabCoordinator) SetOnStateChanged(fn func()) {
68	c.onStateChanged = fn
69}
70
71// notifyStateChanged triggers the state changed callback if set.
72func (c *TabCoordinator) notifyStateChanged() {
73	if c.onStateChanged != nil {
74		c.onStateChanged()
75	}
76}
77
78// Create creates a new tab with the given initial URL.
79func (c *TabCoordinator) Create(ctx context.Context, initialURL string) (*entity.Tab, error) {
80	log := logging.FromContext(ctx)
81
82	output, err := c.tabsUC.Create(ctx, usecase.CreateTabInput{
83		TabList:    c.tabs,
84		Name:       "",
85		InitialURL: initialURL,
86	})
87	if err != nil {
88		log.Error().Err(err).Msg("failed to create tab")
89		return nil, err
90	}
91
92	// Set new tab as active (updates domain state and tracks previous)
93	c.tabs.SetActive(output.Tab.ID)
94
95	// Update tab bar
96	if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
97		c.mainWindow.TabBar().AddTab(output.Tab)
98		c.mainWindow.TabBar().SetActive(output.Tab.ID)
99	}
100
101	// Update tab bar visibility
102	c.UpdateBarVisibility(ctx)
103
104	// Notify app to create workspace view
105	if c.onTabCreated != nil {
106		c.onTabCreated(ctx, output.Tab)
107	}
108
109	// Switch to the new tab's workspace view
110	if c.onTabSwitched != nil {
111		c.onTabSwitched(ctx, output.Tab)
112	}
113
114	// Notify state change for session snapshots
115	c.notifyStateChanged()
116
117	log.Debug().Str("tab_id", string(output.Tab.ID)).Msg("tab created")
118	return output.Tab, nil
119}
120
121// Close closes the active tab.
122func (c *TabCoordinator) Close(ctx context.Context) error {
123	log := logging.FromContext(ctx)
124
125	activeID := c.tabs.ActiveTabID
126	if activeID == "" {
127		log.Debug().Msg("no active tab to close")
128		return nil
129	}
130
131	wasLast, err := c.tabsUC.Close(ctx, c.tabs, activeID)
132	if err != nil {
133		log.Error().Err(err).Msg("failed to close tab")
134		return err
135	}
136
137	if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
138		c.mainWindow.TabBar().RemoveTab(activeID)
139
140		// Switch to new active tab if any
141		if c.tabs.ActiveTabID != "" {
142			c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
143		}
144	}
145
146	// Update tab bar visibility
147	c.UpdateBarVisibility(ctx)
148
149	// Quit if no tabs left
150	if wasLast && c.onQuit != nil {
151		c.onQuit()
152	}
153
154	// Switch workspace view to new active tab (if not last)
155	if !wasLast && c.onTabSwitched != nil {
156		if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
157			c.onTabSwitched(ctx, tab)
158		}
159	}
160
161	// Notify state change for session snapshots (unless quitting)
162	if !wasLast {
163		c.notifyStateChanged()
164	}
165
166	log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed")
167	return nil
168}
169
170// Switch switches to a specific tab by ID.
171func (c *TabCoordinator) Switch(ctx context.Context, tabID entity.TabID) error {
172	log := logging.FromContext(ctx)
173
174	// Skip if already active
175	if tabID == c.tabs.ActiveTabID {
176		return nil
177	}
178
179	log.Debug().
180		Str("from", string(c.tabs.ActiveTabID)).
181		Str("to", string(tabID)).
182		Msg("switching tab")
183
184	// Update domain state
185	if err := c.tabsUC.Switch(ctx, c.tabs, tabID); err != nil {
186		log.Error().Err(err).Str("tab_id", string(tabID)).Msg("failed to switch tab")
187		return err
188	}
189
190	// Update tab bar UI
191	if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
192		c.mainWindow.TabBar().SetActive(tabID)
193	}
194
195	// Invoke callback for workspace view switching
196	if c.onTabSwitched != nil {
197		if tab := c.tabs.Find(tabID); tab != nil {
198			c.onTabSwitched(ctx, tab)
199		}
200	}
201
202	return nil
203}
204
205// SwitchNext switches to the next tab.
206func (c *TabCoordinator) SwitchNext(ctx context.Context) error {
207	log := logging.FromContext(ctx)
208
209	if err := c.tabsUC.SwitchNext(ctx, c.tabs); err != nil {
210		log.Error().Err(err).Msg("failed to switch to next tab")
211		return err
212	}
213
214	if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
215		c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
216	}
217
218	// Invoke callback for workspace view switching
219	if c.onTabSwitched != nil {
220		if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
221			c.onTabSwitched(ctx, tab)
222		}
223	}
224
225	log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to next tab")
226	return nil
227}
228
229// SwitchPrev switches to the previous tab.
230func (c *TabCoordinator) SwitchPrev(ctx context.Context) error {
231	log := logging.FromContext(ctx)
232
233	if err := c.tabsUC.SwitchPrevious(ctx, c.tabs); err != nil {
234		log.Error().Err(err).Msg("failed to switch to previous tab")
235		return err
236	}
237
238	if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
239		c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
240	}
241
242	// Invoke callback for workspace view switching
243	if c.onTabSwitched != nil {
244		if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
245			c.onTabSwitched(ctx, tab)
246		}
247	}
248
249	log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to previous tab")
250	return nil
251}
252
253// SwitchByIndex switches to a tab by 0-based index.
254func (c *TabCoordinator) SwitchByIndex(ctx context.Context, index int) error {
255	log := logging.FromContext(ctx)
256
257	if err := c.tabsUC.SwitchByIndex(ctx, c.tabs, index); err != nil {
258		log.Error().Err(err).Int("index", index).Msg("failed to switch to tab by index")
259		return err
260	}
261
262	if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
263		c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
264	}
265
266	// Invoke callback for workspace view switching
267	if c.onTabSwitched != nil {
268		if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
269			c.onTabSwitched(ctx, tab)
270		}
271	}
272
273	log.Debug().Int("index", index).Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to tab by index")
274	return nil
275}
276
277// SwitchToLastActive switches to the previously active tab (Alt+Tab style).
278func (c *TabCoordinator) SwitchToLastActive(ctx context.Context) error {
279	log := logging.FromContext(ctx)
280
281	if err := c.tabsUC.SwitchToLastActive(ctx, c.tabs); err != nil {
282		log.Error().Err(err).Msg("failed to switch to last active tab")
283		return err
284	}
285
286	if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
287		c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
288	}
289
290	// Invoke callback for workspace view switching
291	if c.onTabSwitched != nil {
292		if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
293			c.onTabSwitched(ctx, tab)
294		}
295	}
296
297	log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to last active tab")
298	return nil
299}
300
301// UpdateBarVisibility shows or hides the tab bar based on tab count.
302func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context) {
303	log := logging.FromContext(ctx)
304
305	// Check if feature is enabled
306	hideEnabled := c.hideTabBarWhenSingleTab
307
308	if !hideEnabled {
309		log.Debug().Msg("tab bar auto-hide disabled by config")
310		return
311	}
312
313	if c.mainWindow == nil || c.mainWindow.TabBar() == nil {
314		log.Debug().Msg("mainWindow or tabBar is nil, skipping visibility update")
315		return
316	}
317
318	tabCount := c.tabs.Count()
319	shouldShow := tabCount > 1
320
321	log.Debug().Int("tab_count", tabCount).Bool("should_show", shouldShow).Msg("setting tab bar visibility")
322	c.mainWindow.TabBar().SetVisible(shouldShow)
323}
324
325// GetTabBar returns the tab bar component.
326func (c *TabCoordinator) GetTabBar() *component.TabBar {
327	if c.mainWindow != nil {
328		return c.mainWindow.TabBar()
329	}
330	return nil
331}
332
333// SetOnAttachPopupToTab sets the callback for attaching popup WebViews to tabs.
334func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView)) {
335	c.onAttachPopupToTab = fn
336}
337
338// CreateWithPane creates a new tab with a pre-created pane and WebView.
339// This is used for tabbed popup behavior where the popup pane already exists.
340func (c *TabCoordinator) CreateWithPane(
341	ctx context.Context,
342	pane *entity.Pane,
343	wv port.WebView,
344	initialURL string,
345) (*entity.Tab, error) {
346	log := logging.FromContext(ctx)
347
348	output, err := c.tabsUC.CreateWithPane(ctx, usecase.CreateTabWithPaneInput{
349		TabList:    c.tabs,
350		Name:       pane.Title,
351		Pane:       pane,
352		InitialURL: initialURL,
353	})
354	if err != nil {
355		log.Error().Err(err).Msg("failed to create tab with pane")
356		return nil, err
357	}
358
359	// Set new tab as active
360	c.tabs.SetActive(output.Tab.ID)
361
362	// Update tab bar
363	if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
364		c.mainWindow.TabBar().AddTab(output.Tab)
365		c.mainWindow.TabBar().SetActive(output.Tab.ID)
366	}
367
368	// Update tab bar visibility
369	c.UpdateBarVisibility(ctx)
370
371	// Notify app to create workspace view
372	if c.onTabCreated != nil {
373		c.onTabCreated(ctx, output.Tab)
374	}
375
376	// Attach the popup WebView to the new tab's workspace
377	if c.onAttachPopupToTab != nil {
378		c.onAttachPopupToTab(ctx, output.Tab.ID, pane, wv)
379	}
380
381	// Switch to the new tab's workspace view
382	if c.onTabSwitched != nil {
383		c.onTabSwitched(ctx, output.Tab)
384	}
385
386	// Notify state change for session snapshots
387	c.notifyStateChanged()
388
389	log.Debug().
390		Str("tab_id", string(output.Tab.ID)).
391		Str("pane_id", string(pane.ID)).
392		Msg("tab created with popup pane")
393	return output.Tab, nil
394}

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