v0.27.2 5960dd8

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/jwijenbergh/puregotk/v4/gdk"
10	"github.com/jwijenbergh/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 []interface{}
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// The callback receives (allowed, persistent) when the user makes a choice.
69func (pp *PermissionPopup) Show(ctx context.Context, heading, body string, callback func(allowed, persistent bool)) {
70	log := logging.FromContext(ctx)
71
72	pp.mu.Lock()
73	if pp.visible {
74		pp.mu.Unlock()
75		log.Warn().Msg("permission popup already visible, ignoring Show")
76		return
77	}
78	pp.visible = true
79	pp.callback = callback
80	pp.mu.Unlock()
81
82	if pp.headingLabel != nil {
83		pp.headingLabel.SetText(heading)
84	}
85	if pp.bodyLabel != nil {
86		pp.bodyLabel.SetText(body)
87	}
88
89	pp.resizeAndCenter()
90	if pp.outerBox != nil {
91		pp.outerBox.SetVisible(true)
92	}
93	// Focus the Deny button as the conservative default
94	if pp.btnDeny != nil {
95		pp.btnDeny.GrabFocus()
96	}
97}
98
99// Hide hides the popup without invoking the callback.
100func (pp *PermissionPopup) Hide() {
101	pp.mu.Lock()
102	if !pp.visible {
103		pp.mu.Unlock()
104		return
105	}
106	pp.visible = false
107	pp.callback = nil
108	pp.mu.Unlock()
109
110	if pp.outerBox != nil {
111		pp.outerBox.SetVisible(false)
112	}
113}
114
115// IsVisible returns whether the popup is currently displayed.
116func (pp *PermissionPopup) IsVisible() bool {
117	pp.mu.Lock()
118	defer pp.mu.Unlock()
119	return pp.visible
120}
121
122func (pp *PermissionPopup) dismiss(allowed, persistent bool) {
123	pp.mu.Lock()
124	if !pp.visible {
125		pp.mu.Unlock()
126		return
127	}
128	pp.visible = false
129	cb := pp.callback
130	pp.callback = nil
131	pp.mu.Unlock()
132
133	if pp.outerBox != nil {
134		pp.outerBox.SetVisible(false)
135	}
136	if cb != nil {
137		cb(allowed, persistent)
138	}
139}
140
141// setupContainers creates and configures the outer and main boxes.
142func (pp *PermissionPopup) setupContainers() error {
143	pp.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
144	if pp.outerBox == nil {
145		return errNilWidget("permissionPopupOuterBox")
146	}
147	pp.outerBox.AddCssClass("permission-popup-outer")
148	pp.outerBox.SetHalign(gtk.AlignCenterValue)
149	pp.outerBox.SetValign(gtk.AlignStartValue)
150	pp.outerBox.SetVisible(false)
151
152	pp.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
153	if pp.mainBox == nil {
154		return errNilWidget("permissionPopupMainBox")
155	}
156	pp.mainBox.AddCssClass("permission-popup-container")
157	return nil
158}
159
160// setupLabels creates and configures the heading and body labels.
161func (pp *PermissionPopup) setupLabels() error {
162	emptyText := ""
163	pp.headingLabel = gtk.NewLabel(&emptyText)
164	if pp.headingLabel == nil {
165		return errNilWidget("permissionPopupHeadingLabel")
166	}
167	pp.headingLabel.AddCssClass("permission-popup-heading")
168	pp.headingLabel.SetHalign(gtk.AlignStartValue)
169
170	pp.bodyLabel = gtk.NewLabel(&emptyText)
171	if pp.bodyLabel == nil {
172		return errNilWidget("permissionPopupBodyLabel")
173	}
174	pp.bodyLabel.AddCssClass("permission-popup-body")
175	pp.bodyLabel.SetHalign(gtk.AlignStartValue)
176	pp.bodyLabel.SetWrap(true)
177	return nil
178}
179
180func (pp *PermissionPopup) createWidgets() error {
181	if err := pp.setupContainers(); err != nil {
182		return err
183	}
184
185	if err := pp.setupLabels(); err != nil {
186		return err
187	}
188
189	// Button row
190	btnRow := gtk.NewBox(gtk.OrientationHorizontalValue, buttonSpacing)
191	if btnRow == nil {
192		return errNilWidget("permissionPopupBtnRow")
193	}
194	btnRow.AddCssClass("permission-popup-btn-row")
195	btnRow.SetHalign(gtk.AlignEndValue)
196
197	// Create buttons: Always Deny | Deny | Allow | Always Allow
198	var err error
199	pp.btnAlwaysDeny, err = pp.createPermissionButton("Always Deny", []string{"permission-popup-btn", "permission-popup-btn-destructive"})
200	if err != nil {
201		return err
202	}
203
204	pp.btnDeny, err = pp.createPermissionButton("Deny", []string{"permission-popup-btn", "permission-popup-btn-deny"})
205	if err != nil {
206		return err
207	}
208
209	pp.btnAllow, err = pp.createPermissionButton("Allow", []string{"permission-popup-btn", "permission-popup-btn-allow"})
210	if err != nil {
211		return err
212	}
213
214	pp.btnAlwaysAllow, err = pp.createPermissionButton("Always Allow", []string{"permission-popup-btn", "permission-popup-btn-allow"})
215	if err != nil {
216		return err
217	}
218
219	// Wire button clicks
220	pp.wireButton(pp.btnAlwaysDeny, false, true)
221	pp.wireButton(pp.btnDeny, false, false)
222	pp.wireButton(pp.btnAllow, true, false)
223	pp.wireButton(pp.btnAlwaysAllow, true, true)
224
225	// Assemble button row
226	btnRow.Append(&pp.btnAlwaysDeny.Widget)
227	btnRow.Append(&pp.btnDeny.Widget)
228	btnRow.Append(&pp.btnAllow.Widget)
229	btnRow.Append(&pp.btnAlwaysAllow.Widget)
230
231	// Assemble main box
232	pp.mainBox.Append(&pp.headingLabel.Widget)
233	pp.mainBox.Append(&pp.bodyLabel.Widget)
234	pp.mainBox.Append(&btnRow.Widget)
235
236	// Assemble outer box
237	pp.outerBox.Append(&pp.mainBox.Widget)
238
239	return nil
240}
241
242// createPermissionButton creates a permission button with the given label and CSS classes.
243func (pp *PermissionPopup) createPermissionButton(label string, cssClasses []string) (*gtk.Button, error) {
244	btn := gtk.NewButtonWithLabel(label)
245	if btn == nil {
246		return nil, errNilWidget("permissionPopupBtn" + label)
247	}
248	for _, class := range cssClasses {
249		btn.AddCssClass(class)
250	}
251	return btn, nil
252}
253
254// wireButton connects a button click to the dismiss callback.
255func (pp *PermissionPopup) wireButton(btn *gtk.Button, allowed, persistent bool) {
256	cb := func(_ gtk.Button) { pp.dismiss(allowed, persistent) }
257	pp.retainedCallbacks = append(pp.retainedCallbacks, cb)
258	btn.ConnectClicked(&cb)
259}
260
261func (pp *PermissionPopup) attachKeyController() {
262	if pp.outerBox == nil {
263		return
264	}
265	controller := gtk.NewEventControllerKey()
266	if controller == nil {
267		return
268	}
269	controller.SetPropagationPhase(gtk.PhaseCaptureValue)
270
271	keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
272		if keyval == uint(gdk.KEY_Escape) {
273			// Escape = deny (conservative default)
274			pp.dismiss(false, false)
275			return true
276		}
277		return false
278	}
279	pp.retainedCallbacks = append(pp.retainedCallbacks, keyPressedCb)
280	controller.ConnectKeyPressed(&keyPressedCb)
281	pp.outerBox.AddController(&controller.EventController)
282}
283
284func (pp *PermissionPopup) resizeAndCenter() {
285	if pp.outerBox == nil || pp.mainBox == nil {
286		return
287	}
288
289	width, marginTop := CalculateModalDimensions(pp.parentOverlay, PermissionPopupSizeDefaults)
290	pp.mainBox.SetSizeRequest(width, -1)
291	pp.outerBox.SetMarginTop(marginTop)
292}

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