v0.29.0 21b3150

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, domain string) (*entity.HistoryWindow, error) {
112	beforeCursor := ""
113	if !before.IsZero() {
114		beforeCursor = before.Format(time.RFC3339Nano)
115	}
116	return request[*entity.HistoryWindow](c, ctx, "history_timeline_window", struct {
117		RequestID string `json:"requestId"`
118		Before    string `json:"before,omitempty"`
119		Domain    string `json:"domain,omitempty"`
120	}{RequestID: nextRequestID(), Before: beforeCursor, Domain: strings.TrimSpace(domain)})
121}
122
123func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {
124	if limit < 0 {
125		return nil, fmt.Errorf("search limit must be non-negative, got %d", limit)
126	}
127	return request[[]*entity.HistoryEntry](c, ctx, "history_search_fts", struct {
128		RequestID string `json:"requestId"`
129		Query     string `json:"query"`
130		Limit     int    `json:"limit"`
131	}{RequestID: nextRequestID(), Query: query, Limit: limit})
132}
133
134func (c *Client) DeleteEntry(ctx context.Context, id int64) error {
135	if id <= 0 {
136		return fmt.Errorf("delete entry id must be positive, got %d", id)
137	}
138	_, err := request[struct{}](c, ctx, "history_delete_entry", struct {
139		RequestID string `json:"requestId"`
140		ID        int64  `json:"id"`
141	}{RequestID: nextRequestID(), ID: id})
142	return err
143}
144
145func (c *Client) DeleteRange(ctx context.Context, rangeID string) error {
146	rangeID = strings.TrimSpace(rangeID)
147	if rangeID == "" {
148		return errors.New("history range ID cannot be empty")
149	}
150	_, err := request[struct{}](c, ctx, "history_delete_range", struct {
151		RequestID string `json:"requestId"`
152		Range     string `json:"range"`
153	}{RequestID: nextRequestID(), Range: rangeID})
154	return err
155}
156
157// Current intentionally targets the direct HTTP config endpoint rather than a
158// snake_case bridge message so CEF/WebKit fetch shims can reuse /api/config.
159func (c *Client) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) {
160	return request[dto.SystemviewConfigPayload](c, ctx, "/api/config", struct {
161		RequestID string `json:"requestId"`
162	}{RequestID: nextRequestID()})
163}
164
165// Default intentionally targets the direct HTTP config endpoint rather than a
166// snake_case bridge message so CEF/WebKit fetch shims can reuse /api/config/default.
167func (c *Client) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) {
168	return request[dto.SystemviewConfigPayload](c, ctx, "/api/config/default", struct {
169		RequestID string `json:"requestId"`
170	}{RequestID: nextRequestID()})
171}
172
173func (c *Client) Save(ctx context.Context, cfg dto.WebUIConfig) error {
174	_, err := request[struct{}](c, ctx, "save_config", cfg)
175	return err
176}
177
178func (c *Client) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) {
179	return request[port.KeybindingsConfig](c, ctx, "get_keybindings", struct {
180		RequestID string `json:"requestId"`
181	}{RequestID: nextRequestID()})
182}
183
184func (c *Client) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
185	return request[port.SetKeybindingResponse](c, ctx, "set_keybinding", req)
186}
187
188func (c *Client) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error {
189	_, err := request[struct{}](c, ctx, "reset_keybinding", req)
190	return err
191}
192
193func (c *Client) ResetAllKeybindings(ctx context.Context) error {
194	_, err := request[struct{}](c, ctx, "reset_all_keybindings", struct {
195		RequestID string `json:"requestId"`
196	}{RequestID: nextRequestID()})
197	return err
198}
199
200func (c *Client) Stats(ctx context.Context) (*entity.HistoryStats, error) {
201	return request[*entity.HistoryStats](c, ctx, "history_stats", struct {
202		RequestID string `json:"requestId"`
203	}{RequestID: nextRequestID()})
204}
205
206func (c *Client) Analytics(ctx context.Context) (*entity.HistoryAnalytics, error) {
207	return request[*entity.HistoryAnalytics](c, ctx, "history_analytics", struct {
208		RequestID string `json:"requestId"`
209	}{RequestID: nextRequestID()})
210}
211
212func (c *Client) DomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {
213	if limit < 0 {
214		return nil, fmt.Errorf("domain stats limit must be non-negative, got %d", limit)
215	}
216	return request[[]*entity.DomainStat](c, ctx, "history_domain_stats", struct {
217		RequestID string `json:"requestId"`
218		Limit     int    `json:"limit"`
219	}{RequestID: nextRequestID(), Limit: limit})
220}
221
222func (c *Client) DeleteDomain(ctx context.Context, domain string) error {
223	domain = strings.TrimSpace(domain)
224	if domain == "" {
225		return errors.New("history domain cannot be empty")
226	}
227	_, err := request[struct{}](c, ctx, "history_delete_domain", struct {
228		RequestID string `json:"requestId"`
229		Domain    string `json:"domain"`
230	}{RequestID: nextRequestID(), Domain: domain})
231	return err
232}
233
234func (c *Client) List(ctx context.Context) ([]*entity.Favorite, error) {
235	return request[[]*entity.Favorite](c, ctx, "favorite_list", struct {
236		RequestID string `json:"requestId"`
237	}{RequestID: nextRequestID()})
238}
239
240func (c *Client) CreateFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
241	return request[*entity.Favorite](c, ctx, "favorite_create", favoriteCreatePayload(input))
242}
243
244func (c *Client) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
245	if input.ID <= 0 {
246		return nil, fmt.Errorf("update favorite id must be positive, got %d", input.ID)
247	}
248	return request[*entity.Favorite](c, ctx, "favorite_update", favoriteUpdatePayload(input))
249}
250
251func (c *Client) DeleteFavorite(ctx context.Context, id int64) error {
252	if id <= 0 {
253		return fmt.Errorf("delete favorite id must be positive, got %d", id)
254	}
255	_, err := request[struct{}](c, ctx, "favorite_delete", struct {
256		RequestID string `json:"requestId"`
257		ID        int64  `json:"id"`
258	}{RequestID: nextRequestID(), ID: id})
259	return err
260}
261
262func favoriteCreatePayload(input dto.FavoriteCreateInput) any {
263	return struct {
264		RequestID  string  `json:"requestId"`
265		URL        string  `json:"url"`
266		Title      string  `json:"title"`
267		FaviconURL string  `json:"favicon_url"`
268		FolderID   *int64  `json:"folder_id"`
269		Tags       []int64 `json:"tags"`
270	}{
271		RequestID:  nextRequestID(),
272		URL:        input.URL,
273		Title:      input.Title,
274		FaviconURL: input.FaviconURL,
275		FolderID:   folderIDPayload(input.FolderID),
276		Tags:       tagIDPayloads(input.Tags),
277	}
278}
279
280func favoriteUpdatePayload(input dto.FavoriteUpdateInput) any {
281	return struct {
282		RequestID   string `json:"requestId"`
283		ID          int64  `json:"id"`
284		Title       string `json:"title"`
285		FaviconURL  string `json:"favicon_url"`
286		FolderID    *int64 `json:"folder_id"`
287		ShortcutKey *int   `json:"shortcut_key"`
288	}{
289		RequestID:   nextRequestID(),
290		ID:          int64(input.ID),
291		Title:       input.Title,
292		FaviconURL:  input.FaviconURL,
293		FolderID:    folderIDPayload(input.FolderID),
294		ShortcutKey: input.ShortcutKey,
295	}
296}
297
298func folderIDPayload(id *entity.FolderID) *int64 {
299	if id == nil {
300		return nil
301	}
302	value := int64(*id)
303	return &value
304}
305
306func tagIDPayloads(ids []entity.TagID) []int64 {
307	out := make([]int64, 0, len(ids))
308	for _, id := range ids {
309		out = append(out, int64(id))
310	}
311	return out
312}
313
314func (c *Client) ListFolders(ctx context.Context) ([]*entity.Folder, error) {
315	return request[[]*entity.Folder](c, ctx, "folder_list", struct {
316		RequestID string `json:"requestId"`
317	}{RequestID: nextRequestID()})
318}
319
320func (c *Client) ListTags(ctx context.Context) ([]*entity.Tag, error) {
321	return request[[]*entity.Tag](c, ctx, "tag_list", struct {
322		RequestID string `json:"requestId"`
323	}{RequestID: nextRequestID()})
324}
325
326func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error {
327	if favoriteID <= 0 {
328		return fmt.Errorf("set shortcut favorite id must be positive, got %d", favoriteID)
329	}
330	_, err := request[struct{}](c, ctx, "favorite_set_shortcut", struct {
331		RequestID   string `json:"requestId"`
332		FavoriteID  int64  `json:"favorite_id"`
333		ShortcutKey *int   `json:"shortcut_key"`
334	}{RequestID: nextRequestID(), FavoriteID: favoriteID, ShortcutKey: shortcutKey})
335	return err
336}
337
338func (c *Client) SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error {
339	if favoriteID <= 0 {
340		return fmt.Errorf("set folder favorite id must be positive, got %d", favoriteID)
341	}
342	_, err := request[struct{}](c, ctx, "favorite_set_folder", struct {
343		RequestID  string `json:"requestId"`
344		FavoriteID int64  `json:"favorite_id"`
345		FolderID   *int64 `json:"folder_id"`
346	}{RequestID: nextRequestID(), FavoriteID: favoriteID, FolderID: folderID})
347	return err
348}
349
350func (c *Client) CreateFolder(ctx context.Context, name, icon string, parentID *int64) (*entity.Folder, error) {
351	name = strings.TrimSpace(name)
352	if name == "" {
353		return nil, fmt.Errorf("folder name is required")
354	}
355	var iconPtr *string
356	if trimmedIcon := strings.TrimSpace(icon); trimmedIcon != "" {
357		iconPtr = &trimmedIcon
358	}
359	return request[*entity.Folder](c, ctx, "folder_create", struct {
360		RequestID string  `json:"requestId"`
361		Name      string  `json:"name"`
362		Icon      *string `json:"icon"`
363		ParentID  *int64  `json:"parent_id,omitempty"`
364	}{RequestID: nextRequestID(), Name: name, Icon: iconPtr, ParentID: parentID})
365}
366
367func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string) error {
368	if id <= 0 {
369		return fmt.Errorf("update folder id must be positive, got %d", id)
370	}
371	name = strings.TrimSpace(name)
372	if name == "" {
373		return fmt.Errorf("folder name is required")
374	}
375	var iconPtr *string
376	if trimmed := strings.TrimSpace(icon); trimmed != "" {
377		iconPtr = &trimmed
378	}
379	_, err := request[struct{}](c, ctx, "folder_update", struct {
380		RequestID string  `json:"requestId"`
381		ID        int64   `json:"id"`
382		Name      string  `json:"name"`
383		Icon      *string `json:"icon"`
384	}{RequestID: nextRequestID(), ID: id, Name: name, Icon: iconPtr})
385	return err
386}
387
388func (c *Client) DeleteFolder(ctx context.Context, id int64) error {
389	if id <= 0 {
390		return fmt.Errorf("delete folder id must be positive, got %d", id)
391	}
392	_, err := request[struct{}](c, ctx, "folder_delete", struct {
393		RequestID string `json:"requestId"`
394		ID        int64  `json:"id"`
395	}{RequestID: nextRequestID(), ID: id})
396	return err
397}
398
399func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) {
400	name = strings.TrimSpace(name)
401	if name == "" {
402		return nil, fmt.Errorf("tag name is required")
403	}
404	var colorPtr *string
405	if trimmedColor := strings.TrimSpace(color); trimmedColor != "" {
406		colorPtr = &trimmedColor
407	}
408	return request[*entity.Tag](c, ctx, "tag_create", struct {
409		RequestID string  `json:"requestId"`
410		Name      string  `json:"name"`
411		Color     *string `json:"color"`
412	}{RequestID: nextRequestID(), Name: name, Color: colorPtr})
413}
414
415func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) error {
416	if id <= 0 {
417		return fmt.Errorf("update tag id must be positive, got %d", id)
418	}
419	trimmedName := strings.TrimSpace(name)
420	if trimmedName == "" {
421		return fmt.Errorf("tag name is required")
422	}
423	namePtr := &trimmedName
424	var colorPtr *string
425	if trimmedColor := strings.TrimSpace(color); trimmedColor != "" {
426		colorPtr = &trimmedColor
427	}
428	_, err := request[struct{}](c, ctx, "tag_update", struct {
429		RequestID string  `json:"requestId"`
430		ID        int64   `json:"id"`
431		Name      *string `json:"name"`
432		Color     *string `json:"color"`
433	}{RequestID: nextRequestID(), ID: id, Name: namePtr, Color: colorPtr})
434	return err
435}
436
437func (c *Client) DeleteTag(ctx context.Context, id int64) error {
438	if id <= 0 {
439		return fmt.Errorf("delete tag id must be positive, got %d", id)
440	}
441	_, err := request[struct{}](c, ctx, "tag_delete", struct {
442		RequestID string `json:"requestId"`
443		ID        int64  `json:"id"`
444	}{RequestID: nextRequestID(), ID: id})
445	return err
446}
447
448func (c *Client) AssignTag(ctx context.Context, favoriteID, tagID int64) error {
449	if favoriteID <= 0 {
450		return fmt.Errorf("assign tag favorite id must be positive, got %d", favoriteID)
451	}
452	if tagID <= 0 {
453		return fmt.Errorf("assign tag id must be positive, got %d", tagID)
454	}
455	_, err := request[struct{}](c, ctx, "tag_assign", struct {
456		RequestID  string `json:"requestId"`
457		FavoriteID int64  `json:"favorite_id"`
458		TagID      int64  `json:"tag_id"`
459	}{RequestID: nextRequestID(), FavoriteID: favoriteID, TagID: tagID})
460	return err
461}
462
463func (c *Client) RemoveTag(ctx context.Context, favoriteID, tagID int64) error {
464	if favoriteID <= 0 {
465		return fmt.Errorf("remove tag favorite id must be positive, got %d", favoriteID)
466	}
467	if tagID <= 0 {
468		return fmt.Errorf("remove tag id must be positive, got %d", tagID)
469	}
470	_, err := request[struct{}](c, ctx, "tag_remove", struct {
471		RequestID  string `json:"requestId"`
472		FavoriteID int64  `json:"favorite_id"`
473		TagID      int64  `json:"tag_id"`
474	}{RequestID: nextRequestID(), FavoriteID: favoriteID, TagID: tagID})
475	return err
476}
477
478func (c *Client) transport() Transport {
479	if c == nil {
480		return nil
481	}
482	if c.native != nil && c.native.Available() {
483		return c.native
484	}
485	if c.fetch != nil && c.fetch.Available() {
486		return c.fetch
487	}
488	return nil
489}
490
491func request[T any](c *Client, ctx context.Context, msgType string, payload any) (T, error) {
492	var zero T
493
494	ctx, cancel := c.withBridgeRequestTimeout(ctx)
495	defer cancel()
496
497	raw, err := c.Send(ctx, msgType, payload)
498	if err != nil {
499		return zero, err
500	}
501
502	return decodeBridgeResponse[T](raw)
503}
504
505// withBridgeRequestTimeout applies the bridge request timeout; ctx must be non-nil.
506func (c *Client) withBridgeRequestTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
507	timeout := defaultBridgeRequestTimeout
508	if c != nil {
509		timeout = c.requestTimeout
510	}
511	if _, ok := ctx.Deadline(); ok || timeout <= 0 {
512		return context.WithCancel(ctx)
513	}
514	return context.WithTimeout(ctx, timeout)
515}
516
517type bridgeResponse struct {
518	RequestID string          `json:"requestId"`
519	Success   bool            `json:"success"`
520	Data      json.RawMessage `json:"data,omitempty"`
521	Error     string          `json:"error,omitempty"`
522}
523
524func decodeBridgeResponse[T any](body []byte) (T, error) {
525	var result T
526
527	var resp bridgeResponse
528	if err := json.Unmarshal(body, &resp); err != nil {
529		return result, fmt.Errorf("unmarshal bridge response: %w", err)
530	}
531	if !resp.Success {
532		if resp.Error == "" {
533			return result, errors.New("bridge request failed")
534		}
535		return result, errors.New(resp.Error)
536	}
537	if len(resp.Data) == 0 {
538		return result, nil
539	}
540
541	if err := json.Unmarshal(resp.Data, &result); err != nil {
542		return result, fmt.Errorf("unmarshal bridge data: %w", err)
543	}
544
545	return result, nil
546}
547
548func nextRequestID() string {
549	return fmt.Sprintf("systemviews-%d", requestSeq.Add(1))
550}
551
552func buildMessageEnvelope(msgType string, payload any) ([]byte, error) {
553	if msgType == "" {
554		return nil, errors.New("message type cannot be empty")
555	}
556
557	body, err := json.Marshal(payload)
558	if err != nil {
559		return nil, fmt.Errorf("marshal payload: %w", err)
560	}
561
562	envelope := port.WebUIMessage{
563		Type:    msgType,
564		Payload: body,
565	}
566
567	data, err := json.Marshal(envelope)
568	if err != nil {
569		return nil, fmt.Errorf("marshal envelope: %w", err)
570	}
571
572	return data, nil
573}

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