v0.30.0 eb3068f

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, "history_timeline", 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, "history_timeline_by_domain", 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, "history_timeline_window", 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, "history_search_fts", 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, "history_delete_entry", 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, "history_delete_range", 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, "save_config", cfg)
182	return err
183}
184
185func (c *Client) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) {
186	return request[port.KeybindingsConfig](c, ctx, "get_keybindings", 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, "set_keybinding", req)
193}
194
195func (c *Client) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error {
196	_, err := request[struct{}](c, ctx, "reset_keybinding", req)
197	return err
198}
199
200func (c *Client) ResetAllKeybindings(ctx context.Context) error {
201	_, err := request[struct{}](c, ctx, "reset_all_keybindings", 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, "history_stats", 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, "history_analytics", 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, "history_domain_stats", 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, "history_delete_domain", 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, "favorite_list", 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, "favorite_create", 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, "favorite_update", 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, "favorite_delete", 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		FolderID   *int64  `json:"folder_id"`
276		Tags       []int64 `json:"tags"`
277	}{
278		RequestID:  nextRequestID(),
279		URL:        input.URL,
280		Title:      input.Title,
281		FaviconURL: input.FaviconURL,
282		FolderID:   folderIDPayload(input.FolderID),
283		Tags:       tagIDPayloads(input.Tags),
284	}
285}
286
287func favoriteUpdatePayload(input dto.FavoriteUpdateInput) any {
288	return struct {
289		RequestID   string `json:"requestId"`
290		ID          int64  `json:"id"`
291		Title       string `json:"title"`
292		FaviconURL  string `json:"favicon_url"`
293		FolderID    *int64 `json:"folder_id"`
294		ShortcutKey *int   `json:"shortcut_key"`
295	}{
296		RequestID:   nextRequestID(),
297		ID:          int64(input.ID),
298		Title:       input.Title,
299		FaviconURL:  input.FaviconURL,
300		FolderID:    folderIDPayload(input.FolderID),
301		ShortcutKey: input.ShortcutKey,
302	}
303}
304
305func folderIDPayload(id *entity.FolderID) *int64 {
306	if id == nil {
307		return nil
308	}
309	value := int64(*id)
310	return &value
311}
312
313func tagIDPayloads(ids []entity.TagID) []int64 {
314	out := make([]int64, 0, len(ids))
315	for _, id := range ids {
316		out = append(out, int64(id))
317	}
318	return out
319}
320
321func (c *Client) ListFolders(ctx context.Context) ([]*entity.Folder, error) {
322	return request[[]*entity.Folder](c, ctx, "folder_list", struct {
323		RequestID string `json:"requestId"`
324	}{RequestID: nextRequestID()})
325}
326
327func (c *Client) ListTags(ctx context.Context) ([]*entity.Tag, error) {
328	return request[[]*entity.Tag](c, ctx, "tag_list", struct {
329		RequestID string `json:"requestId"`
330	}{RequestID: nextRequestID()})
331}
332
333func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error {
334	if favoriteID <= 0 {
335		return fmt.Errorf("set shortcut favorite id must be positive, got %d", favoriteID)
336	}
337	_, err := request[struct{}](c, ctx, "favorite_set_shortcut", struct {
338		RequestID   string `json:"requestId"`
339		FavoriteID  int64  `json:"favorite_id"`
340		ShortcutKey *int   `json:"shortcut_key"`
341	}{RequestID: nextRequestID(), FavoriteID: favoriteID, ShortcutKey: shortcutKey})
342	return err
343}
344
345func (c *Client) SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error {
346	if favoriteID <= 0 {
347		return fmt.Errorf("set folder favorite id must be positive, got %d", favoriteID)
348	}
349	_, err := request[struct{}](c, ctx, "favorite_set_folder", struct {
350		RequestID  string `json:"requestId"`
351		FavoriteID int64  `json:"favorite_id"`
352		FolderID   *int64 `json:"folder_id"`
353	}{RequestID: nextRequestID(), FavoriteID: favoriteID, FolderID: folderID})
354	return err
355}
356
357func (c *Client) CreateFolder(ctx context.Context, name, icon string, parentID *int64) (*entity.Folder, error) {
358	name = strings.TrimSpace(name)
359	if name == "" {
360		return nil, fmt.Errorf("folder name is required")
361	}
362	var iconPtr *string
363	if trimmedIcon := strings.TrimSpace(icon); trimmedIcon != "" {
364		iconPtr = &trimmedIcon
365	}
366	return request[*entity.Folder](c, ctx, "folder_create", struct {
367		RequestID string  `json:"requestId"`
368		Name      string  `json:"name"`
369		Icon      *string `json:"icon"`
370		ParentID  *int64  `json:"parent_id,omitempty"`
371	}{RequestID: nextRequestID(), Name: name, Icon: iconPtr, ParentID: parentID})
372}
373
374func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string) error {
375	if id <= 0 {
376		return fmt.Errorf("update folder id must be positive, got %d", id)
377	}
378	name = strings.TrimSpace(name)
379	if name == "" {
380		return fmt.Errorf("folder name is required")
381	}
382	var iconPtr *string
383	if trimmed := strings.TrimSpace(icon); trimmed != "" {
384		iconPtr = &trimmed
385	}
386	_, err := request[struct{}](c, ctx, "folder_update", struct {
387		RequestID string  `json:"requestId"`
388		ID        int64   `json:"id"`
389		Name      string  `json:"name"`
390		Icon      *string `json:"icon"`
391	}{RequestID: nextRequestID(), ID: id, Name: name, Icon: iconPtr})
392	return err
393}
394
395func (c *Client) DeleteFolder(ctx context.Context, id int64) error {
396	if id <= 0 {
397		return fmt.Errorf("delete folder id must be positive, got %d", id)
398	}
399	_, err := request[struct{}](c, ctx, "folder_delete", struct {
400		RequestID string `json:"requestId"`
401		ID        int64  `json:"id"`
402	}{RequestID: nextRequestID(), ID: id})
403	return err
404}
405
406func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) {
407	name = strings.TrimSpace(name)
408	if name == "" {
409		return nil, fmt.Errorf("tag name is required")
410	}
411	var colorPtr *string
412	if trimmedColor := strings.TrimSpace(color); trimmedColor != "" {
413		colorPtr = &trimmedColor
414	}
415	return request[*entity.Tag](c, ctx, "tag_create", struct {
416		RequestID string  `json:"requestId"`
417		Name      string  `json:"name"`
418		Color     *string `json:"color"`
419	}{RequestID: nextRequestID(), Name: name, Color: colorPtr})
420}
421
422func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) error {
423	if id <= 0 {
424		return fmt.Errorf("update tag id must be positive, got %d", id)
425	}
426	trimmedName := strings.TrimSpace(name)
427	if trimmedName == "" {
428		return fmt.Errorf("tag name is required")
429	}
430	namePtr := &trimmedName
431	var colorPtr *string
432	if trimmedColor := strings.TrimSpace(color); trimmedColor != "" {
433		colorPtr = &trimmedColor
434	}
435	_, err := request[struct{}](c, ctx, "tag_update", struct {
436		RequestID string  `json:"requestId"`
437		ID        int64   `json:"id"`
438		Name      *string `json:"name"`
439		Color     *string `json:"color"`
440	}{RequestID: nextRequestID(), ID: id, Name: namePtr, Color: colorPtr})
441	return err
442}
443
444func (c *Client) DeleteTag(ctx context.Context, id int64) error {
445	if id <= 0 {
446		return fmt.Errorf("delete tag id must be positive, got %d", id)
447	}
448	_, err := request[struct{}](c, ctx, "tag_delete", struct {
449		RequestID string `json:"requestId"`
450		ID        int64  `json:"id"`
451	}{RequestID: nextRequestID(), ID: id})
452	return err
453}
454
455func (c *Client) AssignTag(ctx context.Context, favoriteID, tagID int64) error {
456	if favoriteID <= 0 {
457		return fmt.Errorf("assign tag favorite id must be positive, got %d", favoriteID)
458	}
459	if tagID <= 0 {
460		return fmt.Errorf("assign tag id must be positive, got %d", tagID)
461	}
462	_, err := request[struct{}](c, ctx, "tag_assign", struct {
463		RequestID  string `json:"requestId"`
464		FavoriteID int64  `json:"favorite_id"`
465		TagID      int64  `json:"tag_id"`
466	}{RequestID: nextRequestID(), FavoriteID: favoriteID, TagID: tagID})
467	return err
468}
469
470func (c *Client) RemoveTag(ctx context.Context, favoriteID, tagID int64) error {
471	if favoriteID <= 0 {
472		return fmt.Errorf("remove tag favorite id must be positive, got %d", favoriteID)
473	}
474	if tagID <= 0 {
475		return fmt.Errorf("remove tag id must be positive, got %d", tagID)
476	}
477	_, err := request[struct{}](c, ctx, "tag_remove", struct {
478		RequestID  string `json:"requestId"`
479		FavoriteID int64  `json:"favorite_id"`
480		TagID      int64  `json:"tag_id"`
481	}{RequestID: nextRequestID(), FavoriteID: favoriteID, TagID: tagID})
482	return err
483}
484
485func (c *Client) transport() Transport {
486	if c == nil {
487		return nil
488	}
489	if c.native != nil && c.native.Available() {
490		return c.native
491	}
492	if c.fetch != nil && c.fetch.Available() {
493		return c.fetch
494	}
495	return nil
496}
497
498func request[T any](c *Client, ctx context.Context, msgType string, payload any) (T, error) {
499	var zero T
500
501	ctx, cancel := c.withBridgeRequestTimeout(ctx)
502	defer cancel()
503
504	raw, err := c.Send(ctx, msgType, payload)
505	if err != nil {
506		return zero, err
507	}
508
509	return decodeBridgeResponse[T](raw)
510}
511
512// withBridgeRequestTimeout applies the bridge request timeout; ctx must be non-nil.
513func (c *Client) withBridgeRequestTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
514	timeout := defaultBridgeRequestTimeout
515	if c != nil {
516		timeout = c.requestTimeout
517	}
518	if _, ok := ctx.Deadline(); ok || timeout <= 0 {
519		return context.WithCancel(ctx)
520	}
521	return context.WithTimeout(ctx, timeout)
522}
523
524type bridgeResponse struct {
525	RequestID string          `json:"requestId"`
526	Success   bool            `json:"success"`
527	Data      json.RawMessage `json:"data,omitempty"`
528	Error     string          `json:"error,omitempty"`
529}
530
531func decodeBridgeResponse[T any](body []byte) (T, error) {
532	var result T
533
534	var resp bridgeResponse
535	if err := json.Unmarshal(body, &resp); err != nil {
536		return result, fmt.Errorf("unmarshal bridge response: %w", err)
537	}
538	if !resp.Success {
539		if resp.Error == "" {
540			return result, errors.New("bridge request failed")
541		}
542		return result, errors.New(resp.Error)
543	}
544	if len(resp.Data) == 0 {
545		return result, nil
546	}
547
548	if err := json.Unmarshal(resp.Data, &result); err != nil {
549		return result, fmt.Errorf("unmarshal bridge data: %w", err)
550	}
551
552	return result, nil
553}
554
555func nextRequestID() string {
556	return fmt.Sprintf("systemviews-%d", requestSeq.Add(1))
557}
558
559func buildMessageEnvelope(msgType string, payload any) ([]byte, error) {
560	if msgType == "" {
561		return nil, errors.New("message type cannot be empty")
562	}
563
564	body, err := json.Marshal(payload)
565	if err != nil {
566		return nil, fmt.Errorf("marshal payload: %w", err)
567	}
568
569	envelope := port.WebUIMessage{
570		Type:    msgType,
571		Payload: body,
572	}
573
574	data, err := json.Marshal(envelope)
575	if err != nil {
576		return nil, fmt.Errorf("marshal envelope: %w", err)
577	}
578
579	return data, nil
580}

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