detached db23629

feat(systemviews): add history management actions

commit
db236290e1613966c29297fab66e50b601f1971a
author
brice · · 2026-04-24 02:19 UTC
parent
3183f09
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +855 -35
M assets/systemviews/systemviews.css +144 -0
@@ -249,3 +249,147 @@ body {
249249 color: var(--sv-muted, #9aa3b2);
250250 font-weight: 600;
251251 }
252+.sv-alert {
253+ border-radius: 0.75rem;
254+ padding: 0.75rem 1rem;
255+ border: 1px solid var(--sv-border, #2a313d);
256+}
257+
258+.sv-alert-success {
259+ background: color-mix(in srgb, var(--sv-accent, #8ab4ff) 14%, var(--sv-surface, #171a21));
260+}
261+
262+.sv-alert-error {
263+ background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21));
264+ border-color: color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d));
265+}
266+
267+.sv-toolbar,
268+.sv-button-row,
269+.sv-active-filters,
270+.sv-history-actions-panel,
271+.sv-history-row-actions {
272+ display: flex;
273+ flex-wrap: wrap;
274+ gap: 0.5rem;
275+ align-items: center;
276+}
277+
278+.sv-toolbar {
279+ align-items: end;
280+}
281+
282+.sv-history-actions-panel {
283+ margin-top: 0.75rem;
284+ justify-content: space-between;
285+}
286+
287+.sv-search-label {
288+ display: grid;
289+ gap: 0.25rem;
290+ min-width: min(24rem, 100%);
291+ color: var(--sv-muted, #9aa3b2);
292+ font-size: 12px;
293+ font-weight: 600;
294+}
295+
296+.sv-search-label input {
297+ border: 1px solid var(--sv-border, #2a313d);
298+ border-radius: 0.55rem;
299+ padding: 0.55rem 0.65rem;
300+ background: var(--sv-surface-variant, #1d2129);
301+ color: var(--sv-fg, #e8ebf0);
302+ font: inherit;
303+}
304+
305+.sv-button {
306+ display: inline-flex;
307+ align-items: center;
308+ justify-content: center;
309+ min-height: 2rem;
310+ border: 1px solid var(--sv-accent, #8ab4ff);
311+ border-radius: 0.55rem;
312+ padding: 0.35rem 0.65rem;
313+ background: var(--sv-accent, #8ab4ff);
314+ color: var(--sv-background, #0f1115);
315+ cursor: pointer;
316+ font: inherit;
317+ font-weight: 600;
318+ text-decoration: none;
319+}
320+
321+.sv-button-secondary {
322+ border-color: var(--sv-border, #2a313d);
323+ background: var(--sv-surface-variant, #1d2129);
324+ color: var(--sv-fg, #e8ebf0);
325+}
326+
327+.sv-button-danger {
328+ border-color: color-mix(in srgb, #ef4444 70%, var(--sv-border, #2a313d));
329+ color: #fecaca;
330+}
331+
332+.sv-button:disabled {
333+ cursor: not-allowed;
334+ opacity: 0.45;
335+}
336+
337+.sv-filter-chip {
338+ display: inline-flex;
339+ gap: 0.35rem;
340+ align-items: center;
341+ border: 1px solid var(--sv-border, #2a313d);
342+ border-radius: 999px;
343+ padding: 0.25rem 0.55rem;
344+ background: var(--sv-surface-variant, #1d2129);
345+}
346+
347+.sv-filter-chip button,
348+.sv-domain-filter,
349+.sv-icon-button {
350+ border: 0;
351+ background: transparent;
352+ color: inherit;
353+ cursor: pointer;
354+ font: inherit;
355+}
356+
357+.sv-domain-filter {
358+ color: var(--sv-fg, #e8ebf0);
359+ font-weight: 700;
360+ padding: 0;
361+ text-align: left;
362+}
363+
364+.sv-icon-button {
365+ display: inline-flex;
366+ align-items: center;
367+ justify-content: center;
368+ width: 1.4rem;
369+ height: 1.4rem;
370+ border-radius: 999px;
371+}
372+
373+.sv-danger {
374+ color: #fecaca;
375+}
376+
377+.sv-icon-button:hover,
378+.sv-filter-chip button:hover,
379+.sv-domain-filter:hover {
380+ background: color-mix(in srgb, var(--sv-accent, #8ab4ff) 12%, transparent);
381+}
382+
383+.sv-history-item.sv-focused {
384+ outline: 2px solid var(--sv-accent, #8ab4ff);
385+ outline-offset: 0.35rem;
386+ border-radius: 0.5rem;
387+}
388+
389+.sv-key-hints kbd {
390+ border: 1px solid var(--sv-border, #2a313d);
391+ border-radius: 0.35rem;
392+ padding: 0.1rem 0.3rem;
393+ background: var(--sv-surface-variant, #1d2129);
394+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
395+}
@@ -249,3 +249,147 @@ body {
249 color: var(--sv-muted, #9aa3b2);249 color: var(--sv-muted, #9aa3b2);
250 font-weight: 600;250 font-weight: 600;
251 }251 }
252+.sv-alert {
253+ border-radius: 0.75rem;
254+ padding: 0.75rem 1rem;
255+ border: 1px solid var(--sv-border, #2a313d);
256+}
257+
258+.sv-alert-success {
259+ background: color-mix(in srgb, var(--sv-accent, #8ab4ff) 14%, var(--sv-surface, #171a21));
260+}
261+
262+.sv-alert-error {
263+ background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21));
264+ border-color: color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d));
265+}
266+
267+.sv-toolbar,
268+.sv-button-row,
269+.sv-active-filters,
270+.sv-history-actions-panel,
271+.sv-history-row-actions {
272+ display: flex;
273+ flex-wrap: wrap;
274+ gap: 0.5rem;
275+ align-items: center;
276+}
277+
278+.sv-toolbar {
279+ align-items: end;
280+}
281+
282+.sv-history-actions-panel {
283+ margin-top: 0.75rem;
284+ justify-content: space-between;
285+}
286+
287+.sv-search-label {
288+ display: grid;
289+ gap: 0.25rem;
290+ min-width: min(24rem, 100%);
291+ color: var(--sv-muted, #9aa3b2);
292+ font-size: 12px;
293+ font-weight: 600;
294+}
295+
296+.sv-search-label input {
297+ border: 1px solid var(--sv-border, #2a313d);
298+ border-radius: 0.55rem;
299+ padding: 0.55rem 0.65rem;
300+ background: var(--sv-surface-variant, #1d2129);
301+ color: var(--sv-fg, #e8ebf0);
302+ font: inherit;
303+}
304+
305+.sv-button {
306+ display: inline-flex;
307+ align-items: center;
308+ justify-content: center;
309+ min-height: 2rem;
310+ border: 1px solid var(--sv-accent, #8ab4ff);
311+ border-radius: 0.55rem;
312+ padding: 0.35rem 0.65rem;
313+ background: var(--sv-accent, #8ab4ff);
314+ color: var(--sv-background, #0f1115);
315+ cursor: pointer;
316+ font: inherit;
317+ font-weight: 600;
318+ text-decoration: none;
319+}
320+
321+.sv-button-secondary {
322+ border-color: var(--sv-border, #2a313d);
323+ background: var(--sv-surface-variant, #1d2129);
324+ color: var(--sv-fg, #e8ebf0);
325+}
326+
327+.sv-button-danger {
328+ border-color: color-mix(in srgb, #ef4444 70%, var(--sv-border, #2a313d));
329+ color: #fecaca;
330+}
331+
332+.sv-button:disabled {
333+ cursor: not-allowed;
334+ opacity: 0.45;
335+}
336+
337+.sv-filter-chip {
338+ display: inline-flex;
339+ gap: 0.35rem;
340+ align-items: center;
341+ border: 1px solid var(--sv-border, #2a313d);
342+ border-radius: 999px;
343+ padding: 0.25rem 0.55rem;
344+ background: var(--sv-surface-variant, #1d2129);
345+}
346+
347+.sv-filter-chip button,
348+.sv-domain-filter,
349+.sv-icon-button {
350+ border: 0;
351+ background: transparent;
352+ color: inherit;
353+ cursor: pointer;
354+ font: inherit;
355+}
356+
357+.sv-domain-filter {
358+ color: var(--sv-fg, #e8ebf0);
359+ font-weight: 700;
360+ padding: 0;
361+ text-align: left;
362+}
363+
364+.sv-icon-button {
365+ display: inline-flex;
366+ align-items: center;
367+ justify-content: center;
368+ width: 1.4rem;
369+ height: 1.4rem;
370+ border-radius: 999px;
371+}
372+
373+.sv-danger {
374+ color: #fecaca;
375+}
376+
377+.sv-icon-button:hover,
378+.sv-filter-chip button:hover,
379+.sv-domain-filter:hover {
380+ background: color-mix(in srgb, var(--sv-accent, #8ab4ff) 12%, transparent);
381+}
382+
383+.sv-history-item.sv-focused {
384+ outline: 2px solid var(--sv-accent, #8ab4ff);
385+ outline-offset: 0.35rem;
386+ border-radius: 0.5rem;
387+}
388+
389+.sv-key-hints kbd {
390+ border: 1px solid var(--sv-border, #2a313d);
391+ border-radius: 0.35rem;
392+ padding: 0.1rem 0.3rem;
393+ background: var(--sv-surface-variant, #1d2129);
394+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
395+}
M internal/ui/systemviews/app.go +53 -17
@@ -3,6 +3,7 @@ package systemviews
33 import (
44 "context"
55 "errors"
6+ "strings"
67
78 "github.com/bnema/dumber/internal/application/port"
89 "github.com/bnema/dumber/internal/domain/entity"
@@ -17,18 +18,23 @@ type Dependencies struct {
1718 }
1819
1920 type App struct {
20- deps Dependencies
21- currentRoute Route
22- shellTheme shellTheme
23- historyEntries []*entity.HistoryEntry
24- historyAnalytics *entity.HistoryAnalytics
25- historyDomainStats []*entity.DomainStat
26- favorites []*entity.Favorite
27- folders []*entity.Folder
28- tags []*entity.Tag
29- config *port.SystemviewConfigPayload
30- keybindings any
31- renderedHTML string
21+ deps Dependencies
22+ currentRoute Route
23+ shellTheme shellTheme
24+ historyEntries []*entity.HistoryEntry
25+ historyAnalytics *entity.HistoryAnalytics
26+ historyDomainStats []*entity.DomainStat
27+ historyQuery string
28+ historyDomainFilter string
29+ historyOffset int
30+ historyNotice string
31+ historyError string
32+ favorites []*entity.Favorite
33+ folders []*entity.Folder
34+ tags []*entity.Tag
35+ config *port.SystemviewConfigPayload
36+ keybindings any
37+ renderedHTML string
3238 }
3339
3440 const historyTimelineLimit = 25
@@ -50,7 +56,17 @@ func (a *App) Run() error {
5056 return errors.New("DOM not configured")
5157 }
5258
53- return a.deps.DOM.Mount(a.renderedHTML)
59+ if err := a.deps.DOM.Mount(a.renderedHTML); err != nil {
60+ return err
61+ }
62+ if binder, ok := a.deps.DOM.(DOMActionBinder); ok {
63+ return binder.BindActions(func(action DOMAction) {
64+ go func() {
65+ _ = a.HandleDOMAction(context.Background(), action)
66+ }()
67+ })
68+ }
69+ return nil
5470 }
5571
5672 func (a *App) LoadInitial(ctx context.Context) error {
@@ -122,7 +138,7 @@ func (a *App) loadHistoryRoute(ctx context.Context) error {
122138 return nil
123139 }
124140
125- entries, err := a.deps.History.Timeline(ctx, historyTimelineLimit, 0)
141+ entries, err := a.loadHistoryEntries(ctx)
126142 if err != nil {
127143 return err
128144 }
@@ -137,6 +153,7 @@ func (a *App) loadHistoryRoute(ctx context.Context) error {
137153 a.favorites = nil
138154 a.folders = nil
139155 a.tags = nil
156+ entries = filterHistoryEntriesByDomain(entries, a.historyDomainFilter)
140157 a.historyEntries = entries
141158 a.historyAnalytics = analytics
142159 a.historyDomainStats = domains
@@ -144,14 +161,28 @@ func (a *App) loadHistoryRoute(ctx context.Context) error {
144161 route: RouteHistory,
145162 subtitle: "Recent visits",
146163 body: historyHTML(historyRenderData{
147- Entries: entries,
148- Analytics: analytics,
149- Domains: domains,
164+ Entries: entries,
165+ Analytics: analytics,
166+ Domains: domains,
167+ Query: a.historyQuery,
168+ DomainFilter: a.historyDomainFilter,
169+ Offset: a.historyOffset,
170+ Limit: historyTimelineLimit,
171+ Notice: a.historyNotice,
172+ Error: a.historyError,
150173 }),
151174 }, a.shellTheme)
152175 return nil
153176 }
154177
178+func (a *App) loadHistoryEntries(ctx context.Context) ([]*entity.HistoryEntry, error) {
179+ query := strings.TrimSpace(a.historyQuery)
180+ if query != "" {
181+ return a.deps.History.Search(ctx, query, historyTimelineLimit)
182+ }
183+ return a.deps.History.Timeline(ctx, historyTimelineLimit, a.historyOffset)
184+}
185+
155186 func (a *App) loadFavoritesRoute(ctx context.Context) error {
156187 if a.deps.Favorites == nil {
157188 a.resetRouteState()
@@ -242,6 +273,11 @@ func (a *App) resetRouteState() {
242273 a.historyEntries = nil
243274 a.historyAnalytics = nil
244275 a.historyDomainStats = nil
276+ a.historyQuery = ""
277+ a.historyDomainFilter = ""
278+ a.historyOffset = 0
279+ a.historyNotice = ""
280+ a.historyError = ""
245281 a.favorites = nil
246282 a.folders = nil
247283 a.tags = nil
@@ -3,6 +3,7 @@ package systemviews
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "strings"
6 7
7 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
@@ -17,18 +18,23 @@ type Dependencies struct {
17 }18 }
18 19
19 type App struct {20 type App struct {
20- deps Dependencies21+ deps Dependencies
21- currentRoute Route22+ currentRoute Route
22- shellTheme shellTheme23+ shellTheme shellTheme
23- historyEntries []*entity.HistoryEntry24+ historyEntries []*entity.HistoryEntry
24- historyAnalytics *entity.HistoryAnalytics25+ historyAnalytics *entity.HistoryAnalytics
25- historyDomainStats []*entity.DomainStat26+ historyDomainStats []*entity.DomainStat
26- favorites []*entity.Favorite27+ historyQuery string
27- folders []*entity.Folder28+ historyDomainFilter string
28- tags []*entity.Tag29+ historyOffset int
29- config *port.SystemviewConfigPayload30+ historyNotice string
30- keybindings any31+ historyError string
31- renderedHTML string32+ favorites []*entity.Favorite
33+ folders []*entity.Folder
34+ tags []*entity.Tag
35+ config *port.SystemviewConfigPayload
36+ keybindings any
37+ renderedHTML string
32 }38 }
33 39
34 const historyTimelineLimit = 2540 const historyTimelineLimit = 25
@@ -50,7 +56,17 @@ func (a *App) Run() error {
50 return errors.New("DOM not configured")56 return errors.New("DOM not configured")
51 }57 }
52 58
53- return a.deps.DOM.Mount(a.renderedHTML)59+ if err := a.deps.DOM.Mount(a.renderedHTML); err != nil {
60+ return err
61+ }
62+ if binder, ok := a.deps.DOM.(DOMActionBinder); ok {
63+ return binder.BindActions(func(action DOMAction) {
64+ go func() {
65+ _ = a.HandleDOMAction(context.Background(), action)
66+ }()
67+ })
68+ }
69+ return nil
54 }70 }
55 71
56 func (a *App) LoadInitial(ctx context.Context) error {72 func (a *App) LoadInitial(ctx context.Context) error {
@@ -122,7 +138,7 @@ func (a *App) loadHistoryRoute(ctx context.Context) error {
122 return nil138 return nil
123 }139 }
124 140
125- entries, err := a.deps.History.Timeline(ctx, historyTimelineLimit, 0)141+ entries, err := a.loadHistoryEntries(ctx)
126 if err != nil {142 if err != nil {
127 return err143 return err
128 }144 }
@@ -137,6 +153,7 @@ func (a *App) loadHistoryRoute(ctx context.Context) error {
137 a.favorites = nil153 a.favorites = nil
138 a.folders = nil154 a.folders = nil
139 a.tags = nil155 a.tags = nil
156+ entries = filterHistoryEntriesByDomain(entries, a.historyDomainFilter)
140 a.historyEntries = entries157 a.historyEntries = entries
141 a.historyAnalytics = analytics158 a.historyAnalytics = analytics
142 a.historyDomainStats = domains159 a.historyDomainStats = domains
@@ -144,14 +161,28 @@ func (a *App) loadHistoryRoute(ctx context.Context) error {
144 route: RouteHistory,161 route: RouteHistory,
145 subtitle: "Recent visits",162 subtitle: "Recent visits",
146 body: historyHTML(historyRenderData{163 body: historyHTML(historyRenderData{
147- Entries: entries,164+ Entries: entries,
148- Analytics: analytics,165+ Analytics: analytics,
149- Domains: domains,166+ Domains: domains,
167+ Query: a.historyQuery,
168+ DomainFilter: a.historyDomainFilter,
169+ Offset: a.historyOffset,
170+ Limit: historyTimelineLimit,
171+ Notice: a.historyNotice,
172+ Error: a.historyError,
150 }),173 }),
151 }, a.shellTheme)174 }, a.shellTheme)
152 return nil175 return nil
153 }176 }
154 177
178+func (a *App) loadHistoryEntries(ctx context.Context) ([]*entity.HistoryEntry, error) {
179+ query := strings.TrimSpace(a.historyQuery)
180+ if query != "" {
181+ return a.deps.History.Search(ctx, query, historyTimelineLimit)
182+ }
183+ return a.deps.History.Timeline(ctx, historyTimelineLimit, a.historyOffset)
184+}
185+
155 func (a *App) loadFavoritesRoute(ctx context.Context) error {186 func (a *App) loadFavoritesRoute(ctx context.Context) error {
156 if a.deps.Favorites == nil {187 if a.deps.Favorites == nil {
157 a.resetRouteState()188 a.resetRouteState()
@@ -242,6 +273,11 @@ func (a *App) resetRouteState() {
242 a.historyEntries = nil273 a.historyEntries = nil
243 a.historyAnalytics = nil274 a.historyAnalytics = nil
244 a.historyDomainStats = nil275 a.historyDomainStats = nil
276+ a.historyQuery = ""
277+ a.historyDomainFilter = ""
278+ a.historyOffset = 0
279+ a.historyNotice = ""
280+ a.historyError = ""
245 a.favorites = nil281 a.favorites = nil
246 a.folders = nil282 a.folders = nil
247 a.tags = nil283 a.tags = nil
M internal/ui/systemviews/app_test.go +99 -6
@@ -85,6 +85,74 @@ func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) {
8585 assert.Contains(t, app.renderedHTML, `class="sv-list"`)
8686 }
8787
88+func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) {
89+ t.Parallel()
90+
91+ history := &fakeHistoryService{
92+ entries: []*entity.HistoryEntry{{
93+ ID: 42,
94+ URL: "https://example.com/page",
95+ Title: "Example",
96+ }},
97+ domainStats: []*entity.DomainStat{{Domain: "example.com", PageCount: 1, TotalVisits: 3}},
98+ }
99+
100+ app := NewApp(Dependencies{
101+ History: history,
102+ LocationURI: "dumb://history",
103+ })
104+
105+ require.NoError(t, app.LoadInitial(context.Background()))
106+ assert.Contains(t, app.renderedHTML, `data-sv-action="history.search"`)
107+ assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteEntry"`)
108+ assert.Contains(t, app.renderedHTML, `data-id="42"`)
109+ assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteRange"`)
110+ assert.Contains(t, app.renderedHTML, `data-range="hour"`)
111+ assert.Contains(t, app.renderedHTML, `data-sv-action="history.filterDomain"`)
112+ assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteDomain"`)
113+ assert.Contains(t, app.renderedHTML, "Keys:")
114+ assert.Contains(t, app.renderedHTML, "Enter")
115+}
116+
117+func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) {
118+ dom := &fakeDOM{}
119+ history := &fakeHistoryService{
120+ entries: []*entity.HistoryEntry{{ID: 42, URL: "https://example.com", Title: "Example"}},
121+ searchEntries: []*entity.HistoryEntry{{ID: 7, URL: "https://search.example", Title: "Search result"}},
122+ }
123+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
124+
125+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
126+ Action: historyActionSearch,
127+ Data: map[string]string{"query": " example "},
128+ }))
129+ assert.True(t, history.searchCalled)
130+ assert.Equal(t, "example", history.query)
131+ assert.Contains(t, dom.html, "Search result")
132+ assert.Contains(t, dom.html, "Query: example")
133+
134+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
135+ Action: historyActionDeleteEntry,
136+ Data: map[string]string{"id": "42"},
137+ }))
138+ assert.Equal(t, int64(42), history.deletedEntryID)
139+ assert.Contains(t, dom.html, "Deleted history entry")
140+
141+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
142+ Action: historyActionDeleteRange,
143+ Data: map[string]string{"range": "week"},
144+ }))
145+ assert.Equal(t, "week", history.deletedRangeID)
146+ assert.Contains(t, dom.html, "Deleted history from this week")
147+
148+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
149+ Action: historyActionDeleteDomain,
150+ Data: map[string]string{"domain": "example.com"},
151+ }))
152+ assert.Equal(t, "example.com", history.deletedDomain)
153+ assert.Contains(t, dom.html, "Deleted history for example.com")
154+}
155+
88156 func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) {
89157 t.Parallel()
90158
@@ -338,6 +406,16 @@ type fakeHistoryService struct {
338406 offset int
339407 entries []*entity.HistoryEntry
340408 err error
409+
410+ searchCalled bool
411+ query string
412+ searchLimit int
413+ searchEntries []*entity.HistoryEntry
414+
415+ deletedEntryID int64
416+ deletedRangeID string
417+ deletedDomain string
418+ domainStats []*entity.DomainStat
341419 }
342420
343421 func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
@@ -347,23 +425,38 @@ func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*
347425 return s.entries, s.err
348426 }
349427
350-func (s *fakeHistoryService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) {
351- return nil, nil
428+func (s *fakeHistoryService) Search(_ context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {
429+ s.searchCalled = true
430+ s.query = query
431+ s.searchLimit = limit
432+ if s.searchEntries != nil {
433+ return s.searchEntries, nil
434+ }
435+ return s.entries, nil
352436 }
353437
354-func (s *fakeHistoryService) DeleteEntry(context.Context, int64) error { return nil }
438+func (s *fakeHistoryService) DeleteEntry(_ context.Context, id int64) error {
439+ s.deletedEntryID = id
440+ return nil
441+}
355442
356-func (s *fakeHistoryService) DeleteRange(context.Context, string) error { return nil }
443+func (s *fakeHistoryService) DeleteRange(_ context.Context, rangeID string) error {
444+ s.deletedRangeID = rangeID
445+ return nil
446+}
357447
358448 func (s *fakeHistoryService) Analytics(context.Context) (*entity.HistoryAnalytics, error) {
359449 return nil, nil
360450 }
361451
362452 func (s *fakeHistoryService) DomainStats(context.Context, int) ([]*entity.DomainStat, error) {
363- return nil, nil
453+ return s.domainStats, nil
364454 }
365455
366-func (s *fakeHistoryService) DeleteDomain(context.Context, string) error { return nil }
456+func (s *fakeHistoryService) DeleteDomain(_ context.Context, domain string) error {
457+ s.deletedDomain = domain
458+ return nil
459+}
367460
368461 type fakeFavoritesService struct {
369462 calledList bool
@@ -85,6 +85,74 @@ func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) {
85 assert.Contains(t, app.renderedHTML, `class="sv-list"`)85 assert.Contains(t, app.renderedHTML, `class="sv-list"`)
86 }86 }
87 87
88+func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) {
89+ t.Parallel()
90+
91+ history := &fakeHistoryService{
92+ entries: []*entity.HistoryEntry{{
93+ ID: 42,
94+ URL: "https://example.com/page",
95+ Title: "Example",
96+ }},
97+ domainStats: []*entity.DomainStat{{Domain: "example.com", PageCount: 1, TotalVisits: 3}},
98+ }
99+
100+ app := NewApp(Dependencies{
101+ History: history,
102+ LocationURI: "dumb://history",
103+ })
104+
105+ require.NoError(t, app.LoadInitial(context.Background()))
106+ assert.Contains(t, app.renderedHTML, `data-sv-action="history.search"`)
107+ assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteEntry"`)
108+ assert.Contains(t, app.renderedHTML, `data-id="42"`)
109+ assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteRange"`)
110+ assert.Contains(t, app.renderedHTML, `data-range="hour"`)
111+ assert.Contains(t, app.renderedHTML, `data-sv-action="history.filterDomain"`)
112+ assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteDomain"`)
113+ assert.Contains(t, app.renderedHTML, "Keys:")
114+ assert.Contains(t, app.renderedHTML, "Enter")
115+}
116+
117+func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) {
118+ dom := &fakeDOM{}
119+ history := &fakeHistoryService{
120+ entries: []*entity.HistoryEntry{{ID: 42, URL: "https://example.com", Title: "Example"}},
121+ searchEntries: []*entity.HistoryEntry{{ID: 7, URL: "https://search.example", Title: "Search result"}},
122+ }
123+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
124+
125+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
126+ Action: historyActionSearch,
127+ Data: map[string]string{"query": " example "},
128+ }))
129+ assert.True(t, history.searchCalled)
130+ assert.Equal(t, "example", history.query)
131+ assert.Contains(t, dom.html, "Search result")
132+ assert.Contains(t, dom.html, "Query: example")
133+
134+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
135+ Action: historyActionDeleteEntry,
136+ Data: map[string]string{"id": "42"},
137+ }))
138+ assert.Equal(t, int64(42), history.deletedEntryID)
139+ assert.Contains(t, dom.html, "Deleted history entry")
140+
141+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
142+ Action: historyActionDeleteRange,
143+ Data: map[string]string{"range": "week"},
144+ }))
145+ assert.Equal(t, "week", history.deletedRangeID)
146+ assert.Contains(t, dom.html, "Deleted history from this week")
147+
148+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
149+ Action: historyActionDeleteDomain,
150+ Data: map[string]string{"domain": "example.com"},
151+ }))
152+ assert.Equal(t, "example.com", history.deletedDomain)
153+ assert.Contains(t, dom.html, "Deleted history for example.com")
154+}
155+
88 func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) {156 func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) {
89 t.Parallel()157 t.Parallel()
90 158
@@ -338,6 +406,16 @@ type fakeHistoryService struct {
338 offset int406 offset int
339 entries []*entity.HistoryEntry407 entries []*entity.HistoryEntry
340 err error408 err error
409+
410+ searchCalled bool
411+ query string
412+ searchLimit int
413+ searchEntries []*entity.HistoryEntry
414+
415+ deletedEntryID int64
416+ deletedRangeID string
417+ deletedDomain string
418+ domainStats []*entity.DomainStat
341 }419 }
342 420
343 func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {421 func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
@@ -347,23 +425,38 @@ func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*
347 return s.entries, s.err425 return s.entries, s.err
348 }426 }
349 427
350-func (s *fakeHistoryService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) {428+func (s *fakeHistoryService) Search(_ context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {
351- return nil, nil429+ s.searchCalled = true
430+ s.query = query
431+ s.searchLimit = limit
432+ if s.searchEntries != nil {
433+ return s.searchEntries, nil
434+ }
435+ return s.entries, nil
352 }436 }
353 437
354-func (s *fakeHistoryService) DeleteEntry(context.Context, int64) error { return nil }438+func (s *fakeHistoryService) DeleteEntry(_ context.Context, id int64) error {
439+ s.deletedEntryID = id
440+ return nil
441+}
355 442
356-func (s *fakeHistoryService) DeleteRange(context.Context, string) error { return nil }443+func (s *fakeHistoryService) DeleteRange(_ context.Context, rangeID string) error {
444+ s.deletedRangeID = rangeID
445+ return nil
446+}
357 447
358 func (s *fakeHistoryService) Analytics(context.Context) (*entity.HistoryAnalytics, error) {448 func (s *fakeHistoryService) Analytics(context.Context) (*entity.HistoryAnalytics, error) {
359 return nil, nil449 return nil, nil
360 }450 }
361 451
362 func (s *fakeHistoryService) DomainStats(context.Context, int) ([]*entity.DomainStat, error) {452 func (s *fakeHistoryService) DomainStats(context.Context, int) ([]*entity.DomainStat, error) {
363- return nil, nil453+ return s.domainStats, nil
364 }454 }
365 455
366-func (s *fakeHistoryService) DeleteDomain(context.Context, string) error { return nil }456+func (s *fakeHistoryService) DeleteDomain(_ context.Context, domain string) error {
457+ s.deletedDomain = domain
458+ return nil
459+}
367 460
368 type fakeFavoritesService struct {461 type fakeFavoritesService struct {
369 calledList bool462 calledList bool
M internal/ui/systemviews/dom.go +15 -0
@@ -9,6 +9,21 @@ type DOM interface {
99 Mount(html string) error
1010 }
1111
12+// DOMAction is a browser event delegated from the mounted systemview DOM.
13+type DOMAction struct {
14+ Action string
15+ Data map[string]string
16+}
17+
18+// DOMActionHandler handles delegated browser actions.
19+type DOMActionHandler func(DOMAction)
20+
21+// DOMActionBinder is implemented by DOM adapters that can delegate events back
22+// to the App. Tests can keep using the smaller DOM interface.
23+type DOMActionBinder interface {
24+ BindActions(handler DOMActionHandler) error
25+}
26+
1227 func placeholderHTML(route Route) string {
1328 name := html.EscapeString(string(route))
1429 if name == "" || route == RouteUnknown {
@@ -9,6 +9,21 @@ type DOM interface {
9 Mount(html string) error9 Mount(html string) error
10 }10 }
11 11
12+// DOMAction is a browser event delegated from the mounted systemview DOM.
13+type DOMAction struct {
14+ Action string
15+ Data map[string]string
16+}
17+
18+// DOMActionHandler handles delegated browser actions.
19+type DOMActionHandler func(DOMAction)
20+
21+// DOMActionBinder is implemented by DOM adapters that can delegate events back
22+// to the App. Tests can keep using the smaller DOM interface.
23+type DOMActionBinder interface {
24+ BindActions(handler DOMActionHandler) error
25+}
26+
12 func placeholderHTML(route Route) string {27 func placeholderHTML(route Route) string {
13 name := html.EscapeString(string(route))28 name := html.EscapeString(string(route))
14 if name == "" || route == RouteUnknown {29 if name == "" || route == RouteUnknown {
M internal/ui/systemviews/dom_js.go +218 -0
@@ -4,12 +4,15 @@ package systemviews
44
55 import (
66 "fmt"
7+ "strconv"
8+ "strings"
79 "syscall/js"
810 )
911
1012 type browserDOM struct {
1113 document js.Value
1214 target js.Value
15+ bindings []js.Func
1316 }
1417
1518 func NewDOM() DOM {
@@ -33,3 +36,218 @@ func (d *browserDOM) Mount(html string) error {
3336 d.target.Set("innerHTML", html)
3437 return nil
3538 }
39+
40+func (d *browserDOM) BindActions(handler DOMActionHandler) error {
41+ if d == nil || !d.target.Truthy() || handler == nil {
42+ return fmt.Errorf("DOM action binding target not available")
43+ }
44+
45+ clickHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
46+ event := firstJSArg(args)
47+ if !event.Truthy() {
48+ return nil
49+ }
50+ element := closestActionElement(event)
51+ if !element.Truthy() {
52+ return nil
53+ }
54+ action := element.Call("getAttribute", "data-sv-action").String()
55+ if action == "" {
56+ return nil
57+ }
58+ if !confirmAction(element) {
59+ return nil
60+ }
61+ event.Call("preventDefault")
62+ handler(DOMAction{Action: action, Data: collectActionData(element)})
63+ return nil
64+ })
65+ d.target.Call("addEventListener", "click", clickHandler)
66+ d.bindings = append(d.bindings, clickHandler)
67+
68+ submitHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
69+ event := firstJSArg(args)
70+ if !event.Truthy() {
71+ return nil
72+ }
73+ form := event.Get("target")
74+ if !form.Truthy() {
75+ return nil
76+ }
77+ action := form.Call("getAttribute", "data-sv-action").String()
78+ if action == "" {
79+ return nil
80+ }
81+ event.Call("preventDefault")
82+ data := collectActionData(form)
83+ if queryInput := form.Call("querySelector", `[name="query"]`); queryInput.Truthy() {
84+ data["query"] = queryInput.Get("value").String()
85+ }
86+ handler(DOMAction{Action: action, Data: data})
87+ return nil
88+ })
89+ d.target.Call("addEventListener", "submit", submitHandler)
90+ d.bindings = append(d.bindings, submitHandler)
91+
92+ keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
93+ d.handleHistoryKeyboard(firstJSArg(args))
94+ return nil
95+ })
96+ d.document.Call("addEventListener", "keydown", keydownHandler)
97+ d.bindings = append(d.bindings, keydownHandler)
98+
99+ return nil
100+}
101+
102+func firstJSArg(args []js.Value) js.Value {
103+ if len(args) == 0 {
104+ return js.Value{}
105+ }
106+ return args[0]
107+}
108+
109+func closestActionElement(event js.Value) js.Value {
110+ target := event.Get("target")
111+ if !target.Truthy() || !target.Get("closest").Truthy() {
112+ return js.Value{}
113+ }
114+ return target.Call("closest", "[data-sv-action]")
115+}
116+
117+func collectActionData(element js.Value) map[string]string {
118+ data := map[string]string{}
119+ if !element.Truthy() {
120+ return data
121+ }
122+ dataset := element.Get("dataset")
123+ if !dataset.Truthy() {
124+ return data
125+ }
126+ keys := js.Global().Get("Object").Call("keys", dataset)
127+ for i := 0; i < keys.Get("length").Int(); i++ {
128+ key := keys.Index(i).String()
129+ if key == "svAction" || key == "svConfirm" {
130+ continue
131+ }
132+ data[key] = dataset.Get(key).String()
133+ }
134+ return data
135+}
136+
137+func confirmAction(element js.Value) bool {
138+ message := element.Call("getAttribute", "data-sv-confirm").String()
139+ if message == "" {
140+ return true
141+ }
142+ confirm := js.Global().Get("window").Get("confirm")
143+ if !confirm.Truthy() {
144+ return true
145+ }
146+ return confirm.Invoke(message).Bool()
147+}
148+
149+func (d *browserDOM) handleHistoryKeyboard(event js.Value) {
150+ if d == nil || !d.target.Truthy() || !event.Truthy() || !d.isHistoryRoute() {
151+ return
152+ }
153+ key := strings.ToLower(event.Get("key").String())
154+ target := event.Get("target")
155+ if isEditableTarget(target) && key != "escape" {
156+ return
157+ }
158+
159+ switch key {
160+ case "/":
161+ if input := d.target.Call("querySelector", `[data-sv-history-search]`); input.Truthy() {
162+ event.Call("preventDefault")
163+ input.Call("focus")
164+ }
165+ case "j", "arrowdown":
166+ event.Call("preventDefault")
167+ d.focusHistoryRow(1)
168+ case "k", "arrowup":
169+ event.Call("preventDefault")
170+ d.focusHistoryRow(-1)
171+ case "enter":
172+ if row := d.activeHistoryRow(); row.Truthy() {
173+ event.Call("preventDefault")
174+ if link := row.Call("querySelector", ".sv-history-open"); link.Truthy() {
175+ link.Call("click")
176+ }
177+ }
178+ case "d", "delete", "backspace":
179+ if row := d.activeHistoryRow(); row.Truthy() {
180+ event.Call("preventDefault")
181+ if button := row.Call("querySelector", `[data-sv-action="history.deleteEntry"]`); button.Truthy() {
182+ button.Call("click")
183+ }
184+ }
185+ case "escape":
186+ if target.Truthy() && isEditableTarget(target) {
187+ target.Call("blur")
188+ }
189+ }
190+}
191+
192+func (d *browserDOM) isHistoryRoute() bool {
193+ return d.target.Call("querySelector", `[data-route="history"]`).Truthy()
194+}
195+
196+func isEditableTarget(target js.Value) bool {
197+ if !target.Truthy() {
198+ return false
199+ }
200+ tag := strings.ToLower(target.Get("tagName").String())
201+ return tag == "input" || tag == "textarea" || tag == "select" || target.Get("isContentEditable").Bool()
202+}
203+
204+func (d *browserDOM) focusHistoryRow(delta int) {
205+ rows := d.target.Call("querySelectorAll", "[data-sv-history-row]")
206+ length := rows.Get("length").Int()
207+ if length == 0 {
208+ return
209+ }
210+ idx := d.activeHistoryIndex()
211+ if idx < 0 {
212+ idx = 0
213+ } else {
214+ idx += delta
215+ }
216+ if idx < 0 {
217+ idx = 0
218+ }
219+ if idx >= length {
220+ idx = length - 1
221+ }
222+ for i := 0; i < length; i++ {
223+ rows.Index(i).Get("classList").Call("remove", "sv-focused")
224+ }
225+ row := rows.Index(idx)
226+ row.Get("classList").Call("add", "sv-focused")
227+ d.target.Get("dataset").Set("svActiveHistoryIndex", strconv.Itoa(idx))
228+ row.Call("scrollIntoView", map[string]any{"block": "nearest"})
229+}
230+
231+func (d *browserDOM) activeHistoryRow() js.Value {
232+ rows := d.target.Call("querySelectorAll", "[data-sv-history-row]")
233+ length := rows.Get("length").Int()
234+ if length == 0 {
235+ return js.Value{}
236+ }
237+ idx := d.activeHistoryIndex()
238+ if idx < 0 || idx >= length {
239+ idx = 0
240+ d.target.Get("dataset").Set("svActiveHistoryIndex", "0")
241+ rows.Index(0).Get("classList").Call("add", "sv-focused")
242+ }
243+ return rows.Index(idx)
244+}
245+
246+func (d *browserDOM) activeHistoryIndex() int {
247+ value := d.target.Get("dataset").Get("svActiveHistoryIndex").String()
248+ idx, err := strconv.Atoi(value)
249+ if err != nil {
250+ return -1
251+ }
252+ return idx
253+}
@@ -4,12 +4,15 @@ package systemviews
4 4
5 import (5 import (
6 "fmt"6 "fmt"
7+ "strconv"
8+ "strings"
7 "syscall/js"9 "syscall/js"
8 )10 )
9 11
10 type browserDOM struct {12 type browserDOM struct {
11 document js.Value13 document js.Value
12 target js.Value14 target js.Value
15+ bindings []js.Func
13 }16 }
14 17
15 func NewDOM() DOM {18 func NewDOM() DOM {
@@ -33,3 +36,218 @@ func (d *browserDOM) Mount(html string) error {
33 d.target.Set("innerHTML", html)36 d.target.Set("innerHTML", html)
34 return nil37 return nil
35 }38 }
39+
40+func (d *browserDOM) BindActions(handler DOMActionHandler) error {
41+ if d == nil || !d.target.Truthy() || handler == nil {
42+ return fmt.Errorf("DOM action binding target not available")
43+ }
44+
45+ clickHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
46+ event := firstJSArg(args)
47+ if !event.Truthy() {
48+ return nil
49+ }
50+ element := closestActionElement(event)
51+ if !element.Truthy() {
52+ return nil
53+ }
54+ action := element.Call("getAttribute", "data-sv-action").String()
55+ if action == "" {
56+ return nil
57+ }
58+ if !confirmAction(element) {
59+ return nil
60+ }
61+ event.Call("preventDefault")
62+ handler(DOMAction{Action: action, Data: collectActionData(element)})
63+ return nil
64+ })
65+ d.target.Call("addEventListener", "click", clickHandler)
66+ d.bindings = append(d.bindings, clickHandler)
67+
68+ submitHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
69+ event := firstJSArg(args)
70+ if !event.Truthy() {
71+ return nil
72+ }
73+ form := event.Get("target")
74+ if !form.Truthy() {
75+ return nil
76+ }
77+ action := form.Call("getAttribute", "data-sv-action").String()
78+ if action == "" {
79+ return nil
80+ }
81+ event.Call("preventDefault")
82+ data := collectActionData(form)
83+ if queryInput := form.Call("querySelector", `[name="query"]`); queryInput.Truthy() {
84+ data["query"] = queryInput.Get("value").String()
85+ }
86+ handler(DOMAction{Action: action, Data: data})
87+ return nil
88+ })
89+ d.target.Call("addEventListener", "submit", submitHandler)
90+ d.bindings = append(d.bindings, submitHandler)
91+
92+ keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
93+ d.handleHistoryKeyboard(firstJSArg(args))
94+ return nil
95+ })
96+ d.document.Call("addEventListener", "keydown", keydownHandler)
97+ d.bindings = append(d.bindings, keydownHandler)
98+
99+ return nil
100+}
101+
102+func firstJSArg(args []js.Value) js.Value {
103+ if len(args) == 0 {
104+ return js.Value{}
105+ }
106+ return args[0]
107+}
108+
109+func closestActionElement(event js.Value) js.Value {
110+ target := event.Get("target")
111+ if !target.Truthy() || !target.Get("closest").Truthy() {
112+ return js.Value{}
113+ }
114+ return target.Call("closest", "[data-sv-action]")
115+}
116+
117+func collectActionData(element js.Value) map[string]string {
118+ data := map[string]string{}
119+ if !element.Truthy() {
120+ return data
121+ }
122+ dataset := element.Get("dataset")
123+ if !dataset.Truthy() {
124+ return data
125+ }
126+ keys := js.Global().Get("Object").Call("keys", dataset)
127+ for i := 0; i < keys.Get("length").Int(); i++ {
128+ key := keys.Index(i).String()
129+ if key == "svAction" || key == "svConfirm" {
130+ continue
131+ }
132+ data[key] = dataset.Get(key).String()
133+ }
134+ return data
135+}
136+
137+func confirmAction(element js.Value) bool {
138+ message := element.Call("getAttribute", "data-sv-confirm").String()
139+ if message == "" {
140+ return true
141+ }
142+ confirm := js.Global().Get("window").Get("confirm")
143+ if !confirm.Truthy() {
144+ return true
145+ }
146+ return confirm.Invoke(message).Bool()
147+}
148+
149+func (d *browserDOM) handleHistoryKeyboard(event js.Value) {
150+ if d == nil || !d.target.Truthy() || !event.Truthy() || !d.isHistoryRoute() {
151+ return
152+ }
153+ key := strings.ToLower(event.Get("key").String())
154+ target := event.Get("target")
155+ if isEditableTarget(target) && key != "escape" {
156+ return
157+ }
158+
159+ switch key {
160+ case "/":
161+ if input := d.target.Call("querySelector", `[data-sv-history-search]`); input.Truthy() {
162+ event.Call("preventDefault")
163+ input.Call("focus")
164+ }
165+ case "j", "arrowdown":
166+ event.Call("preventDefault")
167+ d.focusHistoryRow(1)
168+ case "k", "arrowup":
169+ event.Call("preventDefault")
170+ d.focusHistoryRow(-1)
171+ case "enter":
172+ if row := d.activeHistoryRow(); row.Truthy() {
173+ event.Call("preventDefault")
174+ if link := row.Call("querySelector", ".sv-history-open"); link.Truthy() {
175+ link.Call("click")
176+ }
177+ }
178+ case "d", "delete", "backspace":
179+ if row := d.activeHistoryRow(); row.Truthy() {
180+ event.Call("preventDefault")
181+ if button := row.Call("querySelector", `[data-sv-action="history.deleteEntry"]`); button.Truthy() {
182+ button.Call("click")
183+ }
184+ }
185+ case "escape":
186+ if target.Truthy() && isEditableTarget(target) {
187+ target.Call("blur")
188+ }
189+ }
190+}
191+
192+func (d *browserDOM) isHistoryRoute() bool {
193+ return d.target.Call("querySelector", `[data-route="history"]`).Truthy()
194+}
195+
196+func isEditableTarget(target js.Value) bool {
197+ if !target.Truthy() {
198+ return false
199+ }
200+ tag := strings.ToLower(target.Get("tagName").String())
201+ return tag == "input" || tag == "textarea" || tag == "select" || target.Get("isContentEditable").Bool()
202+}
203+
204+func (d *browserDOM) focusHistoryRow(delta int) {
205+ rows := d.target.Call("querySelectorAll", "[data-sv-history-row]")
206+ length := rows.Get("length").Int()
207+ if length == 0 {
208+ return
209+ }
210+ idx := d.activeHistoryIndex()
211+ if idx < 0 {
212+ idx = 0
213+ } else {
214+ idx += delta
215+ }
216+ if idx < 0 {
217+ idx = 0
218+ }
219+ if idx >= length {
220+ idx = length - 1
221+ }
222+ for i := 0; i < length; i++ {
223+ rows.Index(i).Get("classList").Call("remove", "sv-focused")
224+ }
225+ row := rows.Index(idx)
226+ row.Get("classList").Call("add", "sv-focused")
227+ d.target.Get("dataset").Set("svActiveHistoryIndex", strconv.Itoa(idx))
228+ row.Call("scrollIntoView", map[string]any{"block": "nearest"})
229+}
230+
231+func (d *browserDOM) activeHistoryRow() js.Value {
232+ rows := d.target.Call("querySelectorAll", "[data-sv-history-row]")
233+ length := rows.Get("length").Int()
234+ if length == 0 {
235+ return js.Value{}
236+ }
237+ idx := d.activeHistoryIndex()
238+ if idx < 0 || idx >= length {
239+ idx = 0
240+ d.target.Get("dataset").Set("svActiveHistoryIndex", "0")
241+ rows.Index(0).Get("classList").Call("add", "sv-focused")
242+ }
243+ return rows.Index(idx)
244+}
245+
246+func (d *browserDOM) activeHistoryIndex() int {
247+ value := d.target.Get("dataset").Get("svActiveHistoryIndex").String()
248+ idx, err := strconv.Atoi(value)
249+ if err != nil {
250+ return -1
251+ }
252+ return idx
253+}
A internal/ui/systemviews/history_actions.go +150 -0
@@ -0,0 +1,150 @@
1+package systemviews
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strconv"
7+ "strings"
8+)
9+
10+const (
11+ historyActionSearch = "history.search"
12+ historyActionClear = "history.clear"
13+ historyActionFilterDomain = "history.filterDomain"
14+ historyActionClearDomain = "history.clearDomain"
15+ historyActionPage = "history.page"
16+ historyActionDeleteEntry = "history.deleteEntry"
17+ historyActionDeleteRange = "history.deleteRange"
18+ historyActionDeleteDomain = "history.deleteDomain"
19+)
20+
21+// HandleDOMAction applies an action delegated from the browser DOM and refreshes
22+// the mounted view. It keeps UI orchestration in the systemview edge and calls
23+// only application ports for data changes.
24+func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error {
25+ if a == nil {
26+ return fmt.Errorf("app is nil")
27+ }
28+ if a.currentRoute == RouteUnknown || a.currentRoute == "" {
29+ a.currentRoute = ParseRoute(a.deps.LocationURI)
30+ }
31+
32+ switch a.currentRoute {
33+ case RouteHistory:
34+ a.historyError = ""
35+ if err := a.handleHistoryAction(ctx, event); err != nil {
36+ a.historyNotice = ""
37+ a.historyError = err.Error()
38+ }
39+ if err := a.loadHistoryRoute(ctx); err != nil {
40+ a.renderRouteError(err)
41+ }
42+ return a.mountRenderedHTML()
43+ default:
44+ return nil
45+ }
46+}
47+
48+func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
49+ if a.deps.History == nil {
50+ return fmt.Errorf("history service not configured")
51+ }
52+
53+ data := event.Data
54+ switch event.Action {
55+ case historyActionSearch:
56+ a.historyQuery = strings.TrimSpace(data["query"])
57+ a.historyOffset = 0
58+ a.historyNotice = ""
59+ case historyActionClear:
60+ a.historyQuery = ""
61+ a.historyDomainFilter = ""
62+ a.historyOffset = 0
63+ a.historyNotice = ""
64+ case historyActionFilterDomain:
65+ domain := strings.TrimSpace(data["domain"])
66+ if domain == "" {
67+ return fmt.Errorf("domain is required")
68+ }
69+ a.historyDomainFilter = domain
70+ a.historyOffset = 0
71+ a.historyNotice = ""
72+ case historyActionClearDomain:
73+ a.historyDomainFilter = ""
74+ a.historyOffset = 0
75+ a.historyNotice = ""
76+ case historyActionPage:
77+ offset, err := strconv.Atoi(strings.TrimSpace(data["offset"]))
78+ if err != nil || offset < 0 {
79+ return fmt.Errorf("invalid history offset")
80+ }
81+ a.historyOffset = offset
82+ a.historyNotice = ""
83+ case historyActionDeleteEntry:
84+ id, err := strconv.ParseInt(strings.TrimSpace(data["id"]), 10, 64)
85+ if err != nil || id <= 0 {
86+ return fmt.Errorf("invalid history entry id")
87+ }
88+ if err := a.deps.History.DeleteEntry(ctx, id); err != nil {
89+ return err
90+ }
91+ a.historyNotice = "Deleted history entry"
92+ case historyActionDeleteRange:
93+ rangeID := strings.TrimSpace(data["range"])
94+ if !validHistoryRange(rangeID) {
95+ return fmt.Errorf("invalid history cleanup range")
96+ }
97+ if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil {
98+ return err
99+ }
100+ a.historyOffset = 0
101+ a.historyNotice = historyRangeNotice(rangeID)
102+ case historyActionDeleteDomain:
103+ domain := strings.TrimSpace(data["domain"])
104+ if domain == "" {
105+ return fmt.Errorf("domain is required")
106+ }
107+ if err := a.deps.History.DeleteDomain(ctx, domain); err != nil {
108+ return err
109+ }
110+ if strings.EqualFold(a.historyDomainFilter, domain) {
111+ a.historyDomainFilter = ""
112+ }
113+ a.historyOffset = 0
114+ a.historyNotice = "Deleted history for " + domain
115+ }
116+ return nil
117+}
118+
119+func (a *App) mountRenderedHTML() error {
120+ if a.deps.DOM == nil {
121+ return nil
122+ }
123+ return a.deps.DOM.Mount(a.renderedHTML)
124+}
125+
126+func validHistoryRange(rangeID string) bool {
127+ switch rangeID {
128+ case "hour", "day", "week", "month", "all":
129+ return true
130+ default:
131+ return false
132+ }
133+}
134+
135+func historyRangeNotice(rangeID string) string {
136+ switch rangeID {
137+ case "hour":
138+ return "Deleted history from the last hour"
139+ case "day":
140+ return "Deleted history from today"
141+ case "week":
142+ return "Deleted history from this week"
143+ case "month":
144+ return "Deleted history from this month"
145+ case "all":
146+ return "Deleted all history"
147+ default:
148+ return "Deleted history"
149+ }
150+}
@@ -0,0 +1,150 @@
1+package systemviews
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strconv"
7+ "strings"
8+)
9+
10+const (
11+ historyActionSearch = "history.search"
12+ historyActionClear = "history.clear"
13+ historyActionFilterDomain = "history.filterDomain"
14+ historyActionClearDomain = "history.clearDomain"
15+ historyActionPage = "history.page"
16+ historyActionDeleteEntry = "history.deleteEntry"
17+ historyActionDeleteRange = "history.deleteRange"
18+ historyActionDeleteDomain = "history.deleteDomain"
19+)
20+
21+// HandleDOMAction applies an action delegated from the browser DOM and refreshes
22+// the mounted view. It keeps UI orchestration in the systemview edge and calls
23+// only application ports for data changes.
24+func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error {
25+ if a == nil {
26+ return fmt.Errorf("app is nil")
27+ }
28+ if a.currentRoute == RouteUnknown || a.currentRoute == "" {
29+ a.currentRoute = ParseRoute(a.deps.LocationURI)
30+ }
31+
32+ switch a.currentRoute {
33+ case RouteHistory:
34+ a.historyError = ""
35+ if err := a.handleHistoryAction(ctx, event); err != nil {
36+ a.historyNotice = ""
37+ a.historyError = err.Error()
38+ }
39+ if err := a.loadHistoryRoute(ctx); err != nil {
40+ a.renderRouteError(err)
41+ }
42+ return a.mountRenderedHTML()
43+ default:
44+ return nil
45+ }
46+}
47+
48+func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
49+ if a.deps.History == nil {
50+ return fmt.Errorf("history service not configured")
51+ }
52+
53+ data := event.Data
54+ switch event.Action {
55+ case historyActionSearch:
56+ a.historyQuery = strings.TrimSpace(data["query"])
57+ a.historyOffset = 0
58+ a.historyNotice = ""
59+ case historyActionClear:
60+ a.historyQuery = ""
61+ a.historyDomainFilter = ""
62+ a.historyOffset = 0
63+ a.historyNotice = ""
64+ case historyActionFilterDomain:
65+ domain := strings.TrimSpace(data["domain"])
66+ if domain == "" {
67+ return fmt.Errorf("domain is required")
68+ }
69+ a.historyDomainFilter = domain
70+ a.historyOffset = 0
71+ a.historyNotice = ""
72+ case historyActionClearDomain:
73+ a.historyDomainFilter = ""
74+ a.historyOffset = 0
75+ a.historyNotice = ""
76+ case historyActionPage:
77+ offset, err := strconv.Atoi(strings.TrimSpace(data["offset"]))
78+ if err != nil || offset < 0 {
79+ return fmt.Errorf("invalid history offset")
80+ }
81+ a.historyOffset = offset
82+ a.historyNotice = ""
83+ case historyActionDeleteEntry:
84+ id, err := strconv.ParseInt(strings.TrimSpace(data["id"]), 10, 64)
85+ if err != nil || id <= 0 {
86+ return fmt.Errorf("invalid history entry id")
87+ }
88+ if err := a.deps.History.DeleteEntry(ctx, id); err != nil {
89+ return err
90+ }
91+ a.historyNotice = "Deleted history entry"
92+ case historyActionDeleteRange:
93+ rangeID := strings.TrimSpace(data["range"])
94+ if !validHistoryRange(rangeID) {
95+ return fmt.Errorf("invalid history cleanup range")
96+ }
97+ if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil {
98+ return err
99+ }
100+ a.historyOffset = 0
101+ a.historyNotice = historyRangeNotice(rangeID)
102+ case historyActionDeleteDomain:
103+ domain := strings.TrimSpace(data["domain"])
104+ if domain == "" {
105+ return fmt.Errorf("domain is required")
106+ }
107+ if err := a.deps.History.DeleteDomain(ctx, domain); err != nil {
108+ return err
109+ }
110+ if strings.EqualFold(a.historyDomainFilter, domain) {
111+ a.historyDomainFilter = ""
112+ }
113+ a.historyOffset = 0
114+ a.historyNotice = "Deleted history for " + domain
115+ }
116+ return nil
117+}
118+
119+func (a *App) mountRenderedHTML() error {
120+ if a.deps.DOM == nil {
121+ return nil
122+ }
123+ return a.deps.DOM.Mount(a.renderedHTML)
124+}
125+
126+func validHistoryRange(rangeID string) bool {
127+ switch rangeID {
128+ case "hour", "day", "week", "month", "all":
129+ return true
130+ default:
131+ return false
132+ }
133+}
134+
135+func historyRangeNotice(rangeID string) string {
136+ switch rangeID {
137+ case "hour":
138+ return "Deleted history from the last hour"
139+ case "day":
140+ return "Deleted history from today"
141+ case "week":
142+ return "Deleted history from this week"
143+ case "month":
144+ return "Deleted history from this month"
145+ case "all":
146+ return "Deleted all history"
147+ default:
148+ return "Deleted history"
149+ }
150+}
M internal/ui/systemviews/history_view.go +176 -12
@@ -11,20 +11,145 @@ import (
1111 )
1212
1313 type historyRenderData struct {
14- Entries []*entity.HistoryEntry
15- Analytics *entity.HistoryAnalytics
16- Domains []*entity.DomainStat
14+ Entries []*entity.HistoryEntry
15+ Analytics *entity.HistoryAnalytics
16+ Domains []*entity.DomainStat
17+ Query string
18+ DomainFilter string
19+ Offset int
20+ Limit int
21+ Notice string
22+ Error string
1723 }
1824
1925 func historyHTML(data historyRenderData) string {
2026 sections := []string{
27+ historyStatusHTML(data),
28+ sectionHTML("sv-history-controls", "Manage history", historyControlsHTML(data)),
2129 sectionHTML("sv-history-summary", "History", historySummaryHTML(data)),
2230 sectionHTML("sv-history-domains", "Top domains", historyDomainsHTML(data.Domains)),
23- sectionHTML("sv-history-timeline", "Recent visits", historyTimelineHTML(data.Entries)),
31+ sectionHTML("sv-history-timeline", "Recent visits", historyTimelineHTML(data)),
2432 }
2533 return strings.Join(sections, "")
2634 }
2735
36+func historyStatusHTML(data historyRenderData) string {
37+ var out strings.Builder
38+ if strings.TrimSpace(data.Notice) != "" {
39+ out.WriteString(fmt.Sprintf(`<div class="sv-alert sv-alert-success" role="status">%s</div>`, html.EscapeString(data.Notice)))
40+ }
41+ if strings.TrimSpace(data.Error) != "" {
42+ out.WriteString(fmt.Sprintf(`<div class="sv-alert sv-alert-error" role="alert">%s</div>`, html.EscapeString(data.Error)))
43+ }
44+ return out.String()
45+}
46+
47+func historyControlsHTML(data historyRenderData) string {
48+ query := strings.TrimSpace(data.Query)
49+ domain := strings.TrimSpace(data.DomainFilter)
50+ limit := data.Limit
51+ if limit <= 0 {
52+ limit = historyTimelineLimit
53+ }
54+ offset := data.Offset
55+ if offset < 0 {
56+ offset = 0
57+ }
58+
59+ var out strings.Builder
60+ out.WriteString(`<form class="sv-toolbar sv-history-search" data-sv-action="history.search">`)
61+ out.WriteString(fmt.Sprintf(`<label class="sv-search-label"><span>Search</span><input data-sv-history-search name="query" type="search" value="%s" placeholder="Search title or URL" autocomplete="off"></label>`, html.EscapeString(query)))
62+ out.WriteString(`<button class="sv-button" type="submit">Search</button>`)
63+ out.WriteString(`<button class="sv-button sv-button-secondary" type="button" data-sv-action="history.clear">Clear</button>`)
64+ out.WriteString(`</form>`)
65+
66+ if query != "" || domain != "" {
67+ out.WriteString(`<div class="sv-active-filters">`)
68+ if query != "" {
69+ out.WriteString(fmt.Sprintf(`<span class="sv-filter-chip">Query: %s</span>`, html.EscapeString(query)))
70+ }
71+ if domain != "" {
72+ out.WriteString(fmt.Sprintf(`<span class="sv-filter-chip">Domain: %s <button type="button" data-sv-action="history.clearDomain" aria-label="Clear domain filter">×</button></span>`, html.EscapeString(domain)))
73+ }
74+ out.WriteString(`</div>`)
75+ }
76+
77+ out.WriteString(`<div class="sv-history-actions-panel">`)
78+ out.WriteString(`<div class="sv-button-row" aria-label="History pagination">`)
79+ prevOffset := offset - limit
80+ if prevOffset < 0 {
81+ prevOffset = 0
82+ }
83+ out.WriteString(historyActionButton("Previous", historyActionPage, map[string]string{"offset": fmt.Sprintf("%d", prevOffset)}, false, offset == 0 || query != ""))
84+ out.WriteString(fmt.Sprintf(`<span class="sv-meta">Showing %d item%s%s</span>`, countHistoryEntries(data.Entries), pluralSuffix(countHistoryEntries(data.Entries)), historyOffsetLabel(offset, query)))
85+ out.WriteString(historyActionButton("Next", historyActionPage, map[string]string{"offset": fmt.Sprintf("%d", offset+limit)}, false, query != "" || countHistoryEntries(data.Entries) < limit))
86+ out.WriteString(`</div>`)
87+
88+ out.WriteString(`<div class="sv-button-row" aria-label="History cleanup">`)
89+ out.WriteString(`<span class="sv-meta">Clean up</span>`)
90+ for _, item := range []struct {
91+ label string
92+ rangeID string
93+ }{
94+ {label: "Last hour", rangeID: "hour"},
95+ {label: "Today", rangeID: "day"},
96+ {label: "Week", rangeID: "week"},
97+ {label: "Month", rangeID: "month"},
98+ {label: "All", rangeID: "all"},
99+ } {
100+ confirm := "Delete history for " + strings.ToLower(item.label) + "?"
101+ if item.rangeID == "all" {
102+ confirm = "Delete all browsing history?"
103+ }
104+ out.WriteString(historyActionButton(item.label, historyActionDeleteRange, map[string]string{"range": item.rangeID, "sv-confirm": confirm}, true, false))
105+ }
106+ out.WriteString(`</div>`)
107+ out.WriteString(`</div>`)
108+
109+ out.WriteString(`<p class="sv-meta sv-key-hints">Keys: <kbd>/</kbd> search, <kbd>j</kbd>/<kbd>k</kbd> move, <kbd>Enter</kbd> open, <kbd>d</kbd> delete focused entry.</p>`)
110+ return out.String()
111+}
112+
113+func historyOffsetLabel(offset int, query string) string {
114+ if strings.TrimSpace(query) != "" {
115+ return " matching search"
116+ }
117+ if offset <= 0 {
118+ return ""
119+ }
120+ return fmt.Sprintf(" from offset %d", offset)
121+}
122+
123+func historyActionButton(label, action string, data map[string]string, danger, disabled bool) string {
124+ classes := "sv-button sv-button-secondary"
125+ if danger {
126+ classes += " sv-button-danger"
127+ }
128+ attrs := []string{
129+ `type="button"`,
130+ fmt.Sprintf(`class="%s"`, html.EscapeString(classes)),
131+ fmt.Sprintf(`data-sv-action="%s"`, html.EscapeString(action)),
132+ }
133+ if disabled {
134+ attrs = append(attrs, `disabled aria-disabled="true"`)
135+ }
136+ for key, value := range data {
137+ attrs = append(attrs, fmt.Sprintf(`data-%s="%s"`, html.EscapeString(kebabDataKey(key)), html.EscapeString(value)))
138+ }
139+ return fmt.Sprintf(`<button %s>%s</button>`, strings.Join(attrs, " "), html.EscapeString(label))
140+}
141+
142+func kebabDataKey(key string) string {
143+ return strings.ReplaceAll(key, "_", "-")
144+}
145+
146+func pluralSuffix(count int) string {
147+ if count == 1 {
148+ return ""
149+ }
150+ return "s"
151+}
152+
28153 func historySummaryHTML(data historyRenderData) string {
29154 entries := int64(countHistoryEntries(data.Entries))
30155 visits := sumHistoryVisits(data.Entries)
@@ -53,12 +178,21 @@ func historyDomainsHTML(domains []*entity.DomainStat) string {
53178 if domain == nil || strings.TrimSpace(domain.Domain) == "" {
54179 continue
55180 }
181+ displayDomain := strings.TrimPrefix(domain.Domain, "www.")
182+ chips.WriteString(`<span class="sv-domain-chip">`)
183+ chips.WriteString(fmt.Sprintf(
184+ `<button type="button" class="sv-domain-filter" data-sv-action="history.filterDomain" data-domain="%s">%s</button>`,
185+ html.EscapeString(displayDomain),
186+ html.EscapeString(displayDomain),
187+ ))
188+ chips.WriteString(fmt.Sprintf(`<span class="sv-domain-meta">%d pages · %d visits</span>`, domain.PageCount, domain.TotalVisits))
56189 chips.WriteString(fmt.Sprintf(
57- `<span class="sv-domain-chip"><span>%s</span><span class="sv-domain-meta">%d pages · %d visits</span></span>`,
58- html.EscapeString(strings.TrimPrefix(domain.Domain, "www.")),
59- domain.PageCount,
60- domain.TotalVisits,
190+ `<button type="button" class="sv-icon-button sv-danger" data-sv-action="history.deleteDomain" data-domain="%s" data-sv-confirm="Delete all history for %s?" aria-label="Delete history for %s">×</button>`,
191+ html.EscapeString(displayDomain),
192+ html.EscapeString(displayDomain),
193+ html.EscapeString(displayDomain),
61194 ))
195+ chips.WriteString(`</span>`)
62196 }
63197 if chips.Len() == 0 {
64198 return emptyStateHTML("No domain statistics yet")
@@ -66,9 +200,12 @@ func historyDomainsHTML(domains []*entity.DomainStat) string {
66200 return `<div class="sv-domain-list">` + chips.String() + `</div>`
67201 }
68202
69-func historyTimelineHTML(entries []*entity.HistoryEntry) string {
70- groups := groupHistoryEntries(entries)
203+func historyTimelineHTML(data historyRenderData) string {
204+ groups := groupHistoryEntries(data.Entries)
71205 if len(groups) == 0 {
206+ if strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != "" {
207+ return emptyStateHTML("No history entries match the current filters")
208+ }
72209 return emptyStateHTML("No history entries")
73210 }
74211
@@ -111,10 +248,19 @@ func historyItemHTML(entry *entity.HistoryEntry) string {
111248 meta = append(meta, formatHistoryTime(entry.LastVisited))
112249 }
113250
114- return fmt.Sprintf(`<article class="sv-history-item"><div class="sv-history-main">%s<p class="sv-history-url">%s</p></div><p class="sv-history-meta">%s</p></article>`,
115- linkHTML(entry.URL, label),
251+ deleteButton := historyActionButton("Delete", historyActionDeleteEntry, map[string]string{
252+ "id": fmt.Sprintf("%d", entry.ID),
253+ "sv-confirm": "Delete this history entry?",
254+ }, true, entry.ID <= 0)
255+
256+ return fmt.Sprintf(`<article class="sv-history-item" data-sv-history-row data-history-id="%d"><div class="sv-history-main"><a class="sv-link sv-history-open" href="%s">%s</a><p class="sv-history-url">%s</p><p class="sv-history-meta">%s</p></div><div class="sv-history-row-actions"><a class="sv-button sv-button-secondary" href="%s">Open</a>%s</div></article>`,
257+ entry.ID,
258+ html.EscapeString(sanitizeHref(entry.URL)),
259+ html.EscapeString(label),
116260 html.EscapeString(entry.URL),
117261 html.EscapeString(strings.Join(nonEmptyStrings(meta), " · ")),
262+ html.EscapeString(sanitizeHref(entry.URL)),
263+ deleteButton,
118264 )
119265 }
120266
@@ -201,6 +347,24 @@ func countUniqueHistoryDays(entries []*entity.HistoryEntry) int64 {
201347 return int64(len(seen))
202348 }
203349
350+func filterHistoryEntriesByDomain(entries []*entity.HistoryEntry, domain string) []*entity.HistoryEntry {
351+ domain = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(domain)), "www.")
352+ if domain == "" {
353+ return entries
354+ }
355+ filtered := make([]*entity.HistoryEntry, 0, len(entries))
356+ for _, entry := range entries {
357+ if entry == nil {
358+ continue
359+ }
360+ entryDomain := strings.TrimPrefix(strings.ToLower(displayHistoryDomain(entry.URL)), "www.")
361+ if entryDomain == domain {
362+ filtered = append(filtered, entry)
363+ }
364+ }
365+ return filtered
366+}
367+
204368 func displayHistoryDomain(raw string) string {
205369 parsed, err := url.Parse(raw)
206370 if err != nil || parsed.Hostname() == "" {
@@ -11,20 +11,145 @@ import (
11 )11 )
12 12
13 type historyRenderData struct {13 type historyRenderData struct {
14- Entries []*entity.HistoryEntry14+ Entries []*entity.HistoryEntry
15- Analytics *entity.HistoryAnalytics15+ Analytics *entity.HistoryAnalytics
16- Domains []*entity.DomainStat16+ Domains []*entity.DomainStat
17+ Query string
18+ DomainFilter string
19+ Offset int
20+ Limit int
21+ Notice string
22+ Error string
17 }23 }
18 24
19 func historyHTML(data historyRenderData) string {25 func historyHTML(data historyRenderData) string {
20 sections := []string{26 sections := []string{
27+ historyStatusHTML(data),
28+ sectionHTML("sv-history-controls", "Manage history", historyControlsHTML(data)),
21 sectionHTML("sv-history-summary", "History", historySummaryHTML(data)),29 sectionHTML("sv-history-summary", "History", historySummaryHTML(data)),
22 sectionHTML("sv-history-domains", "Top domains", historyDomainsHTML(data.Domains)),30 sectionHTML("sv-history-domains", "Top domains", historyDomainsHTML(data.Domains)),
23- sectionHTML("sv-history-timeline", "Recent visits", historyTimelineHTML(data.Entries)),31+ sectionHTML("sv-history-timeline", "Recent visits", historyTimelineHTML(data)),
24 }32 }
25 return strings.Join(sections, "")33 return strings.Join(sections, "")
26 }34 }
27 35
36+func historyStatusHTML(data historyRenderData) string {
37+ var out strings.Builder
38+ if strings.TrimSpace(data.Notice) != "" {
39+ out.WriteString(fmt.Sprintf(`<div class="sv-alert sv-alert-success" role="status">%s</div>`, html.EscapeString(data.Notice)))
40+ }
41+ if strings.TrimSpace(data.Error) != "" {
42+ out.WriteString(fmt.Sprintf(`<div class="sv-alert sv-alert-error" role="alert">%s</div>`, html.EscapeString(data.Error)))
43+ }
44+ return out.String()
45+}
46+
47+func historyControlsHTML(data historyRenderData) string {
48+ query := strings.TrimSpace(data.Query)
49+ domain := strings.TrimSpace(data.DomainFilter)
50+ limit := data.Limit
51+ if limit <= 0 {
52+ limit = historyTimelineLimit
53+ }
54+ offset := data.Offset
55+ if offset < 0 {
56+ offset = 0
57+ }
58+
59+ var out strings.Builder
60+ out.WriteString(`<form class="sv-toolbar sv-history-search" data-sv-action="history.search">`)
61+ out.WriteString(fmt.Sprintf(`<label class="sv-search-label"><span>Search</span><input data-sv-history-search name="query" type="search" value="%s" placeholder="Search title or URL" autocomplete="off"></label>`, html.EscapeString(query)))
62+ out.WriteString(`<button class="sv-button" type="submit">Search</button>`)
63+ out.WriteString(`<button class="sv-button sv-button-secondary" type="button" data-sv-action="history.clear">Clear</button>`)
64+ out.WriteString(`</form>`)
65+
66+ if query != "" || domain != "" {
67+ out.WriteString(`<div class="sv-active-filters">`)
68+ if query != "" {
69+ out.WriteString(fmt.Sprintf(`<span class="sv-filter-chip">Query: %s</span>`, html.EscapeString(query)))
70+ }
71+ if domain != "" {
72+ out.WriteString(fmt.Sprintf(`<span class="sv-filter-chip">Domain: %s <button type="button" data-sv-action="history.clearDomain" aria-label="Clear domain filter">×</button></span>`, html.EscapeString(domain)))
73+ }
74+ out.WriteString(`</div>`)
75+ }
76+
77+ out.WriteString(`<div class="sv-history-actions-panel">`)
78+ out.WriteString(`<div class="sv-button-row" aria-label="History pagination">`)
79+ prevOffset := offset - limit
80+ if prevOffset < 0 {
81+ prevOffset = 0
82+ }
83+ out.WriteString(historyActionButton("Previous", historyActionPage, map[string]string{"offset": fmt.Sprintf("%d", prevOffset)}, false, offset == 0 || query != ""))
84+ out.WriteString(fmt.Sprintf(`<span class="sv-meta">Showing %d item%s%s</span>`, countHistoryEntries(data.Entries), pluralSuffix(countHistoryEntries(data.Entries)), historyOffsetLabel(offset, query)))
85+ out.WriteString(historyActionButton("Next", historyActionPage, map[string]string{"offset": fmt.Sprintf("%d", offset+limit)}, false, query != "" || countHistoryEntries(data.Entries) < limit))
86+ out.WriteString(`</div>`)
87+
88+ out.WriteString(`<div class="sv-button-row" aria-label="History cleanup">`)
89+ out.WriteString(`<span class="sv-meta">Clean up</span>`)
90+ for _, item := range []struct {
91+ label string
92+ rangeID string
93+ }{
94+ {label: "Last hour", rangeID: "hour"},
95+ {label: "Today", rangeID: "day"},
96+ {label: "Week", rangeID: "week"},
97+ {label: "Month", rangeID: "month"},
98+ {label: "All", rangeID: "all"},
99+ } {
100+ confirm := "Delete history for " + strings.ToLower(item.label) + "?"
101+ if item.rangeID == "all" {
102+ confirm = "Delete all browsing history?"
103+ }
104+ out.WriteString(historyActionButton(item.label, historyActionDeleteRange, map[string]string{"range": item.rangeID, "sv-confirm": confirm}, true, false))
105+ }
106+ out.WriteString(`</div>`)
107+ out.WriteString(`</div>`)
108+
109+ out.WriteString(`<p class="sv-meta sv-key-hints">Keys: <kbd>/</kbd> search, <kbd>j</kbd>/<kbd>k</kbd> move, <kbd>Enter</kbd> open, <kbd>d</kbd> delete focused entry.</p>`)
110+ return out.String()
111+}
112+
113+func historyOffsetLabel(offset int, query string) string {
114+ if strings.TrimSpace(query) != "" {
115+ return " matching search"
116+ }
117+ if offset <= 0 {
118+ return ""
119+ }
120+ return fmt.Sprintf(" from offset %d", offset)
121+}
122+
123+func historyActionButton(label, action string, data map[string]string, danger, disabled bool) string {
124+ classes := "sv-button sv-button-secondary"
125+ if danger {
126+ classes += " sv-button-danger"
127+ }
128+ attrs := []string{
129+ `type="button"`,
130+ fmt.Sprintf(`class="%s"`, html.EscapeString(classes)),
131+ fmt.Sprintf(`data-sv-action="%s"`, html.EscapeString(action)),
132+ }
133+ if disabled {
134+ attrs = append(attrs, `disabled aria-disabled="true"`)
135+ }
136+ for key, value := range data {
137+ attrs = append(attrs, fmt.Sprintf(`data-%s="%s"`, html.EscapeString(kebabDataKey(key)), html.EscapeString(value)))
138+ }
139+ return fmt.Sprintf(`<button %s>%s</button>`, strings.Join(attrs, " "), html.EscapeString(label))
140+}
141+
142+func kebabDataKey(key string) string {
143+ return strings.ReplaceAll(key, "_", "-")
144+}
145+
146+func pluralSuffix(count int) string {
147+ if count == 1 {
148+ return ""
149+ }
150+ return "s"
151+}
152+
28 func historySummaryHTML(data historyRenderData) string {153 func historySummaryHTML(data historyRenderData) string {
29 entries := int64(countHistoryEntries(data.Entries))154 entries := int64(countHistoryEntries(data.Entries))
30 visits := sumHistoryVisits(data.Entries)155 visits := sumHistoryVisits(data.Entries)
@@ -53,12 +178,21 @@ func historyDomainsHTML(domains []*entity.DomainStat) string {
53 if domain == nil || strings.TrimSpace(domain.Domain) == "" {178 if domain == nil || strings.TrimSpace(domain.Domain) == "" {
54 continue179 continue
55 }180 }
181+ displayDomain := strings.TrimPrefix(domain.Domain, "www.")
182+ chips.WriteString(`<span class="sv-domain-chip">`)
183+ chips.WriteString(fmt.Sprintf(
184+ `<button type="button" class="sv-domain-filter" data-sv-action="history.filterDomain" data-domain="%s">%s</button>`,
185+ html.EscapeString(displayDomain),
186+ html.EscapeString(displayDomain),
187+ ))
188+ chips.WriteString(fmt.Sprintf(`<span class="sv-domain-meta">%d pages · %d visits</span>`, domain.PageCount, domain.TotalVisits))
56 chips.WriteString(fmt.Sprintf(189 chips.WriteString(fmt.Sprintf(
57- `<span class="sv-domain-chip"><span>%s</span><span class="sv-domain-meta">%d pages · %d visits</span></span>`,190+ `<button type="button" class="sv-icon-button sv-danger" data-sv-action="history.deleteDomain" data-domain="%s" data-sv-confirm="Delete all history for %s?" aria-label="Delete history for %s">×</button>`,
58- html.EscapeString(strings.TrimPrefix(domain.Domain, "www.")),191+ html.EscapeString(displayDomain),
59- domain.PageCount,192+ html.EscapeString(displayDomain),
60- domain.TotalVisits,193+ html.EscapeString(displayDomain),
61 ))194 ))
195+ chips.WriteString(`</span>`)
62 }196 }
63 if chips.Len() == 0 {197 if chips.Len() == 0 {
64 return emptyStateHTML("No domain statistics yet")198 return emptyStateHTML("No domain statistics yet")
@@ -66,9 +200,12 @@ func historyDomainsHTML(domains []*entity.DomainStat) string {
66 return `<div class="sv-domain-list">` + chips.String() + `</div>`200 return `<div class="sv-domain-list">` + chips.String() + `</div>`
67 }201 }
68 202
69-func historyTimelineHTML(entries []*entity.HistoryEntry) string {203+func historyTimelineHTML(data historyRenderData) string {
70- groups := groupHistoryEntries(entries)204+ groups := groupHistoryEntries(data.Entries)
71 if len(groups) == 0 {205 if len(groups) == 0 {
206+ if strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != "" {
207+ return emptyStateHTML("No history entries match the current filters")
208+ }
72 return emptyStateHTML("No history entries")209 return emptyStateHTML("No history entries")
73 }210 }
74 211
@@ -111,10 +248,19 @@ func historyItemHTML(entry *entity.HistoryEntry) string {
111 meta = append(meta, formatHistoryTime(entry.LastVisited))248 meta = append(meta, formatHistoryTime(entry.LastVisited))
112 }249 }
113 250
114- return fmt.Sprintf(`<article class="sv-history-item"><div class="sv-history-main">%s<p class="sv-history-url">%s</p></div><p class="sv-history-meta">%s</p></article>`,251+ deleteButton := historyActionButton("Delete", historyActionDeleteEntry, map[string]string{
115- linkHTML(entry.URL, label),252+ "id": fmt.Sprintf("%d", entry.ID),
253+ "sv-confirm": "Delete this history entry?",
254+ }, true, entry.ID <= 0)
255+
256+ return fmt.Sprintf(`<article class="sv-history-item" data-sv-history-row data-history-id="%d"><div class="sv-history-main"><a class="sv-link sv-history-open" href="%s">%s</a><p class="sv-history-url">%s</p><p class="sv-history-meta">%s</p></div><div class="sv-history-row-actions"><a class="sv-button sv-button-secondary" href="%s">Open</a>%s</div></article>`,
257+ entry.ID,
258+ html.EscapeString(sanitizeHref(entry.URL)),
259+ html.EscapeString(label),
116 html.EscapeString(entry.URL),260 html.EscapeString(entry.URL),
117 html.EscapeString(strings.Join(nonEmptyStrings(meta), " · ")),261 html.EscapeString(strings.Join(nonEmptyStrings(meta), " · ")),
262+ html.EscapeString(sanitizeHref(entry.URL)),
263+ deleteButton,
118 )264 )
119 }265 }
120 266
@@ -201,6 +347,24 @@ func countUniqueHistoryDays(entries []*entity.HistoryEntry) int64 {
201 return int64(len(seen))347 return int64(len(seen))
202 }348 }
203 349
350+func filterHistoryEntriesByDomain(entries []*entity.HistoryEntry, domain string) []*entity.HistoryEntry {
351+ domain = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(domain)), "www.")
352+ if domain == "" {
353+ return entries
354+ }
355+ filtered := make([]*entity.HistoryEntry, 0, len(entries))
356+ for _, entry := range entries {
357+ if entry == nil {
358+ continue
359+ }
360+ entryDomain := strings.TrimPrefix(strings.ToLower(displayHistoryDomain(entry.URL)), "www.")
361+ if entryDomain == domain {
362+ filtered = append(filtered, entry)
363+ }
364+ }
365+ return filtered
366+}
367+
204 func displayHistoryDomain(raw string) string {368 func displayHistoryDomain(raw string) string {
205 parsed, err := url.Parse(raw)369 parsed, err := url.Parse(raw)
206 if err != nil || parsed.Hostname() == "" {370 if err != nil || parsed.Hostname() == "" {

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