v0.33.0 62a09f5

client.go RawHistoryFolder

1package systemviewsbridge
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"strings"
9	"sync/atomic"
10	"time"
11
12	"github.com/bnema/dumber/internal/application/dto"
13	"github.com/bnema/dumber/internal/application/port"
14	"github.com/bnema/dumber/internal/domain/entity"
15)
16
17// Transport sends a WebUI message envelope and returns the raw response body.
18type Transport interface {
19	Available() bool
20	Send(ctx context.Context, body []byte) ([]byte, error)
21}
22
23// Client sends WebUI message envelopes through the first available transport.
24type Client struct {
25	native         Transport
26	fetch          Transport
27	requestTimeout time.Duration
28}
29
30var _ port.SystemviewConfigService = (*Client)(nil)
31var _ port.SystemviewHistoryService = (*Client)(nil)
32var _ port.SystemviewFavoritesService = (*Client)(nil)
33
34var requestSeq atomic.Uint64
35
36const defaultBridgeRequestTimeout = 15 * time.Second
37
38// ClientOption customizes a bridge client.
39type ClientOption func(*Client)
40
41// WithRequestTimeout sets the timeout used when callers provide no deadline.
42func WithRequestTimeout(timeout time.Duration) ClientOption {
43	return func(c *Client) {
44		c.requestTimeout = timeout
45	}
46}
47
48// NewClient creates a bridge client with native WebKit and fetch transports.
49func NewClient(native, fetch Transport, opts ...ClientOption) *Client {
50	client := &Client{native: native, fetch: fetch, requestTimeout: defaultBridgeRequestTimeout}
51	for _, opt := range opts {
52		if opt != nil {
53			opt(client)
54		}
55	}
56	return client
57}
58
59// Send builds a WebUI message envelope and sends it through the active transport.
60func (c *Client) Send(ctx context.Context, msgType string, payload any) ([]byte, error) {
61	if c == nil {
62		return nil, errors.New("bridge client is nil")
63	}
64
65	envelope, err := buildMessageEnvelope(msgType, payload)
66	if err != nil {
67		return nil, err
68	}
69
70	transport := c.transport()
71	if transport == nil {
72		return nil, errors.New("no bridge transport available")
73	}
74
75	return transport.Send(ctx, envelope)
76}
77
78func (c *Client) Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
79	if limit < 0 {
80		return nil, fmt.Errorf("timeline limit must be non-negative, got %d", limit)
81	}
82	if offset < 0 {
83		return nil, fmt.Errorf("timeline offset must be non-negative, got %d", offset)
84	}
85	return request[[]*entity.HistoryEntry](c, ctx, port.MsgHistoryTimeline, struct {
86		RequestID string `json:"requestId"`
87		Limit     int    `json:"limit"`
88		Offset    int    `json:"offset"`
89	}{RequestID: nextRequestID(), Limit: limit, Offset: offset})
90}
91
92func (c *Client) TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
93	domain = strings.TrimSpace(domain)
94	if domain == "" {
95		return nil, fmt.Errorf("timeline-by-domain domain must be non-empty")
96	}
97	if limit < 0 {
98		return nil, fmt.Errorf("timeline-by-domain limit must be non-negative, got %d", limit)
99	}
100	if offset < 0 {
101		return nil, fmt.Errorf("timeline-by-domain offset must be non-negative, got %d", offset)
102	}
103	return request[[]*entity.HistoryEntry](c, ctx, port.MsgHistoryTimelineByDomain, struct {
104		RequestID string `json:"requestId"`
105		Domain    string `json:"domain"`
106		Limit     int    `json:"limit"`
107		Offset    int    `json:"offset"`
108	}{RequestID: nextRequestID(), Domain: domain, Limit: limit, Offset: offset})
109}
110
111func (c *Client) TimelineWindow(ctx context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error) {
112	if beforeID < 0 {
113		return nil, fmt.Errorf("history window cursor beforeID must be non-negative")
114	}
115	if before.IsZero() != (beforeID == 0) {
116		return nil, fmt.Errorf("history window cursor requires before and beforeID")
117	}
118	beforeCursor := ""
119	if !before.IsZero() {
120		beforeCursor = before.Format(time.RFC3339Nano)
121	}
122	return request[*entity.HistoryWindow](c, ctx, port.MsgHistoryTimelineWindow, struct {
123		RequestID string `json:"requestId"`
124		Before    string `json:"before,omitempty"`
125		BeforeID  int64  `json:"beforeId,omitempty"`
126		Domain    string `json:"domain,omitempty"`
127	}{RequestID: nextRequestID(), Before: beforeCursor, BeforeID: beforeID, Domain: strings.TrimSpace(domain)})
128}
129
130func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {
131	if limit < 0 {
132		return nil, fmt.Errorf("search limit must be non-negative, got %d", limit)
133	}
134	return request[[]*entity.HistoryEntry](c, ctx, port.MsgHistorySearchFTS, struct {
135		RequestID string `json:"requestId"`
136		Query     string `json:"query"`
137		Limit     int    `json:"limit"`
138	}{RequestID: nextRequestID(), Query: query, Limit: limit})
139}
140
141func (c *Client) DeleteEntry(ctx context.Context, id int64) error {
142	if id <= 0 {
143		return fmt.Errorf("delete entry id must be positive, got %d", id)
144	}
145	_, err := request[struct{}](c, ctx, port.MsgHistoryDeleteEntry, struct {
146		RequestID string `json:"requestId"`
147		ID        int64  `json:"id"`
148	}{RequestID: nextRequestID(), ID: id})
149	return err
150}
151
152func (c *Client) DeleteRange(ctx context.Context, rangeID string) error {
153	rangeID = strings.TrimSpace(rangeID)
154	if rangeID == "" {
155		return errors.New("history range ID cannot be empty")
156	}
157	_, err := request[struct{}](c, ctx, port.MsgHistoryDeleteRange, struct {
158		RequestID string `json:"requestId"`
159		Range     string `json:"range"`
160	}{RequestID: nextRequestID(), Range: rangeID})
161	return err
162}
163
164// Current intentionally targets the direct HTTP config endpoint rather than a
165// snake_case bridge message so CEF/WebKit fetch shims can reuse /api/config.
166func (c *Client) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) {
167	return request[dto.SystemviewConfigPayload](c, ctx, "/api/config", struct {
168		RequestID string `json:"requestId"`
169	}{RequestID: nextRequestID()})
170}
171
172// Default intentionally targets the direct HTTP config endpoint rather than a
173// snake_case bridge message so CEF/WebKit fetch shims can reuse /api/config/default.
174func (c *Client) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) {
175	return request[dto.SystemviewConfigPayload](c, ctx, "/api/config/default", struct {
176		RequestID string `json:"requestId"`
177	}{RequestID: nextRequestID()})
178}
179
180func (c *Client) Save(ctx context.Context, cfg dto.WebUIConfig) error {
181	_, err := request[struct{}](c, ctx, port.MsgSaveConfig, cfg)
182	return err
183}
184
185func (c *Client) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) {
186	return request[port.KeybindingsConfig](c, ctx, port.MsgGetKeybindings, struct {
187		RequestID string `json:"requestId"`
188	}{RequestID: nextRequestID()})
189}
190
191func (c *Client) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
192	return request[port.SetKeybindingResponse](c, ctx, port.MsgSetKeybinding, req)
193}
194
195func (c *Client) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error {
196	_, err := request[struct{}](c, ctx, port.MsgResetKeybinding, req)
197	return err
198}
199
200func (c *Client) ResetAllKeybindings(ctx context.Context) error {
201	_, err := request[struct{}](c, ctx, port.MsgResetAllKeybindings, struct {
202		RequestID string `json:"requestId"`
203	}{RequestID: nextRequestID()})
204	return err
205}
206
207func (c *Client) Stats(ctx context.Context) (*entity.HistoryStats, error) {
208	return request[*entity.HistoryStats](c, ctx, port.MsgHistoryStats, struct {
209		RequestID string `json:"requestId"`
210	}{RequestID: nextRequestID()})
211}
212
213func (c *Client) Analytics(ctx context.Context) (*entity.HistoryAnalytics, error) {
214	return request[*entity.HistoryAnalytics](c, ctx, port.MsgHistoryAnalytics, struct {
215		RequestID string `json:"requestId"`
216	}{RequestID: nextRequestID()})
217}
218
219func (c *Client) DomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {
220	if limit < 0 {
221		return nil, fmt.Errorf("domain stats limit must be non-negative, got %d", limit)
222	}
223	return request[[]*entity.DomainStat](c, ctx, port.MsgHistoryDomainStats, struct {
224		RequestID string `json:"requestId"`
225		Limit     int    `json:"limit"`
226	}{RequestID: nextRequestID(), Limit: limit})
227}
228
229func (c *Client) DeleteDomain(ctx context.Context, domain string) error {
230	domain = strings.TrimSpace(domain)
231	if domain == "" {
232		return errors.New("history domain cannot be empty")
233	}
234	_, err := request[struct{}](c, ctx, port.MsgHistoryDeleteDomain, struct {
235		RequestID string `json:"requestId"`
236		Domain    string `json:"domain"`
237	}{RequestID: nextRequestID(), Domain: domain})
238	return err
239}
240
241func (c *Client) List(ctx context.Context) ([]*entity.Favorite, error) {
242	return request[[]*entity.Favorite](c, ctx, port.MsgFavoriteList, struct {
243		RequestID string `json:"requestId"`
244	}{RequestID: nextRequestID()})
245}
246
247func (c *Client) CreateFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
248	return request[*entity.Favorite](c, ctx, port.MsgFavoriteCreate, favoriteCreatePayload(input))
249}
250
251func (c *Client) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
252	if input.ID <= 0 {
253		return nil, fmt.Errorf("update favorite id must be positive, got %d", input.ID)
254	}
255	return request[*entity.Favorite](c, ctx, port.MsgFavoriteUpdate, favoriteUpdatePayload(input))
256}
257
258func (c *Client) DeleteFavorite(ctx context.Context, id int64) error {
259	if id <= 0 {
260		return fmt.Errorf("delete favorite id must be positive, got %d", id)
261	}
262	_, err := request[struct{}](c, ctx, port.MsgFavoriteDelete, struct {
263		RequestID string `json:"requestId"`
264		ID        int64  `json:"id"`
265	}{RequestID: nextRequestID(), ID: id})
266	return err
267}
268
269func favoriteCreatePayload(input dto.FavoriteCreateInput) any {
270	return struct {
271		RequestID  string  `json:"requestId"`
272		URL        string  `json:"url"`
273		Title      string  `json:"title"`
274		FaviconURL string  `json:"favicon_url"`
275		Tags       []int64 `json:"tags"`
276	}{
277		RequestID:  nextRequestID(),
278		URL:        input.URL,
279		Title:      input.Title,
280		FaviconURL: input.FaviconURL,
281		Tags:       tagIDPayloads(input.Tags),
282	}
283}
284
285func favoriteUpdatePayload(input dto.FavoriteUpdateInput) any {
286	payload := map[string]any{
287		"requestId":   nextRequestID(),
288		"id":          int64(input.ID),
289		"title":       input.Title,
290		"favicon_url": input.FaviconURL,
291	}
292	if input.ShortcutKeySet {
293		payload["shortcut_key"] = input.ShortcutKey
294	}
295	return payload
296}
297
298func tagIDPayloads(ids []entity.TagID) []int64 {
299	out := make([]int64, 0, len(ids))
300	for _, id := range ids {
301		out = append(out, int64(id))
302	}
303	return out
304}
305
306func (c *Client) ListTags(ctx context.Context) ([]*entity.Tag, error) {
307	return request[[]*entity.Tag](c, ctx, port.MsgTagList, struct {
308		RequestID string `json:"requestId"`
309	}{RequestID: nextRequestID()})
310}
311
312func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error {
313	if favoriteID <= 0 {
314		return fmt.Errorf("set shortcut favorite id must be positive, got %d", favoriteID)
315	}
316	_, err := request[struct{}](c, ctx, port.MsgFavoriteSetShortcut, struct {
317		RequestID   string `json:"requestId"`
318		FavoriteID  int64  `json:"favorite_id"`
319		ShortcutKey *int   `json:"shortcut_key"`
320	}{RequestID: nextRequestID(), FavoriteID: favoriteID, ShortcutKey: shortcutKey})
321	return err
322}
323
324func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) {
325	name = strings.TrimSpace(name)
326	if name == "" {
327		return nil, fmt.Errorf("tag name is required")
328	}
329	var colorPtr *string
330	if trimmedColor := strings.TrimSpace(color); trimmedColor != "" {
331		colorPtr = &trimmedColor
332	}
333	return request[*entity.Tag](c, ctx, port.MsgTagCreate, struct {
334		RequestID string  `json:"requestId"`
335		Name      string  `json:"name"`
336		Color     *string `json:"color"`
337	}{RequestID: nextRequestID(), Name: name, Color: colorPtr})
338}
339
340func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) error {
341	if id <= 0 {
342		return fmt.Errorf("update tag id must be positive, got %d", id)
343	}
344	trimmedName := strings.TrimSpace(name)
345	if trimmedName == "" {
346		return fmt.Errorf("tag name is required")
347	}
348	namePtr := &trimmedName
349	var colorPtr *string
350	if trimmedColor := strings.TrimSpace(color); trimmedColor != "" {
351		colorPtr = &trimmedColor
352	}
353	_, err := request[struct{}](c, ctx, port.MsgTagUpdate, struct {
354		RequestID string  `json:"requestId"`
355		ID        int64   `json:"id"`
356		Name      *string `json:"name"`
357		Color     *string `json:"color"`
358	}{RequestID: nextRequestID(), ID: id, Name: namePtr, Color: colorPtr})
359	return err
360}
361
362func (c *Client) DeleteTag(ctx context.Context, id int64) error {
363	if id <= 0 {
364		return fmt.Errorf("delete tag id must be positive, got %d", id)
365	}
366	_, err := request[struct{}](c, ctx, port.MsgTagDelete, struct {
367		RequestID string `json:"requestId"`
368		ID        int64  `json:"id"`
369	}{RequestID: nextRequestID(), ID: id})
370	return err
371}
372
373func (c *Client) AssignTag(ctx context.Context, favoriteID, tagID int64) error {
374	if favoriteID <= 0 {
375		return fmt.Errorf("assign tag favorite id must be positive, got %d", favoriteID)
376	}
377	if tagID <= 0 {
378		return fmt.Errorf("assign tag id must be positive, got %d", tagID)
379	}
380	_, err := request[struct{}](c, ctx, port.MsgTagAssign, struct {
381		RequestID  string `json:"requestId"`
382		FavoriteID int64  `json:"favorite_id"`
383		TagID      int64  `json:"tag_id"`
384	}{RequestID: nextRequestID(), FavoriteID: favoriteID, TagID: tagID})
385	return err
386}
387
388func (c *Client) RemoveTag(ctx context.Context, favoriteID, tagID int64) error {
389	if favoriteID <= 0 {
390		return fmt.Errorf("remove tag favorite id must be positive, got %d", favoriteID)
391	}
392	if tagID <= 0 {
393		return fmt.Errorf("remove tag id must be positive, got %d", tagID)
394	}
395	_, err := request[struct{}](c, ctx, port.MsgTagRemove, struct {
396		RequestID  string `json:"requestId"`
397		FavoriteID int64  `json:"favorite_id"`
398		TagID      int64  `json:"tag_id"`
399	}{RequestID: nextRequestID(), FavoriteID: favoriteID, TagID: tagID})
400	return err
401}
402
403func (c *Client) transport() Transport {
404	if c == nil {
405		return nil
406	}
407	if c.native != nil && c.native.Available() {
408		return c.native
409	}
410	if c.fetch != nil && c.fetch.Available() {
411		return c.fetch
412	}
413	return nil
414}
415
416func request[T any](c *Client, ctx context.Context, msgType string, payload any) (T, error) {
417	var zero T
418
419	ctx, cancel := c.withBridgeRequestTimeout(ctx)
420	defer cancel()
421
422	raw, err := c.Send(ctx, msgType, payload)
423	if err != nil {
424		return zero, err
425	}
426
427	return decodeBridgeResponse[T](raw)
428}
429
430// withBridgeRequestTimeout applies the bridge request timeout; ctx must be non-nil.
431func (c *Client) withBridgeRequestTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
432	timeout := defaultBridgeRequestTimeout
433	if c != nil {
434		timeout = c.requestTimeout
435	}
436	if _, ok := ctx.Deadline(); ok || timeout <= 0 {
437		return context.WithCancel(ctx)
438	}
439	return context.WithTimeout(ctx, timeout)
440}
441
442type bridgeResponse struct {
443	RequestID string          `json:"requestId"`
444	Success   bool            `json:"success"`
445	Data      json.RawMessage `json:"data,omitempty"`
446	Error     string          `json:"error,omitempty"`
447}
448
449func decodeBridgeResponse[T any](body []byte) (T, error) {
450	var result T
451
452	var resp bridgeResponse
453	if err := json.Unmarshal(body, &resp); err != nil {
454		return result, fmt.Errorf("unmarshal bridge response: %w", err)
455	}
456	if !resp.Success {
457		if resp.Error == "" {
458			return result, errors.New("bridge request failed")
459		}
460		return result, errors.New(resp.Error)
461	}
462	if len(resp.Data) == 0 {
463		return result, nil
464	}
465
466	if err := json.Unmarshal(resp.Data, &result); err != nil {
467		return result, fmt.Errorf("unmarshal bridge data: %w", err)
468	}
469
470	return result, nil
471}
472
473func nextRequestID() string {
474	return fmt.Sprintf("systemviews-%d", requestSeq.Add(1))
475}
476
477func buildMessageEnvelope(msgType string, payload any) ([]byte, error) {
478	if msgType == "" {
479		return nil, errors.New("message type cannot be empty")
480	}
481
482	body, err := json.Marshal(payload)
483	if err != nil {
484		return nil, fmt.Errorf("marshal payload: %w", err)
485	}
486
487	envelope := port.WebUIMessage{
488		Type:    msgType,
489		Payload: body,
490	}
491
492	data, err := json.Marshal(envelope)
493	if err != nil {
494		return nil, fmt.Errorf("marshal envelope: %w", err)
495	}
496
497	return data, nil
498}

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