native_popup_window.go RawHistoryFolder

1package ui
2
3import (
4	"context"
5	"fmt"
6	"sync"
7
8	"github.com/bnema/dumber/internal/application/port"
9	"github.com/bnema/dumber/internal/domain/entity"
10	"github.com/bnema/dumber/internal/logging"
11	"github.com/bnema/dumber/internal/ui/coordinator/content"
12	"github.com/bnema/dumber/internal/ui/layout"
13	"github.com/bnema/dumber/internal/ui/window"
14	"github.com/bnema/puregotk/v4/gtk"
15)
16
17type nativePopupShell interface {
18	DetachContent() *gtk.Widget
19	Destroy()
20	Close()
21	Show()
22}
23
24type nativePopupWindow struct {
25	popupID        port.WebViewID
26	parentPaneID   entity.PaneID
27	parentWindowID string
28	popupWindow    nativePopupShell
29	webView        port.WebView
30	closeOnce      sync.Once
31}
32
33type nativePopupReleaseMode int
34
35const (
36	nativePopupReleaseDestroy nativePopupReleaseMode = iota
37	nativePopupReleaseDetach
38)
39
40var newPopupWindow = window.NewPopup
41
42func destroyFailedNativePopupSetup(popupShell nativePopupShell) {
43	if popupShell != nil {
44		popupShell.Destroy()
45	}
46}
47
48func prepareNativePopupContentWidget(widget layout.Widget) (*gtk.Widget, error) {
49	if widget == nil {
50		return nil, fmt.Errorf("failed to wrap native popup webview widget")
51	}
52	gtkWidget := widget.GtkWidget()
53	if gtkWidget == nil {
54		return nil, fmt.Errorf("failed to wrap native popup webview widget")
55	}
56	widget.SetHexpand(true)
57	widget.SetVexpand(true)
58	return gtkWidget, nil
59}
60
61func (a *App) ensureNativePopupWindows() {
62	if a.nativePopupWindows == nil {
63		a.nativePopupWindows = make(map[port.WebViewID]*nativePopupWindow)
64	}
65}
66
67func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePopupInput) error {
68	if a == nil || a.gtkApp == nil {
69		return fmt.Errorf("gtk application not available for native popup")
70	}
71	if input.PopupWebView == nil {
72		return fmt.Errorf("native popup webview is nil")
73	}
74
75	popupShell, err := newPopupWindow(ctx, a.gtkApp)
76	if err != nil {
77		return err
78	}
79	if a.contentCoord == nil {
80		destroyFailedNativePopupSetup(popupShell)
81		return fmt.Errorf("content coordinator not available for native popup")
82	}
83	widget := a.contentCoord.WrapWidget(ctx, input.PopupWebView)
84	gtkWidget, err := prepareNativePopupContentWidget(widget)
85	if err != nil {
86		destroyFailedNativePopupSetup(popupShell)
87		return err
88	}
89	popupShell.SetContent(gtkWidget)
90
91	parentWindowID := ""
92	if bw := a.browserWindowForAnyPane(input.ParentPaneID); bw != nil {
93		parentWindowID = bw.id
94	}
95
96	popupID := input.PopupWebView.ID()
97	state := &nativePopupWindow{
98		popupID:        popupID,
99		parentPaneID:   input.ParentPaneID,
100		parentWindowID: parentWindowID,
101		popupWindow:    popupShell,
102		webView:        input.PopupWebView,
103	}
104	popupShell.SetTitle("Dumber")
105
106	a.ensureNativePopupWindows()
107	a.nativePopupWindows[popupID] = state
108
109	if popupShell.Window() != nil {
110		closeRequestCb := func(_ gtk.Window) bool {
111			a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy)
112			return false
113		}
114		popupShell.Window().ConnectCloseRequest(&closeRequestCb)
115	}
116
117	if aborter, ok := input.PopupWebView.(port.NativePopupHostAbortCapable); ok {
118		aborter.SetNativePopupHostAbort(func() {
119			a.dispatchNativePopupLifecycle("ui.native_popup.abort", popupID, func() {
120				a.abortNativePopupWindow(context.Background(), popupID, input)
121			})
122		})
123	}
124	if lifecycle, ok := input.PopupWebView.(port.PopupLifecycleCapable); ok {
125		lifecycle.SetOnReadyToShow(func() {
126			a.dispatchNativePopupLifecycle("ui.native_popup.ready_to_show", popupID, func() {
127				a.showNativePopupWindow(popupID)
128			})
129		})
130		lifecycle.SetOnClose(func() {
131			a.dispatchNativePopupLifecycle("ui.native_popup.close", popupID, func() {
132				a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy)
133			})
134		})
135	} else {
136		popupShell.Show()
137	}
138	if oauthWV, ok := input.PopupWebView.(port.OAuthCallbackCapable); ok {
139		oauthWV.AddCloseCallback(func() {
140			a.dispatchNativePopupLifecycle("ui.native_popup.oauth_close", popupID, func() {
141				a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy)
142			})
143		})
144	}
145	if a.contentCoord != nil && input.ObserveOAuthAutoClose {
146		a.contentCoord.ObserveNativePopupAuth(ctx, input)
147	}
148
149	logging.FromContext(ctx).Info().
150		Uint64("popup_id", uint64(popupID)).
151		Str("target_uri", input.TargetURI).
152		Str("parent_pane_id", string(input.ParentPaneID)).
153		Msg("native popup host created")
154	return nil
155}
156
157func (a *App) abortNativePopupWindow(ctx context.Context, popupID port.WebViewID, input content.NativePopupInput) {
158	wv := a.releaseNativePopupWindow(popupID, nativePopupReleaseDetach)
159	if wv == nil {
160		return
161	}
162	transferred := false
163	if input.AllowBrowserWindowFallback && input.OnNativeHostAbort != nil {
164		transferred = input.OnNativeHostAbort(ctx, wv)
165	} else if input.OnNativeHostAbort != nil {
166		input.OnNativeHostAbort(ctx, wv)
167	}
168	if !transferred && !wv.IsDestroyed() {
169		wv.Destroy()
170	}
171}
172
173func (a *App) dispatchNativePopupLifecycle(label string, popupID port.WebViewID, fn func()) {
174	if a == nil || fn == nil {
175		return
176	}
177	if a.dispatchOnMainThread == nil {
178		fn()
179		return
180	}
181	result := a.dispatchOnMainThread(label, fn)
182	if result.Completed() {
183		return
184	}
185	ctx := context.Background()
186	if a.deps != nil && a.deps.Ctx != nil {
187		ctx = a.deps.Ctx
188	}
189	logging.FromContext(ctx).Warn().
190		Uint64("popup_id", uint64(popupID)).
191		Str("dispatch_label", result.Label).
192		Dur("elapsed", result.Elapsed).
193		Str("dispatch_status", string(result.Status)).
194		Msg("native popup lifecycle dispatch did not complete")
195}
196
197func (a *App) showNativePopupWindow(popupID port.WebViewID) {
198	if a == nil || a.nativePopupWindows == nil {
199		return
200	}
201	if state := a.nativePopupWindows[popupID]; state != nil && state.popupWindow != nil {
202		state.popupWindow.Show()
203	}
204}
205
206func (a *App) releaseNativePopupWindow(popupID port.WebViewID, mode nativePopupReleaseMode) port.WebView {
207	if a == nil || a.nativePopupWindows == nil {
208		return nil
209	}
210	state := a.nativePopupWindows[popupID]
211	if state == nil {
212		return nil
213	}
214	var detached port.WebView
215	state.closeOnce.Do(func() {
216		delete(a.nativePopupWindows, popupID)
217		if mode == nativePopupReleaseDetach {
218			if state.popupWindow != nil && state.popupWindow.DetachContent() != nil {
219				detached = state.webView
220			} else if state.webView != nil && !state.webView.IsDestroyed() {
221				state.webView.Destroy()
222			}
223		} else if state.webView != nil && !state.webView.IsDestroyed() {
224			state.webView.Destroy()
225		}
226		if state.popupWindow != nil {
227			state.popupWindow.Destroy()
228		}
229	})
230	return detached
231}

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