v0.23.0 d05d132

tab.go RawHistoryFolder

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

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