v0.29.0 21b3150

browser_window.go RawHistoryFolder

1package ui
2
3import (
4	"context"
5	"sort"
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/coordinator"
13	"github.com/bnema/dumber/internal/ui/focus"
14	"github.com/bnema/dumber/internal/ui/input"
15	"github.com/bnema/dumber/internal/ui/layout"
16	"github.com/bnema/dumber/internal/ui/window"
17	"github.com/bnema/puregotk/v4/glib"
18)
19
20type browserWindow struct {
21	id                    string
22	initialURL            string
23	tabs                  *entity.TabList // per-window tab list (single source of truth for tab state)
24	mainWindow            *window.MainWindow
25	appToaster            *component.Toaster
26	modeToaster           *component.Toaster
27	borderMgr             *focus.BorderManager
28	sessionManager        *component.SessionManager
29	tabPicker             *component.TabPicker
30	tabPickerWidget       layout.Widget
31	tabPickerPaneID       entity.PaneID
32	insertAccentUC        *usecase.InsertAccentUseCase
33	accentPicker          *component.AccentPicker
34	keyboardHandler       *input.KeyboardHandler
35	globalShortcutHandler *input.GlobalShortcutHandler
36	permissionDialog      port.PermissionDialogPresenter
37	webrtcIndicator       *component.WebRTCPermissionIndicator
38}
39
40func (bw *browserWindow) clearShellState() {
41	if bw == nil {
42		return
43	}
44	bw.appToaster = nil
45	bw.modeToaster = nil
46	bw.borderMgr = nil
47	bw.sessionManager = nil
48	bw.tabPicker = nil
49	bw.tabPickerWidget = nil
50	bw.tabPickerPaneID = ""
51	bw.insertAccentUC = nil
52	bw.accentPicker = nil
53	bw.keyboardHandler = nil
54	bw.globalShortcutHandler = nil
55	bw.permissionDialog = nil
56	bw.webrtcIndicator = nil
57}
58
59func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
60	if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
61		return
62	}
63
64	bw.initToasterOverlay(a)
65	bw.initBorderOverlay(a)
66	bw.initAccentPicker(ctx, a)
67	bw.initSessionManager(ctx, a)
68	bw.initTabPicker(ctx, a)
69}
70
71func (bw *browserWindow) initToasterOverlay(a *App) {
72	if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
73		return
74	}
75
76	bw.appToaster = component.NewToaster(a.widgetFactory)
77	if bw.appToaster != nil {
78		if widget := bw.appToaster.Widget(); widget != nil {
79			if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
80				bw.mainWindow.AddOverlay(gtkWidget)
81			}
82		}
83	}
84
85	bw.modeToaster = component.NewToaster(a.widgetFactory)
86	if bw.modeToaster != nil {
87		if widget := bw.modeToaster.Widget(); widget != nil {
88			if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
89				bw.mainWindow.AddOverlay(gtkWidget)
90			}
91		}
92	}
93}
94
95func (bw *browserWindow) initBorderOverlay(a *App) {
96	if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
97		return
98	}
99
100	bw.borderMgr = focus.NewBorderManager(a.widgetFactory)
101	if bw.borderMgr == nil {
102		return
103	}
104	if widget := bw.borderMgr.Widget(); widget != nil {
105		if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
106			bw.mainWindow.AddOverlay(gtkWidget)
107		}
108	}
109}
110
111func (bw *browserWindow) initAccentPicker(ctx context.Context, a *App) {
112	log := logging.FromContext(ctx)
113	if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil || a.deps == nil {
114		log.Debug().Msg("widget factory, deps, or main window not available, skipping accent picker")
115		return
116	}
117
118	bw.accentPicker = component.NewAccentPicker(a.widgetFactory)
119	if bw.accentPicker == nil {
120		log.Warn().Msg("failed to create accent picker")
121		return
122	}
123	if widget := bw.accentPicker.Widget(); widget != nil {
124		if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
125			bw.mainWindow.AddOverlay(gtkWidget)
126		}
127	}
128
129	a.accentFocusProvider = a.deps.AccentFocusProvider
130	bw.insertAccentUC = usecase.NewInsertAccentUseCase(
131		a.accentFocusProvider,
132		bw.accentPicker,
133		func(fn func()) {
134			cb := glib.SourceFunc(func(_ uintptr) bool {
135				fn()
136				return false
137			})
138			glib.IdleAdd(&cb, 0)
139		},
140	)
141
142	log.Debug().Msg("accent picker initialized")
143}
144
145func (bw *browserWindow) initSessionManager(ctx context.Context, a *App) {
146	log := logging.FromContext(ctx)
147	if bw == nil || a == nil || a.deps == nil || a.deps.Config == nil || bw.mainWindow == nil {
148		log.Debug().Msg("deps or main window not available, skipping session manager")
149		return
150	}
151
152	var listSessionsUC *usecase.ListSessionsUseCase
153	var deleteSessionUC *usecase.DeleteSessionUseCase
154	if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil {
155		listSessionsUC = usecase.NewListSessionsUseCase(
156			a.deps.SessionRepo,
157			a.deps.SessionStateRepo,
158		)
159		deleteSessionUC = usecase.NewDeleteSessionUseCase(
160			a.deps.SessionStateRepo,
161			a.deps.SessionRepo,
162		)
163	}
164
165	bw.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
166		ListSessionsUC:  listSessionsUC,
167		DeleteSessionUC: deleteSessionUC,
168		CurrentSession:  a.deps.CurrentSessionID,
169		UIScale:         a.deps.Config.DefaultUIScale,
170		OnClose: func() {
171			log.Debug().Msg("session manager closed")
172		},
173		OnOpen: func(sessionID entity.SessionID) {
174			log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
175			if spawner := a.deps.SessionSpawner; spawner == nil {
176				log.Warn().Str("session_id", string(sessionID)).Msg("session spawner not available")
177				return
178			} else if err := spawner.SpawnWithSession(sessionID); err != nil {
179				log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session")
180			}
181		},
182		OnToast: func(ctx context.Context, message string, level component.ToastLevel) {
183			if bw.appToaster != nil {
184				bw.appToaster.Show(ctx, message, level)
185			}
186		},
187	})
188
189	if bw.sessionManager == nil {
190		log.Warn().Msg("failed to create session manager")
191		return
192	}
193	if widget := bw.sessionManager.Widget(); widget != nil {
194		bw.mainWindow.AddOverlay(widget)
195	}
196	log.Debug().Msg("session manager initialized")
197}
198
199func (bw *browserWindow) initTabPicker(ctx context.Context, a *App) {
200	log := logging.FromContext(ctx)
201	if bw == nil || a == nil || a.deps == nil || a.deps.Config == nil {
202		log.Debug().Msg("deps/config not available, skipping tab picker")
203		return
204	}
205
206	bw.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{
207		UIScale: a.deps.Config.DefaultUIScale,
208		OnClose: func() {
209			log.Debug().Msg("tab picker closed")
210		},
211		OnSelect: func(item component.TabPickerItem) {
212			cb := glib.SourceFunc(func(_ uintptr) bool {
213				var targetID entity.TabID
214				if !item.IsNew {
215					targetID = item.TabID
216				}
217				if err := a.moveActivePaneToTabFromBrowserWindow(ctx, bw, targetID); err != nil {
218					log.Warn().Err(err).Msg("move pane to tab failed")
219				}
220				return false
221			})
222			glib.IdleAdd(&cb, 0)
223		},
224	})
225
226	if bw.tabPicker == nil {
227		log.Warn().Msg("failed to create tab picker")
228		return
229	}
230
231	log.Debug().Msg("tab picker initialized")
232}
233
234func (bw *browserWindow) ensureTabs() {
235	if bw != nil && bw.tabs == nil {
236		bw.tabs = entity.NewTabList()
237	}
238}
239
240func (a *App) registerBrowserWindow(bw *browserWindow) {
241	if bw == nil {
242		return
243	}
244	bw.ensureTabs()
245	if a.browserWindows == nil {
246		a.browserWindows = make(map[string]*browserWindow)
247	}
248	a.browserWindows[bw.id] = bw
249
250	// Track registration order, guarding against duplicates.
251	for _, id := range a.browserWindowOrder {
252		if id == bw.id {
253			return
254		}
255	}
256	a.browserWindowOrder = append(a.browserWindowOrder, bw.id)
257}
258
259func (a *App) removeBrowserWindow(id string) {
260	if id == "" || a.browserWindows == nil {
261		return
262	}
263	removed := a.browserWindows[id]
264
265	// Remove from registration order
266	for i, wid := range a.browserWindowOrder {
267		if wid == id {
268			a.browserWindowOrder = append(a.browserWindowOrder[:i], a.browserWindowOrder[i+1:]...)
269			break
270		}
271	}
272	wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow
273	ownedTabIDs := make([]entity.TabID, 0)
274	for tabID, bw := range a.windowForTab {
275		if bw != nil && bw.id == id {
276			ownedTabIDs = append(ownedTabIDs, tabID)
277		}
278	}
279	for _, tabID := range ownedTabIDs {
280		a.releaseFloatingSessionsForTab(context.Background(), tabID)
281		delete(a.workspaceViews, tabID)
282		delete(a.windowForTab, tabID)
283		if a.tabs != nil && a.tabs.Find(tabID) != nil {
284			a.tabs.Remove(tabID)
285		}
286	}
287	if removed != nil {
288		removed.clearShellState()
289	}
290	if a.contentCoord != nil {
291		a.contentCoord.ClearPopupNamedContextsForWindow(id)
292	}
293	delete(a.browserWindows, id)
294	fallback := a.deterministicBrowserWindowFallback()
295	if a.lastFocusedWindowID == id {
296		if fallback != nil {
297			a.lastFocusedWindowID = fallback.id
298		} else {
299			a.lastFocusedWindowID = ""
300		}
301	}
302	if wasMainWindow {
303		a.clearResizeModeBorder()
304		if fallback != nil {
305			a.activateBrowserWindow(fallback)
306			return
307		}
308		a.lastFocusedWindowID = ""
309		a.mainWindow = nil
310		a.keyboardHandler = nil
311		a.globalShortcutHandler = nil
312		if a.tabCoord != nil {
313			a.tabCoord.SetCurrentTarget(coordinator.TabTarget{})
314		}
315	}
316}
317
318func (a *App) deterministicBrowserWindowFallback() *browserWindow {
319	if a.browserWindows == nil {
320		return nil
321	}
322	for _, id := range a.browserWindowOrder {
323		bw := a.browserWindows[id]
324		if id == "" || bw == nil || bw.mainWindow == nil {
325			continue
326		}
327		return bw
328	}
329	ids := make([]string, 0, len(a.browserWindows))
330	for id, bw := range a.browserWindows {
331		if id == "" || bw == nil || bw.mainWindow == nil {
332			continue
333		}
334		ids = append(ids, id)
335	}
336	sort.Strings(ids)
337	if len(ids) == 0 {
338		return nil
339	}
340	return a.browserWindows[ids[0]]
341}
342
343func (a *App) OpenFreshWindow(ctx context.Context, url string) error {
344	dispatch := a.dispatchOnMainThread
345	if dispatch == nil {
346		dispatch = func(fn func()) { fn() }
347	}
348
349	var openErr error
350	dispatch(func() {
351		openErr = a.openFreshWindow(ctx, url)
352	})
353
354	return openErr
355}

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