messaging.go RawHistoryFolder

1package cef
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"sync"
9
10	"github.com/bnema/dumber/internal/application/port"
11	"github.com/bnema/dumber/internal/logging"
12)
13
14// Compile-time check: MessageRouter implements port.WebUIHandlerRouter.
15var _ port.WebUIHandlerRouter = (*MessageRouter)(nil)
16
17// Message is the JS -> Go message envelope sent via fetch to /api/message.
18type Message = port.WebUIMessage
19
20type handlerEntry struct {
21	handler       portHandlerAdapter
22	callback      string
23	errorCallback string
24	world         string
25}
26
27// MessageRouter dispatches fetch-based message events to registered handlers.
28type MessageRouter struct {
29	handlers map[string]handlerEntry
30	baseCtx  context.Context
31	mu       sync.RWMutex
32}
33
34// NewMessageRouter creates a new message router.
35func NewMessageRouter(ctx context.Context) *MessageRouter {
36	if ctx == nil {
37		ctx = context.Background()
38	}
39	return &MessageRouter{
40		handlers: make(map[string]handlerEntry),
41		baseCtx:  ctx,
42	}
43}
44
45func (r *MessageRouter) SetBaseContext(ctx context.Context) {
46	if r == nil {
47		return
48	}
49	if ctx == nil {
50		ctx = context.Background()
51	}
52	r.mu.Lock()
53	r.baseCtx = ctx
54	r.mu.Unlock()
55}
56
57// RegisterHandler registers a handler for a message type.
58// Satisfies port.WebUIHandlerRouter.
59func (r *MessageRouter) RegisterHandler(msgType string, handler port.WebUIMessageHandler) error {
60	if msgType == "" {
61		return errors.New("message type cannot be empty")
62	}
63	if handler == nil {
64		return errors.New("message handler cannot be nil")
65	}
66	r.mu.Lock()
67	defer r.mu.Unlock()
68	r.handlers[msgType] = handlerEntry{handler: portHandlerAdapter{handler}}
69	return nil
70}
71
72// RegisterHandlerWithCallbacks registers a handler with response callback names.
73// callback is invoked on success, errorCallback (optional) on failure.
74// worldName allows targeting a specific script world (unused in CEF fetch bridge).
75// Satisfies port.WebUIHandlerRouter.
76func (r *MessageRouter) RegisterHandlerWithCallbacks(
77	msgType, callback, errorCallback, worldName string, handler port.WebUIMessageHandler,
78) error {
79	if msgType == "" {
80		return errors.New("message type cannot be empty")
81	}
82	if handler == nil {
83		return errors.New("message handler cannot be nil")
84	}
85	if callback == "" {
86		return errors.New("callback cannot be empty when registering with callbacks")
87	}
88	r.mu.Lock()
89	defer r.mu.Unlock()
90	r.handlers[msgType] = handlerEntry{
91		handler:       portHandlerAdapter{handler},
92		callback:      callback,
93		errorCallback: errorCallback,
94		world:         worldName,
95	}
96	return nil
97}
98
99// portHandlerAdapter wraps a port.WebUIMessageHandler, converting the WebViewID type.
100type portHandlerAdapter struct {
101	inner port.WebUIMessageHandler
102}
103
104func (a portHandlerAdapter) Handle(ctx context.Context, webviewID uint64, payload json.RawMessage) (any, error) {
105	return a.inner.Handle(ctx, port.WebViewID(webviewID), payload)
106}
107
108// HandleMessage decodes a raw JSON message body and routes it to the correct handler.
109// Returns the JSON-encoded response. Called by the scheme handler for /api/message POST.
110func (r *MessageRouter) HandleMessage(ctx context.Context, webviewID uint64, body []byte) ([]byte, error) {
111	r.mu.RLock()
112	baseCtx := r.baseCtx
113	r.mu.RUnlock()
114	log := logging.FromContext(baseCtx).With().Str("component", "message-router").Logger()
115
116	var msg Message
117	if err := json.Unmarshal(body, &msg); err != nil {
118		return nil, fmt.Errorf("unmarshal message: %w", err)
119	}
120	if msg.WebViewID == 0 && msg.WebViewIDAlt != 0 {
121		msg.WebViewID = msg.WebViewIDAlt
122	}
123	if msg.WebViewID == 0 {
124		msg.WebViewID = webviewID
125	}
126	if msg.Type == "" {
127		return nil, errors.New("message missing type")
128	}
129
130	r.mu.RLock()
131	entry, ok := r.handlers[msg.Type]
132	r.mu.RUnlock()
133
134	if !ok || entry.handler.inner == nil {
135		log.Warn().Str("type", msg.Type).Msg("no handler registered for message type")
136		return json.Marshal(map[string]string{"error": "unknown message type: " + msg.Type})
137	}
138
139	log.Debug().
140		Str("type", msg.Type).
141		Uint64("webview_id", msg.WebViewID).
142		Int("payload_len", len(msg.Payload)).
143		Msg("dispatching message to handler")
144
145	if ctx == nil {
146		ctx = baseCtx
147	}
148	resp, err := entry.handler.Handle(ctx, msg.WebViewID, msg.Payload)
149	if err != nil {
150		log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error")
151		result := map[string]any{"error": err.Error()}
152		if entry.errorCallback != "" {
153			result["_callback"] = entry.errorCallback
154		}
155		return json.Marshal(result)
156	}
157
158	result := map[string]any{"data": resp}
159	if entry.callback != "" {
160		result["_callback"] = entry.callback
161	}
162	return json.Marshal(result)
163}
164
165// MessageBridgeJS is the JavaScript shim injected into dumb:// pages to provide
166// the window.dumber.postMessage() bridge via fetch.
167//
168// The message body is sent via the X-Dumber-Body header (base64-encoded) rather
169// than the fetch body, because CEF's PostData element API (GetElements/GetBytes)
170// requires unexported wrappers in purego-cef. Using a header avoids that limitation
171// while keeping the bridge simple and reliable.
172const MessageBridgeJS = `
173window.dumber = {
174    postMessage: async function(msg) {
175        const encoded = btoa(unescape(encodeURIComponent(JSON.stringify(msg))));
176        const r = await fetch('/api/message', {
177            method: 'POST',
178            headers: {
179                'Content-Type': 'application/json',
180                'X-Dumber-Body': encoded
181            }
182        });
183        const resp = await r.json();
184        if (resp && resp._callback && window[resp._callback]) {
185            window[resp._callback](resp.data);
186        }
187        return resp;
188    }
189};
190`

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