main 5041353

permission_popup.go RawHistoryFolder

1package component
2
3import (
4	"context"
5	"sync"
6
7	"github.com/bnema/dumber/internal/logging"
8	"github.com/bnema/dumber/internal/ui/layout"
9	"github.com/bnema/puregotk/v4/gdk"
10	"github.com/bnema/puregotk/v4/gtk"
11)
12
13// buttonSpacing is the spacing between buttons in the permission popup.
14const buttonSpacing = 6
15
16// PermissionPopup is a custom overlay component for permission prompts.
17// It replaces the Adwaita AlertDialog to sidestep the purego ConnectResponse bug
18// and match the app's custom UI style.
19type PermissionPopup struct {
20	outerBox *gtk.Box
21	mainBox  *gtk.Box
22
23	headingLabel *gtk.Label
24	bodyLabel    *gtk.Label
25
26	btnAllow       *gtk.Button
27	btnAlwaysAllow *gtk.Button
28	btnDeny        *gtk.Button
29	btnAlwaysDeny  *gtk.Button
30
31	parentOverlay layout.OverlayWidget
32	uiScale       float64
33
34	mu       sync.Mutex
35	visible  bool
36	callback func(allowed, persistent bool)
37
38	retainedCallbacks []any
39}
40
41// NewPermissionPopup creates a new permission popup component.
42func NewPermissionPopup(parentOverlay layout.OverlayWidget, uiScale float64) *PermissionPopup {
43	if uiScale <= 0 {
44		uiScale = 1.0
45	}
46
47	pp := &PermissionPopup{
48		parentOverlay: parentOverlay,
49		uiScale:       uiScale,
50	}
51
52	if err := pp.createWidgets(); err != nil {
53		return nil
54	}
55	pp.attachKeyController()
56	return pp
57}
58
59// Widget returns the outer GTK widget for overlay registration.
60func (pp *PermissionPopup) Widget() *gtk.Widget {
61	if pp.outerBox == nil {
62		return nil
63	}
64	return &pp.outerBox.Widget
65}
66
67// Show displays the permission popup with the given heading and body text.
68// When persistable is false the "Always Allow/Deny" buttons are hidden.
69// The callback receives (allowed, persistent) when the user makes a choice.
70func (pp *PermissionPopup) Show(ctx context.Context, heading, body string, persistable bool, callback func(allowed, persistent bool)) {
71	log := logging.FromContext(ctx)
72
73	pp.mu.Lock()
74	if pp.visible {
75		pp.mu.Unlock()
76		log.Warn().Msg("permission popup already visible, ignoring Show")
77		return
78	}
79	pp.visible = true
80	pp.callback = callback
81	pp.mu.Unlock()
82
83	if pp.headingLabel != nil {
84		pp.headingLabel.SetText(heading)
85	}
86	if pp.bodyLabel != nil {
87		pp.bodyLabel.SetText(body)
88	}
89	if pp.btnAlwaysAllow != nil {
90		pp.btnAlwaysAllow.SetVisible(persistable)
91	}
92	if pp.btnAlwaysDeny != nil {
93		pp.btnAlwaysDeny.SetVisible(persistable)
94	}
95
96	pp.resizeAndCenter()
97	if pp.outerBox != nil {
98		pp.outerBox.SetVisible(true)
99	}
100	// Focus the Deny button as the conservative default
101	if pp.btnDeny != nil {
102		pp.btnDeny.GrabFocus()
103	}
104}
105
106// Hide hides the popup without invoking the callback.
107func (pp *PermissionPopup) Hide() {
108	pp.mu.Lock()
109	if !pp.visible {
110		pp.mu.Unlock()
111		return
112	}
113	pp.visible = false
114	pp.callback = nil
115	pp.mu.Unlock()
116
117	if pp.outerBox != nil {
118		pp.outerBox.SetVisible(false)
119	}
120}
121
122// IsVisible returns whether the popup is currently displayed.
123func (pp *PermissionPopup) IsVisible() bool {
124	pp.mu.Lock()
125	defer pp.mu.Unlock()
126	return pp.visible
127}
128
129func (pp *PermissionPopup) dismiss(allowed, persistent bool) {
130	pp.mu.Lock()
131	if !pp.visible {
132		pp.mu.Unlock()
133		return
134	}
135	pp.visible = false
136	cb := pp.callback
137	pp.callback = nil
138	pp.mu.Unlock()
139
140	if pp.outerBox != nil {
141		pp.outerBox.SetVisible(false)
142	}
143	if cb != nil {
144		cb(allowed, persistent)
145	}
146}
147
148// setupContainers creates and configures the outer and main boxes.
149func (pp *PermissionPopup) setupContainers() error {
150	pp.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
151	if pp.outerBox == nil {
152		return errNilWidget("permissionPopupOuterBox")
153	}
154	pp.outerBox.AddCssClass("permission-popup-outer")
155	pp.outerBox.SetHalign(gtk.AlignCenterValue)
156	pp.outerBox.SetValign(gtk.AlignStartValue)
157	pp.outerBox.SetVisible(false)
158
159	pp.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
160	if pp.mainBox == nil {
161		return errNilWidget("permissionPopupMainBox")
162	}
163	pp.mainBox.AddCssClass("permission-popup-container")
164	return nil
165}
166
167// setupLabels creates and configures the heading and body labels.
168func (pp *PermissionPopup) setupLabels() error {
169	emptyText := ""
170	pp.headingLabel = gtk.NewLabel(&emptyText)
171	if pp.headingLabel == nil {
172		return errNilWidget("permissionPopupHeadingLabel")
173	}
174	pp.headingLabel.AddCssClass("permission-popup-heading")
175	pp.headingLabel.SetHalign(gtk.AlignStartValue)
176
177	pp.bodyLabel = gtk.NewLabel(&emptyText)
178	if pp.bodyLabel == nil {
179		return errNilWidget("permissionPopupBodyLabel")
180	}
181	pp.bodyLabel.AddCssClass("permission-popup-body")
182	pp.bodyLabel.SetHalign(gtk.AlignStartValue)
183	pp.bodyLabel.SetWrap(true)
184	return nil
185}
186
187func (pp *PermissionPopup) createWidgets() error {
188	if err := pp.setupContainers(); err != nil {
189		return err
190	}
191
192	if err := pp.setupLabels(); err != nil {
193		return err
194	}
195
196	// Button row
197	btnRow := gtk.NewBox(gtk.OrientationHorizontalValue, buttonSpacing)
198	if btnRow == nil {
199		return errNilWidget("permissionPopupBtnRow")
200	}
201	btnRow.AddCssClass("permission-popup-btn-row")
202	btnRow.SetHalign(gtk.AlignEndValue)
203
204	// Create buttons: Always Deny | Deny | Allow | Always Allow
205	var err error
206	pp.btnAlwaysDeny, err = pp.createPermissionButton("Always Deny", []string{"permission-popup-btn", "permission-popup-btn-destructive"})
207	if err != nil {
208		return err
209	}
210
211	pp.btnDeny, err = pp.createPermissionButton("Deny", []string{"permission-popup-btn", "permission-popup-btn-deny"})
212	if err != nil {
213		return err
214	}
215
216	pp.btnAllow, err = pp.createPermissionButton("Allow", []string{"permission-popup-btn", "permission-popup-btn-allow"})
217	if err != nil {
218		return err
219	}
220
221	pp.btnAlwaysAllow, err = pp.createPermissionButton("Always Allow", []string{"permission-popup-btn", "permission-popup-btn-allow"})
222	if err != nil {
223		return err
224	}
225
226	// Wire button clicks
227	pp.wireButton(pp.btnAlwaysDeny, false, true)
228	pp.wireButton(pp.btnDeny, false, false)
229	pp.wireButton(pp.btnAllow, true, false)
230	pp.wireButton(pp.btnAlwaysAllow, true, true)
231
232	// Assemble button row
233	btnRow.Append(&pp.btnAlwaysDeny.Widget)
234	btnRow.Append(&pp.btnDeny.Widget)
235	btnRow.Append(&pp.btnAllow.Widget)
236	btnRow.Append(&pp.btnAlwaysAllow.Widget)
237
238	// Assemble main box
239	pp.mainBox.Append(&pp.headingLabel.Widget)
240	pp.mainBox.Append(&pp.bodyLabel.Widget)
241	pp.mainBox.Append(&btnRow.Widget)
242
243	// Assemble outer box
244	pp.outerBox.Append(&pp.mainBox.Widget)
245
246	return nil
247}
248
249// createPermissionButton creates a permission button with the given label and CSS classes.
250func (pp *PermissionPopup) createPermissionButton(label string, cssClasses []string) (*gtk.Button, error) {
251	btn := gtk.NewButtonWithLabel(label)
252	if btn == nil {
253		return nil, errNilWidget("permissionPopupBtn" + label)
254	}
255	for _, class := range cssClasses {
256		btn.AddCssClass(class)
257	}
258	return btn, nil
259}
260
261// wireButton connects a button click to the dismiss callback.
262func (pp *PermissionPopup) wireButton(btn *gtk.Button, allowed, persistent bool) {
263	cb := func(_ gtk.Button) { pp.dismiss(allowed, persistent) }
264	pp.retainedCallbacks = append(pp.retainedCallbacks, cb)
265	btn.ConnectClicked(&cb)
266}
267
268func (pp *PermissionPopup) attachKeyController() {
269	if pp.outerBox == nil {
270		return
271	}
272	controller := gtk.NewEventControllerKey()
273	if controller == nil {
274		return
275	}
276	controller.SetPropagationPhase(gtk.PhaseCaptureValue)
277
278	keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
279		if keyval == uint(gdk.KEY_Escape) {
280			// Escape = deny (conservative default)
281			pp.dismiss(false, false)
282			return true
283		}
284		return false
285	}
286	pp.retainedCallbacks = append(pp.retainedCallbacks, keyPressedCb)
287	controller.ConnectKeyPressed(&keyPressedCb)
288	pp.outerBox.AddController(&controller.EventController)
289}
290
291func (pp *PermissionPopup) resizeAndCenter() {
292	if pp.outerBox == nil || pp.mainBox == nil {
293		return
294	}
295
296	width, marginTop := CalculateModalDimensions(pp.parentOverlay, PermissionPopupSizeDefaults)
297	pp.mainBox.SetSizeRequest(width, -1)
298	pp.outerBox.SetMarginTop(marginTop)
299}

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