detached e9a1267

Merge pull request #70 from bnema/refactor/dmenu-history-days

feat(dmenu): change history limit from count to days, add --most-visited flag
commit
e9a12674c198797e3d965e93ae4a0baeb2d01919
author
bnema · · 2025-12-29 20:09 UTC
committer
GitHub · · 2025-12-29 20:09 UTC
merge of
76a4000 + b782902 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
18 files · +1103 -20
M README.md +2 -1
@@ -251,7 +251,8 @@ sudo apt install va-driver-all
251251 - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select`
252252 - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select`
253253 - `dumber dmenu --interactive` # built-in TUI fuzzy finder
254- - `dumber dmenu --max 200` # limit output entries
254+ - `dumber dmenu --days 7` # show history from last 7 days (default: 30)
255+ - `dumber dmenu --most-visited` # sort by visit count instead of recency
255256 - Manage browsing history:
256257 - `dumber history` # interactive history browser (timeline tabs + fuzzy search)
257258 - `dumber history --json` # output recent entries as JSON
@@ -251,7 +251,8 @@ sudo apt install va-driver-all
251 - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select`251 - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select`
252 - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select`252 - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select`
253 - `dumber dmenu --interactive` # built-in TUI fuzzy finder253 - `dumber dmenu --interactive` # built-in TUI fuzzy finder
254- - `dumber dmenu --max 200` # limit output entries254+ - `dumber dmenu --days 7` # show history from last 7 days (default: 30)
255+ - `dumber dmenu --most-visited` # sort by visit count instead of recency
255 - Manage browsing history:256 - Manage browsing history:
256 - `dumber history` # interactive history browser (timeline tabs + fuzzy search)257 - `dumber history` # interactive history browser (timeline tabs + fuzzy search)
257 - `dumber history --json` # output recent entries as JSON258 - `dumber history --json` # output recent entries as JSON
M docs/CONFIG.md +10 -4
@@ -83,14 +83,14 @@ These settings control the `dumber dmenu` CLI command for rofi/fuzzel integratio
8383
8484 | Key | Type | Default | Description |
8585 |-----|------|---------|-------------|
86-| `dmenu.max_history_items` | int | `20` | Max items shown in launcher |
86+| `dmenu.max_history_days` | int | `30` | Number of days of history to show (0 = all) |
8787 | `dmenu.show_visit_count` | bool | `true` | Show visit counts in output |
8888 | `dmenu.show_last_visited` | bool | `true` | Show last visited dates in output |
8989 | `dmenu.history_prefix` | string | `"🕒"` | Prefix for history items |
9090 | `dmenu.shortcut_prefix` | string | `"🔍"` | Prefix for shortcuts |
9191 | `dmenu.url_prefix` | string | `"🌐"` | Prefix for URLs |
9292 | `dmenu.date_format` | string | `"2006-01-02 15:04"` | Go time format string |
93-| `dmenu.sort_by_visit_count` | bool | `true` | Sort by popularity |
93+| `dmenu.sort_by_visit_count` | bool | `true` | Sort by visit count instead of recency |
9494
9595 **CLI Usage:**
9696 ```bash
@@ -101,8 +101,14 @@ dumber dmenu | fuzzel --dmenu -p "Browse: " | dumber dmenu --select
101101 # Interactive TUI mode
102102 dumber dmenu --interactive
103103
104-# Override max items via CLI flag
105-dumber dmenu --max 50
104+# Override history days via CLI flag (show last 7 days)
105+dumber dmenu --days 7
106+
107+# Sort by most visited instead of recency
108+dumber dmenu --most-visited
109+
110+# Combined: most visited from last 14 days
111+dumber dmenu --days 14 --most-visited
106112 ```
107113
108114 ## Omnibox
@@ -83,14 +83,14 @@ These settings control the `dumber dmenu` CLI command for rofi/fuzzel integratio
83 83
84 | Key | Type | Default | Description |84 | Key | Type | Default | Description |
85 |-----|------|---------|-------------|85 |-----|------|---------|-------------|
86-| `dmenu.max_history_items` | int | `20` | Max items shown in launcher |86+| `dmenu.max_history_days` | int | `30` | Number of days of history to show (0 = all) |
87 | `dmenu.show_visit_count` | bool | `true` | Show visit counts in output |87 | `dmenu.show_visit_count` | bool | `true` | Show visit counts in output |
88 | `dmenu.show_last_visited` | bool | `true` | Show last visited dates in output |88 | `dmenu.show_last_visited` | bool | `true` | Show last visited dates in output |
89 | `dmenu.history_prefix` | string | `"🕒"` | Prefix for history items |89 | `dmenu.history_prefix` | string | `"🕒"` | Prefix for history items |
90 | `dmenu.shortcut_prefix` | string | `"🔍"` | Prefix for shortcuts |90 | `dmenu.shortcut_prefix` | string | `"🔍"` | Prefix for shortcuts |
91 | `dmenu.url_prefix` | string | `"🌐"` | Prefix for URLs |91 | `dmenu.url_prefix` | string | `"🌐"` | Prefix for URLs |
92 | `dmenu.date_format` | string | `"2006-01-02 15:04"` | Go time format string |92 | `dmenu.date_format` | string | `"2006-01-02 15:04"` | Go time format string |
93-| `dmenu.sort_by_visit_count` | bool | `true` | Sort by popularity |93+| `dmenu.sort_by_visit_count` | bool | `true` | Sort by visit count instead of recency |
94 94
95 **CLI Usage:**95 **CLI Usage:**
96 ```bash96 ```bash
@@ -101,8 +101,14 @@ dumber dmenu | fuzzel --dmenu -p "Browse: " | dumber dmenu --select
101 # Interactive TUI mode101 # Interactive TUI mode
102 dumber dmenu --interactive102 dumber dmenu --interactive
103 103
104-# Override max items via CLI flag104+# Override history days via CLI flag (show last 7 days)
105-dumber dmenu --max 50105+dumber dmenu --days 7
106+
107+# Sort by most visited instead of recency
108+dumber dmenu --most-visited
109+
110+# Combined: most visited from last 14 days
111+dumber dmenu --days 14 --most-visited
106 ```112 ```
107 113
108 ## Omnibox114 ## Omnibox
M internal/application/usecase/search_history.go +50 -0
@@ -75,6 +75,56 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int
7575 return entries, nil
7676 }
7777
78+// GetRecentSince retrieves history entries visited within the last N days.
79+// If days is 0, returns all history entries.
80+// If days is negative, defaults to 30 days.
81+func (uc *SearchHistoryUseCase) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
82+ if days < 0 {
83+ days = 30 // Default to 30 days for negative values
84+ }
85+
86+ var entries []*entity.HistoryEntry
87+ var err error
88+
89+ switch days {
90+ case 0:
91+ entries, err = uc.historyRepo.GetAllRecentHistory(ctx)
92+ default:
93+ entries, err = uc.historyRepo.GetRecentSince(ctx, days)
94+ }
95+
96+ if err != nil {
97+ return nil, fmt.Errorf("failed to get recent history: %w", err)
98+ }
99+
100+ return entries, nil
101+}
102+
103+// GetMostVisited retrieves history entries sorted by visit count within the last N days.
104+// If days is 0, returns all history entries sorted by visit count.
105+// If days is negative, defaults to 30 days.
106+func (uc *SearchHistoryUseCase) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
107+ if days < 0 {
108+ days = 30 // Default to 30 days for negative values
109+ }
110+
111+ var entries []*entity.HistoryEntry
112+ var err error
113+
114+ switch days {
115+ case 0:
116+ entries, err = uc.historyRepo.GetAllMostVisited(ctx)
117+ default:
118+ entries, err = uc.historyRepo.GetMostVisited(ctx, days)
119+ }
120+
121+ if err != nil {
122+ return nil, fmt.Errorf("failed to get most visited history: %w", err)
123+ }
124+
125+ return entries, nil
126+}
127+
78128 // FindByURL retrieves a history entry by its URL.
79129 func (uc *SearchHistoryUseCase) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
80130 log := logging.FromContext(ctx)
@@ -75,6 +75,56 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int
75 return entries, nil75 return entries, nil
76 }76 }
77 77
78+// GetRecentSince retrieves history entries visited within the last N days.
79+// If days is 0, returns all history entries.
80+// If days is negative, defaults to 30 days.
81+func (uc *SearchHistoryUseCase) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
82+ if days < 0 {
83+ days = 30 // Default to 30 days for negative values
84+ }
85+
86+ var entries []*entity.HistoryEntry
87+ var err error
88+
89+ switch days {
90+ case 0:
91+ entries, err = uc.historyRepo.GetAllRecentHistory(ctx)
92+ default:
93+ entries, err = uc.historyRepo.GetRecentSince(ctx, days)
94+ }
95+
96+ if err != nil {
97+ return nil, fmt.Errorf("failed to get recent history: %w", err)
98+ }
99+
100+ return entries, nil
101+}
102+
103+// GetMostVisited retrieves history entries sorted by visit count within the last N days.
104+// If days is 0, returns all history entries sorted by visit count.
105+// If days is negative, defaults to 30 days.
106+func (uc *SearchHistoryUseCase) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
107+ if days < 0 {
108+ days = 30 // Default to 30 days for negative values
109+ }
110+
111+ var entries []*entity.HistoryEntry
112+ var err error
113+
114+ switch days {
115+ case 0:
116+ entries, err = uc.historyRepo.GetAllMostVisited(ctx)
117+ default:
118+ entries, err = uc.historyRepo.GetMostVisited(ctx, days)
119+ }
120+
121+ if err != nil {
122+ return nil, fmt.Errorf("failed to get most visited history: %w", err)
123+ }
124+
125+ return entries, nil
126+}
127+
78 // FindByURL retrieves a history entry by its URL.128 // FindByURL retrieves a history entry by its URL.
79 func (uc *SearchHistoryUseCase) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {129 func (uc *SearchHistoryUseCase) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
80 log := logging.FromContext(ctx)130 log := logging.FromContext(ctx)
A internal/application/usecase/search_history_test.go +153 -0
@@ -0,0 +1,153 @@
1+package usecase_test
2+
3+import (
4+ "errors"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) {
17+ ctx := testContext()
18+
19+ historyRepo := repomocks.NewMockHistoryRepository(t)
20+
21+ entries := []*entity.HistoryEntry{
22+ {ID: 1, URL: "https://example.com", Title: "Example", LastVisited: time.Now()},
23+ {ID: 2, URL: "https://github.com", Title: "GitHub", LastVisited: time.Now().Add(-24 * time.Hour)},
24+ }
25+
26+ historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil)
27+
28+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
29+
30+ result, err := uc.GetRecentSince(ctx, 30)
31+ require.NoError(t, err)
32+ require.Len(t, result, 2)
33+ assert.Equal(t, "https://example.com", result[0].URL)
34+ assert.Equal(t, "https://github.com", result[1].URL)
35+}
36+
37+func TestSearchHistoryUseCase_GetRecentSince_ZeroMeansAll(t *testing.T) {
38+ ctx := testContext()
39+
40+ historyRepo := repomocks.NewMockHistoryRepository(t)
41+
42+ entries := []*entity.HistoryEntry{
43+ {ID: 1, URL: "https://example.com", Title: "Example"},
44+ }
45+
46+ // When days == 0, should call GetAllRecentHistory
47+ historyRepo.EXPECT().GetAllRecentHistory(mock.Anything).Return(entries, nil)
48+
49+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
50+
51+ result, err := uc.GetRecentSince(ctx, 0)
52+ require.NoError(t, err)
53+ require.Len(t, result, 1)
54+}
55+
56+func TestSearchHistoryUseCase_GetRecentSince_NegativeDaysDefaultsTo30(t *testing.T) {
57+ ctx := testContext()
58+
59+ historyRepo := repomocks.NewMockHistoryRepository(t)
60+
61+ entries := []*entity.HistoryEntry{}
62+
63+ historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil)
64+
65+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
66+
67+ result, err := uc.GetRecentSince(ctx, -5)
68+ require.NoError(t, err)
69+ require.Empty(t, result)
70+}
71+
72+func TestSearchHistoryUseCase_GetRecentSince_ReturnsErrorOnRepoFailure(t *testing.T) {
73+ ctx := testContext()
74+
75+ historyRepo := repomocks.NewMockHistoryRepository(t)
76+
77+ historyRepo.EXPECT().GetRecentSince(mock.Anything, 7).Return(nil, errors.New("db connection failed"))
78+
79+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
80+
81+ result, err := uc.GetRecentSince(ctx, 7)
82+ require.Error(t, err)
83+ assert.Nil(t, result)
84+ assert.Contains(t, err.Error(), "failed to get recent history")
85+}
86+
87+func TestSearchHistoryUseCase_GetMostVisited_ReturnsEntries(t *testing.T) {
88+ ctx := testContext()
89+
90+ historyRepo := repomocks.NewMockHistoryRepository(t)
91+
92+ entries := []*entity.HistoryEntry{
93+ {ID: 1, URL: "https://github.com", Title: "GitHub", VisitCount: 100},
94+ {ID: 2, URL: "https://example.com", Title: "Example", VisitCount: 50},
95+ }
96+
97+ historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return(entries, nil)
98+
99+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
100+
101+ result, err := uc.GetMostVisited(ctx, 30)
102+ require.NoError(t, err)
103+ require.Len(t, result, 2)
104+ assert.Equal(t, "https://github.com", result[0].URL)
105+ assert.Equal(t, int64(100), result[0].VisitCount)
106+}
107+
108+func TestSearchHistoryUseCase_GetMostVisited_ZeroMeansAll(t *testing.T) {
109+ ctx := testContext()
110+
111+ historyRepo := repomocks.NewMockHistoryRepository(t)
112+
113+ entries := []*entity.HistoryEntry{}
114+
115+ // When days == 0, should call GetAllMostVisited
116+ historyRepo.EXPECT().GetAllMostVisited(mock.Anything).Return(entries, nil)
117+
118+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
119+
120+ result, err := uc.GetMostVisited(ctx, 0)
121+ require.NoError(t, err)
122+ require.Empty(t, result)
123+}
124+
125+func TestSearchHistoryUseCase_GetMostVisited_ReturnsErrorOnRepoFailure(t *testing.T) {
126+ ctx := testContext()
127+
128+ historyRepo := repomocks.NewMockHistoryRepository(t)
129+
130+ historyRepo.EXPECT().GetMostVisited(mock.Anything, 14).Return(nil, errors.New("query failed"))
131+
132+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
133+
134+ result, err := uc.GetMostVisited(ctx, 14)
135+ require.Error(t, err)
136+ assert.Nil(t, result)
137+ assert.Contains(t, err.Error(), "failed to get most visited history")
138+}
139+
140+func TestSearchHistoryUseCase_GetMostVisited_ReturnsEmptyWhenNoHistory(t *testing.T) {
141+ ctx := testContext()
142+
143+ historyRepo := repomocks.NewMockHistoryRepository(t)
144+
145+ historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return([]*entity.HistoryEntry{}, nil)
146+
147+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
148+
149+ result, err := uc.GetMostVisited(ctx, 30)
150+ require.NoError(t, err)
151+ require.NotNil(t, result)
152+ assert.Empty(t, result)
153+}
@@ -0,0 +1,153 @@
1+package usecase_test
2+
3+import (
4+ "errors"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) {
17+ ctx := testContext()
18+
19+ historyRepo := repomocks.NewMockHistoryRepository(t)
20+
21+ entries := []*entity.HistoryEntry{
22+ {ID: 1, URL: "https://example.com", Title: "Example", LastVisited: time.Now()},
23+ {ID: 2, URL: "https://github.com", Title: "GitHub", LastVisited: time.Now().Add(-24 * time.Hour)},
24+ }
25+
26+ historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil)
27+
28+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
29+
30+ result, err := uc.GetRecentSince(ctx, 30)
31+ require.NoError(t, err)
32+ require.Len(t, result, 2)
33+ assert.Equal(t, "https://example.com", result[0].URL)
34+ assert.Equal(t, "https://github.com", result[1].URL)
35+}
36+
37+func TestSearchHistoryUseCase_GetRecentSince_ZeroMeansAll(t *testing.T) {
38+ ctx := testContext()
39+
40+ historyRepo := repomocks.NewMockHistoryRepository(t)
41+
42+ entries := []*entity.HistoryEntry{
43+ {ID: 1, URL: "https://example.com", Title: "Example"},
44+ }
45+
46+ // When days == 0, should call GetAllRecentHistory
47+ historyRepo.EXPECT().GetAllRecentHistory(mock.Anything).Return(entries, nil)
48+
49+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
50+
51+ result, err := uc.GetRecentSince(ctx, 0)
52+ require.NoError(t, err)
53+ require.Len(t, result, 1)
54+}
55+
56+func TestSearchHistoryUseCase_GetRecentSince_NegativeDaysDefaultsTo30(t *testing.T) {
57+ ctx := testContext()
58+
59+ historyRepo := repomocks.NewMockHistoryRepository(t)
60+
61+ entries := []*entity.HistoryEntry{}
62+
63+ historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil)
64+
65+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
66+
67+ result, err := uc.GetRecentSince(ctx, -5)
68+ require.NoError(t, err)
69+ require.Empty(t, result)
70+}
71+
72+func TestSearchHistoryUseCase_GetRecentSince_ReturnsErrorOnRepoFailure(t *testing.T) {
73+ ctx := testContext()
74+
75+ historyRepo := repomocks.NewMockHistoryRepository(t)
76+
77+ historyRepo.EXPECT().GetRecentSince(mock.Anything, 7).Return(nil, errors.New("db connection failed"))
78+
79+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
80+
81+ result, err := uc.GetRecentSince(ctx, 7)
82+ require.Error(t, err)
83+ assert.Nil(t, result)
84+ assert.Contains(t, err.Error(), "failed to get recent history")
85+}
86+
87+func TestSearchHistoryUseCase_GetMostVisited_ReturnsEntries(t *testing.T) {
88+ ctx := testContext()
89+
90+ historyRepo := repomocks.NewMockHistoryRepository(t)
91+
92+ entries := []*entity.HistoryEntry{
93+ {ID: 1, URL: "https://github.com", Title: "GitHub", VisitCount: 100},
94+ {ID: 2, URL: "https://example.com", Title: "Example", VisitCount: 50},
95+ }
96+
97+ historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return(entries, nil)
98+
99+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
100+
101+ result, err := uc.GetMostVisited(ctx, 30)
102+ require.NoError(t, err)
103+ require.Len(t, result, 2)
104+ assert.Equal(t, "https://github.com", result[0].URL)
105+ assert.Equal(t, int64(100), result[0].VisitCount)
106+}
107+
108+func TestSearchHistoryUseCase_GetMostVisited_ZeroMeansAll(t *testing.T) {
109+ ctx := testContext()
110+
111+ historyRepo := repomocks.NewMockHistoryRepository(t)
112+
113+ entries := []*entity.HistoryEntry{}
114+
115+ // When days == 0, should call GetAllMostVisited
116+ historyRepo.EXPECT().GetAllMostVisited(mock.Anything).Return(entries, nil)
117+
118+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
119+
120+ result, err := uc.GetMostVisited(ctx, 0)
121+ require.NoError(t, err)
122+ require.Empty(t, result)
123+}
124+
125+func TestSearchHistoryUseCase_GetMostVisited_ReturnsErrorOnRepoFailure(t *testing.T) {
126+ ctx := testContext()
127+
128+ historyRepo := repomocks.NewMockHistoryRepository(t)
129+
130+ historyRepo.EXPECT().GetMostVisited(mock.Anything, 14).Return(nil, errors.New("query failed"))
131+
132+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
133+
134+ result, err := uc.GetMostVisited(ctx, 14)
135+ require.Error(t, err)
136+ assert.Nil(t, result)
137+ assert.Contains(t, err.Error(), "failed to get most visited history")
138+}
139+
140+func TestSearchHistoryUseCase_GetMostVisited_ReturnsEmptyWhenNoHistory(t *testing.T) {
141+ ctx := testContext()
142+
143+ historyRepo := repomocks.NewMockHistoryRepository(t)
144+
145+ historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return([]*entity.HistoryEntry{}, nil)
146+
147+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
148+
149+ result, err := uc.GetMostVisited(ctx, 30)
150+ require.NoError(t, err)
151+ require.NotNil(t, result)
152+ assert.Empty(t, result)
153+}
M internal/cli/cmd/dmenu.go +21 -6
@@ -13,6 +13,7 @@ import (
1313
1414 "github.com/bnema/dumber/assets"
1515 "github.com/bnema/dumber/internal/cli/model"
16+ "github.com/bnema/dumber/internal/domain/entity"
1617 domainurl "github.com/bnema/dumber/internal/domain/url"
1718 "github.com/bnema/dumber/internal/infrastructure/config"
1819 "github.com/bnema/dumber/internal/infrastructure/favicon"
@@ -22,7 +23,8 @@ import (
2223 var (
2324 dmenuInteractive bool
2425 dmenuSelect bool
25- dmenuMax int
26+ dmenuDays int
27+ dmenuMostVisited bool
2628 )
2729
2830 var dmenuCmd = &cobra.Command{
@@ -43,7 +45,8 @@ func init() {
4345
4446 dmenuCmd.Flags().BoolVarP(&dmenuInteractive, "interactive", "i", false, "use interactive TUI mode")
4547 dmenuCmd.Flags().BoolVar(&dmenuSelect, "select", false, "process selection from stdin")
46- dmenuCmd.Flags().IntVar(&dmenuMax, "max", 0, "maximum entries to output (default from config)")
48+ dmenuCmd.Flags().IntVar(&dmenuDays, "days", 0, "number of days of history to show (default from config)")
49+ dmenuCmd.Flags().BoolVarP(&dmenuMostVisited, "most-visited", "m", false, "sort by visit count instead of recency")
4750 }
4851
4952 func runDmenu(_ *cobra.Command, _ []string) error {
@@ -71,9 +74,14 @@ func getDmenuConfig() config.DmenuConfig {
7174 app := GetApp()
7275 cfg := app.Config.Dmenu
7376
74- // Override max if explicitly set via CLI flag
75- if dmenuMax > 0 {
76- cfg.MaxHistoryItems = dmenuMax
77+ // Override days if explicitly set via CLI flag
78+ if dmenuDays > 0 {
79+ cfg.MaxHistoryDays = dmenuDays
80+ }
81+
82+ // Override sort_by_visit_count if --most-visited flag is set
83+ if dmenuMostVisited {
84+ cfg.SortByVisitCount = true
7785 }
7886
7987 return cfg
@@ -131,7 +139,14 @@ func runDmenuPipe() error {
131139 ctx := context.Background()
132140 cfg := getDmenuConfig()
133141
134- entries, err := app.SearchHistoryUC.GetRecent(ctx, cfg.MaxHistoryItems, 0)
142+ var entries []*entity.HistoryEntry
143+ var err error
144+
145+ if cfg.SortByVisitCount {
146+ entries, err = app.SearchHistoryUC.GetMostVisited(ctx, cfg.MaxHistoryDays)
147+ } else {
148+ entries, err = app.SearchHistoryUC.GetRecentSince(ctx, cfg.MaxHistoryDays)
149+ }
135150 if err != nil {
136151 return fmt.Errorf("get history: %w", err)
137152 }
@@ -13,6 +13,7 @@ import (
13 13
14 "github.com/bnema/dumber/assets"14 "github.com/bnema/dumber/assets"
15 "github.com/bnema/dumber/internal/cli/model"15 "github.com/bnema/dumber/internal/cli/model"
16+ "github.com/bnema/dumber/internal/domain/entity"
16 domainurl "github.com/bnema/dumber/internal/domain/url"17 domainurl "github.com/bnema/dumber/internal/domain/url"
17 "github.com/bnema/dumber/internal/infrastructure/config"18 "github.com/bnema/dumber/internal/infrastructure/config"
18 "github.com/bnema/dumber/internal/infrastructure/favicon"19 "github.com/bnema/dumber/internal/infrastructure/favicon"
@@ -22,7 +23,8 @@ import (
22 var (23 var (
23 dmenuInteractive bool24 dmenuInteractive bool
24 dmenuSelect bool25 dmenuSelect bool
25- dmenuMax int26+ dmenuDays int
27+ dmenuMostVisited bool
26 )28 )
27 29
28 var dmenuCmd = &cobra.Command{30 var dmenuCmd = &cobra.Command{
@@ -43,7 +45,8 @@ func init() {
43 45
44 dmenuCmd.Flags().BoolVarP(&dmenuInteractive, "interactive", "i", false, "use interactive TUI mode")46 dmenuCmd.Flags().BoolVarP(&dmenuInteractive, "interactive", "i", false, "use interactive TUI mode")
45 dmenuCmd.Flags().BoolVar(&dmenuSelect, "select", false, "process selection from stdin")47 dmenuCmd.Flags().BoolVar(&dmenuSelect, "select", false, "process selection from stdin")
46- dmenuCmd.Flags().IntVar(&dmenuMax, "max", 0, "maximum entries to output (default from config)")48+ dmenuCmd.Flags().IntVar(&dmenuDays, "days", 0, "number of days of history to show (default from config)")
49+ dmenuCmd.Flags().BoolVarP(&dmenuMostVisited, "most-visited", "m", false, "sort by visit count instead of recency")
47 }50 }
48 51
49 func runDmenu(_ *cobra.Command, _ []string) error {52 func runDmenu(_ *cobra.Command, _ []string) error {
@@ -71,9 +74,14 @@ func getDmenuConfig() config.DmenuConfig {
71 app := GetApp()74 app := GetApp()
72 cfg := app.Config.Dmenu75 cfg := app.Config.Dmenu
73 76
74- // Override max if explicitly set via CLI flag77+ // Override days if explicitly set via CLI flag
75- if dmenuMax > 0 {78+ if dmenuDays > 0 {
76- cfg.MaxHistoryItems = dmenuMax79+ cfg.MaxHistoryDays = dmenuDays
80+ }
81+
82+ // Override sort_by_visit_count if --most-visited flag is set
83+ if dmenuMostVisited {
84+ cfg.SortByVisitCount = true
77 }85 }
78 86
79 return cfg87 return cfg
@@ -131,7 +139,14 @@ func runDmenuPipe() error {
131 ctx := context.Background()139 ctx := context.Background()
132 cfg := getDmenuConfig()140 cfg := getDmenuConfig()
133 141
134- entries, err := app.SearchHistoryUC.GetRecent(ctx, cfg.MaxHistoryItems, 0)142+ var entries []*entity.HistoryEntry
143+ var err error
144+
145+ if cfg.SortByVisitCount {
146+ entries, err = app.SearchHistoryUC.GetMostVisited(ctx, cfg.MaxHistoryDays)
147+ } else {
148+ entries, err = app.SearchHistoryUC.GetRecentSince(ctx, cfg.MaxHistoryDays)
149+ }
135 if err != nil {150 if err != nil {
136 return fmt.Errorf("get history: %w", err)151 return fmt.Errorf("get history: %w", err)
137 }152 }
M internal/domain/repository/history.go +14 -0
@@ -21,6 +21,20 @@ type HistoryRepository interface {
2121 // GetRecent retrieves recent history entries with pagination.
2222 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
2323
24+ // GetRecentSince retrieves history entries visited within the last N days.
25+ // days must be > 0.
26+ GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error)
27+
28+ // GetMostVisited retrieves history entries sorted by visit count within the last N days.
29+ // days must be > 0.
30+ GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error)
31+
32+ // GetAllRecentHistory retrieves all history entries sorted by recency.
33+ GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error)
34+
35+ // GetAllMostVisited retrieves all history entries sorted by visit count.
36+ GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error)
37+
2438 // IncrementVisitCount increments the visit count for a URL.
2539 IncrementVisitCount(ctx context.Context, url string) error
2640
@@ -21,6 +21,20 @@ type HistoryRepository interface {
21 // GetRecent retrieves recent history entries with pagination.21 // GetRecent retrieves recent history entries with pagination.
22 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)22 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
23 23
24+ // GetRecentSince retrieves history entries visited within the last N days.
25+ // days must be > 0.
26+ GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error)
27+
28+ // GetMostVisited retrieves history entries sorted by visit count within the last N days.
29+ // days must be > 0.
30+ GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error)
31+
32+ // GetAllRecentHistory retrieves all history entries sorted by recency.
33+ GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error)
34+
35+ // GetAllMostVisited retrieves all history entries sorted by visit count.
36+ GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error)
37+
24 // IncrementVisitCount increments the visit count for a URL.38 // IncrementVisitCount increments the visit count for a URL.
25 IncrementVisitCount(ctx context.Context, url string) error39 IncrementVisitCount(ctx context.Context, url string) error
26 40
M internal/domain/repository/mocks/mocks.go +260 -0
@@ -2197,6 +2197,130 @@ func (_c *MockHistoryRepository_FindByURL_Call) RunAndReturn(run func(ctx contex
21972197 return _c
21982198 }
21992199
2200+// GetAllMostVisited provides a mock function for the type MockHistoryRepository
2201+func (_mock *MockHistoryRepository) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) {
2202+ ret := _mock.Called(ctx)
2203+
2204+ if len(ret) == 0 {
2205+ panic("no return value specified for GetAllMostVisited")
2206+ }
2207+
2208+ var r0 []*entity.HistoryEntry
2209+ var r1 error
2210+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*entity.HistoryEntry, error)); ok {
2211+ return returnFunc(ctx)
2212+ }
2213+ if returnFunc, ok := ret.Get(0).(func(context.Context) []*entity.HistoryEntry); ok {
2214+ r0 = returnFunc(ctx)
2215+ } else {
2216+ if ret.Get(0) != nil {
2217+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2218+ }
2219+ }
2220+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
2221+ r1 = returnFunc(ctx)
2222+ } else {
2223+ r1 = ret.Error(1)
2224+ }
2225+ return r0, r1
2226+}
2227+
2228+// MockHistoryRepository_GetAllMostVisited_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllMostVisited'
2229+type MockHistoryRepository_GetAllMostVisited_Call struct {
2230+ *mock.Call
2231+}
2232+
2233+// GetAllMostVisited is a helper method to define mock.On call
2234+// - ctx context.Context
2235+func (_e *MockHistoryRepository_Expecter) GetAllMostVisited(ctx interface{}) *MockHistoryRepository_GetAllMostVisited_Call {
2236+ return &MockHistoryRepository_GetAllMostVisited_Call{Call: _e.mock.On("GetAllMostVisited", ctx)}
2237+}
2238+
2239+func (_c *MockHistoryRepository_GetAllMostVisited_Call) Run(run func(ctx context.Context)) *MockHistoryRepository_GetAllMostVisited_Call {
2240+ _c.Call.Run(func(args mock.Arguments) {
2241+ var arg0 context.Context
2242+ if args[0] != nil {
2243+ arg0 = args[0].(context.Context)
2244+ }
2245+ run(
2246+ arg0,
2247+ )
2248+ })
2249+ return _c
2250+}
2251+
2252+func (_c *MockHistoryRepository_GetAllMostVisited_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetAllMostVisited_Call {
2253+ _c.Call.Return(historyEntrys, err)
2254+ return _c
2255+}
2256+
2257+func (_c *MockHistoryRepository_GetAllMostVisited_Call) RunAndReturn(run func(ctx context.Context) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetAllMostVisited_Call {
2258+ _c.Call.Return(run)
2259+ return _c
2260+}
2261+
2262+// GetAllRecentHistory provides a mock function for the type MockHistoryRepository
2263+func (_mock *MockHistoryRepository) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {
2264+ ret := _mock.Called(ctx)
2265+
2266+ if len(ret) == 0 {
2267+ panic("no return value specified for GetAllRecentHistory")
2268+ }
2269+
2270+ var r0 []*entity.HistoryEntry
2271+ var r1 error
2272+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*entity.HistoryEntry, error)); ok {
2273+ return returnFunc(ctx)
2274+ }
2275+ if returnFunc, ok := ret.Get(0).(func(context.Context) []*entity.HistoryEntry); ok {
2276+ r0 = returnFunc(ctx)
2277+ } else {
2278+ if ret.Get(0) != nil {
2279+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2280+ }
2281+ }
2282+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
2283+ r1 = returnFunc(ctx)
2284+ } else {
2285+ r1 = ret.Error(1)
2286+ }
2287+ return r0, r1
2288+}
2289+
2290+// MockHistoryRepository_GetAllRecentHistory_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllRecentHistory'
2291+type MockHistoryRepository_GetAllRecentHistory_Call struct {
2292+ *mock.Call
2293+}
2294+
2295+// GetAllRecentHistory is a helper method to define mock.On call
2296+// - ctx context.Context
2297+func (_e *MockHistoryRepository_Expecter) GetAllRecentHistory(ctx interface{}) *MockHistoryRepository_GetAllRecentHistory_Call {
2298+ return &MockHistoryRepository_GetAllRecentHistory_Call{Call: _e.mock.On("GetAllRecentHistory", ctx)}
2299+}
2300+
2301+func (_c *MockHistoryRepository_GetAllRecentHistory_Call) Run(run func(ctx context.Context)) *MockHistoryRepository_GetAllRecentHistory_Call {
2302+ _c.Call.Run(func(args mock.Arguments) {
2303+ var arg0 context.Context
2304+ if args[0] != nil {
2305+ arg0 = args[0].(context.Context)
2306+ }
2307+ run(
2308+ arg0,
2309+ )
2310+ })
2311+ return _c
2312+}
2313+
2314+func (_c *MockHistoryRepository_GetAllRecentHistory_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetAllRecentHistory_Call {
2315+ _c.Call.Return(historyEntrys, err)
2316+ return _c
2317+}
2318+
2319+func (_c *MockHistoryRepository_GetAllRecentHistory_Call) RunAndReturn(run func(ctx context.Context) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetAllRecentHistory_Call {
2320+ _c.Call.Return(run)
2321+ return _c
2322+}
2323+
22002324 // GetDailyVisitCount provides a mock function for the type MockHistoryRepository
22012325 func (_mock *MockHistoryRepository) GetDailyVisitCount(ctx context.Context, daysAgo string) ([]*entity.DailyVisitCount, error) {
22022326 ret := _mock.Called(ctx, daysAgo)
@@ -2395,6 +2519,74 @@ func (_c *MockHistoryRepository_GetHourlyDistribution_Call) RunAndReturn(run fun
23952519 return _c
23962520 }
23972521
2522+// GetMostVisited provides a mock function for the type MockHistoryRepository
2523+func (_mock *MockHistoryRepository) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
2524+ ret := _mock.Called(ctx, days)
2525+
2526+ if len(ret) == 0 {
2527+ panic("no return value specified for GetMostVisited")
2528+ }
2529+
2530+ var r0 []*entity.HistoryEntry
2531+ var r1 error
2532+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]*entity.HistoryEntry, error)); ok {
2533+ return returnFunc(ctx, days)
2534+ }
2535+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) []*entity.HistoryEntry); ok {
2536+ r0 = returnFunc(ctx, days)
2537+ } else {
2538+ if ret.Get(0) != nil {
2539+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2540+ }
2541+ }
2542+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
2543+ r1 = returnFunc(ctx, days)
2544+ } else {
2545+ r1 = ret.Error(1)
2546+ }
2547+ return r0, r1
2548+}
2549+
2550+// MockHistoryRepository_GetMostVisited_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMostVisited'
2551+type MockHistoryRepository_GetMostVisited_Call struct {
2552+ *mock.Call
2553+}
2554+
2555+// GetMostVisited is a helper method to define mock.On call
2556+// - ctx context.Context
2557+// - days int
2558+func (_e *MockHistoryRepository_Expecter) GetMostVisited(ctx interface{}, days interface{}) *MockHistoryRepository_GetMostVisited_Call {
2559+ return &MockHistoryRepository_GetMostVisited_Call{Call: _e.mock.On("GetMostVisited", ctx, days)}
2560+}
2561+
2562+func (_c *MockHistoryRepository_GetMostVisited_Call) Run(run func(ctx context.Context, days int)) *MockHistoryRepository_GetMostVisited_Call {
2563+ _c.Call.Run(func(args mock.Arguments) {
2564+ var arg0 context.Context
2565+ if args[0] != nil {
2566+ arg0 = args[0].(context.Context)
2567+ }
2568+ var arg1 int
2569+ if args[1] != nil {
2570+ arg1 = args[1].(int)
2571+ }
2572+ run(
2573+ arg0,
2574+ arg1,
2575+ )
2576+ })
2577+ return _c
2578+}
2579+
2580+func (_c *MockHistoryRepository_GetMostVisited_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetMostVisited_Call {
2581+ _c.Call.Return(historyEntrys, err)
2582+ return _c
2583+}
2584+
2585+func (_c *MockHistoryRepository_GetMostVisited_Call) RunAndReturn(run func(ctx context.Context, days int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetMostVisited_Call {
2586+ _c.Call.Return(run)
2587+ return _c
2588+}
2589+
23982590 // GetRecent provides a mock function for the type MockHistoryRepository
23992591 func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error) {
24002592 ret := _mock.Called(ctx, limit, offset)
@@ -2469,6 +2661,74 @@ func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx contex
24692661 return _c
24702662 }
24712663
2664+// GetRecentSince provides a mock function for the type MockHistoryRepository
2665+func (_mock *MockHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
2666+ ret := _mock.Called(ctx, days)
2667+
2668+ if len(ret) == 0 {
2669+ panic("no return value specified for GetRecentSince")
2670+ }
2671+
2672+ var r0 []*entity.HistoryEntry
2673+ var r1 error
2674+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]*entity.HistoryEntry, error)); ok {
2675+ return returnFunc(ctx, days)
2676+ }
2677+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) []*entity.HistoryEntry); ok {
2678+ r0 = returnFunc(ctx, days)
2679+ } else {
2680+ if ret.Get(0) != nil {
2681+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2682+ }
2683+ }
2684+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
2685+ r1 = returnFunc(ctx, days)
2686+ } else {
2687+ r1 = ret.Error(1)
2688+ }
2689+ return r0, r1
2690+}
2691+
2692+// MockHistoryRepository_GetRecentSince_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentSince'
2693+type MockHistoryRepository_GetRecentSince_Call struct {
2694+ *mock.Call
2695+}
2696+
2697+// GetRecentSince is a helper method to define mock.On call
2698+// - ctx context.Context
2699+// - days int
2700+func (_e *MockHistoryRepository_Expecter) GetRecentSince(ctx interface{}, days interface{}) *MockHistoryRepository_GetRecentSince_Call {
2701+ return &MockHistoryRepository_GetRecentSince_Call{Call: _e.mock.On("GetRecentSince", ctx, days)}
2702+}
2703+
2704+func (_c *MockHistoryRepository_GetRecentSince_Call) Run(run func(ctx context.Context, days int)) *MockHistoryRepository_GetRecentSince_Call {
2705+ _c.Call.Run(func(args mock.Arguments) {
2706+ var arg0 context.Context
2707+ if args[0] != nil {
2708+ arg0 = args[0].(context.Context)
2709+ }
2710+ var arg1 int
2711+ if args[1] != nil {
2712+ arg1 = args[1].(int)
2713+ }
2714+ run(
2715+ arg0,
2716+ arg1,
2717+ )
2718+ })
2719+ return _c
2720+}
2721+
2722+func (_c *MockHistoryRepository_GetRecentSince_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentSince_Call {
2723+ _c.Call.Return(historyEntrys, err)
2724+ return _c
2725+}
2726+
2727+func (_c *MockHistoryRepository_GetRecentSince_Call) RunAndReturn(run func(ctx context.Context, days int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentSince_Call {
2728+ _c.Call.Return(run)
2729+ return _c
2730+}
2731+
24722732 // GetStats provides a mock function for the type MockHistoryRepository
24732733 func (_mock *MockHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
24742734 ret := _mock.Called(ctx)
@@ -2197,6 +2197,130 @@ func (_c *MockHistoryRepository_FindByURL_Call) RunAndReturn(run func(ctx contex
2197 return _c2197 return _c
2198 }2198 }
2199 2199
2200+// GetAllMostVisited provides a mock function for the type MockHistoryRepository
2201+func (_mock *MockHistoryRepository) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) {
2202+ ret := _mock.Called(ctx)
2203+
2204+ if len(ret) == 0 {
2205+ panic("no return value specified for GetAllMostVisited")
2206+ }
2207+
2208+ var r0 []*entity.HistoryEntry
2209+ var r1 error
2210+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*entity.HistoryEntry, error)); ok {
2211+ return returnFunc(ctx)
2212+ }
2213+ if returnFunc, ok := ret.Get(0).(func(context.Context) []*entity.HistoryEntry); ok {
2214+ r0 = returnFunc(ctx)
2215+ } else {
2216+ if ret.Get(0) != nil {
2217+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2218+ }
2219+ }
2220+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
2221+ r1 = returnFunc(ctx)
2222+ } else {
2223+ r1 = ret.Error(1)
2224+ }
2225+ return r0, r1
2226+}
2227+
2228+// MockHistoryRepository_GetAllMostVisited_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllMostVisited'
2229+type MockHistoryRepository_GetAllMostVisited_Call struct {
2230+ *mock.Call
2231+}
2232+
2233+// GetAllMostVisited is a helper method to define mock.On call
2234+// - ctx context.Context
2235+func (_e *MockHistoryRepository_Expecter) GetAllMostVisited(ctx interface{}) *MockHistoryRepository_GetAllMostVisited_Call {
2236+ return &MockHistoryRepository_GetAllMostVisited_Call{Call: _e.mock.On("GetAllMostVisited", ctx)}
2237+}
2238+
2239+func (_c *MockHistoryRepository_GetAllMostVisited_Call) Run(run func(ctx context.Context)) *MockHistoryRepository_GetAllMostVisited_Call {
2240+ _c.Call.Run(func(args mock.Arguments) {
2241+ var arg0 context.Context
2242+ if args[0] != nil {
2243+ arg0 = args[0].(context.Context)
2244+ }
2245+ run(
2246+ arg0,
2247+ )
2248+ })
2249+ return _c
2250+}
2251+
2252+func (_c *MockHistoryRepository_GetAllMostVisited_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetAllMostVisited_Call {
2253+ _c.Call.Return(historyEntrys, err)
2254+ return _c
2255+}
2256+
2257+func (_c *MockHistoryRepository_GetAllMostVisited_Call) RunAndReturn(run func(ctx context.Context) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetAllMostVisited_Call {
2258+ _c.Call.Return(run)
2259+ return _c
2260+}
2261+
2262+// GetAllRecentHistory provides a mock function for the type MockHistoryRepository
2263+func (_mock *MockHistoryRepository) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {
2264+ ret := _mock.Called(ctx)
2265+
2266+ if len(ret) == 0 {
2267+ panic("no return value specified for GetAllRecentHistory")
2268+ }
2269+
2270+ var r0 []*entity.HistoryEntry
2271+ var r1 error
2272+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*entity.HistoryEntry, error)); ok {
2273+ return returnFunc(ctx)
2274+ }
2275+ if returnFunc, ok := ret.Get(0).(func(context.Context) []*entity.HistoryEntry); ok {
2276+ r0 = returnFunc(ctx)
2277+ } else {
2278+ if ret.Get(0) != nil {
2279+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2280+ }
2281+ }
2282+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
2283+ r1 = returnFunc(ctx)
2284+ } else {
2285+ r1 = ret.Error(1)
2286+ }
2287+ return r0, r1
2288+}
2289+
2290+// MockHistoryRepository_GetAllRecentHistory_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllRecentHistory'
2291+type MockHistoryRepository_GetAllRecentHistory_Call struct {
2292+ *mock.Call
2293+}
2294+
2295+// GetAllRecentHistory is a helper method to define mock.On call
2296+// - ctx context.Context
2297+func (_e *MockHistoryRepository_Expecter) GetAllRecentHistory(ctx interface{}) *MockHistoryRepository_GetAllRecentHistory_Call {
2298+ return &MockHistoryRepository_GetAllRecentHistory_Call{Call: _e.mock.On("GetAllRecentHistory", ctx)}
2299+}
2300+
2301+func (_c *MockHistoryRepository_GetAllRecentHistory_Call) Run(run func(ctx context.Context)) *MockHistoryRepository_GetAllRecentHistory_Call {
2302+ _c.Call.Run(func(args mock.Arguments) {
2303+ var arg0 context.Context
2304+ if args[0] != nil {
2305+ arg0 = args[0].(context.Context)
2306+ }
2307+ run(
2308+ arg0,
2309+ )
2310+ })
2311+ return _c
2312+}
2313+
2314+func (_c *MockHistoryRepository_GetAllRecentHistory_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetAllRecentHistory_Call {
2315+ _c.Call.Return(historyEntrys, err)
2316+ return _c
2317+}
2318+
2319+func (_c *MockHistoryRepository_GetAllRecentHistory_Call) RunAndReturn(run func(ctx context.Context) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetAllRecentHistory_Call {
2320+ _c.Call.Return(run)
2321+ return _c
2322+}
2323+
2200 // GetDailyVisitCount provides a mock function for the type MockHistoryRepository2324 // GetDailyVisitCount provides a mock function for the type MockHistoryRepository
2201 func (_mock *MockHistoryRepository) GetDailyVisitCount(ctx context.Context, daysAgo string) ([]*entity.DailyVisitCount, error) {2325 func (_mock *MockHistoryRepository) GetDailyVisitCount(ctx context.Context, daysAgo string) ([]*entity.DailyVisitCount, error) {
2202 ret := _mock.Called(ctx, daysAgo)2326 ret := _mock.Called(ctx, daysAgo)
@@ -2395,6 +2519,74 @@ func (_c *MockHistoryRepository_GetHourlyDistribution_Call) RunAndReturn(run fun
2395 return _c2519 return _c
2396 }2520 }
2397 2521
2522+// GetMostVisited provides a mock function for the type MockHistoryRepository
2523+func (_mock *MockHistoryRepository) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
2524+ ret := _mock.Called(ctx, days)
2525+
2526+ if len(ret) == 0 {
2527+ panic("no return value specified for GetMostVisited")
2528+ }
2529+
2530+ var r0 []*entity.HistoryEntry
2531+ var r1 error
2532+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]*entity.HistoryEntry, error)); ok {
2533+ return returnFunc(ctx, days)
2534+ }
2535+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) []*entity.HistoryEntry); ok {
2536+ r0 = returnFunc(ctx, days)
2537+ } else {
2538+ if ret.Get(0) != nil {
2539+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2540+ }
2541+ }
2542+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
2543+ r1 = returnFunc(ctx, days)
2544+ } else {
2545+ r1 = ret.Error(1)
2546+ }
2547+ return r0, r1
2548+}
2549+
2550+// MockHistoryRepository_GetMostVisited_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMostVisited'
2551+type MockHistoryRepository_GetMostVisited_Call struct {
2552+ *mock.Call
2553+}
2554+
2555+// GetMostVisited is a helper method to define mock.On call
2556+// - ctx context.Context
2557+// - days int
2558+func (_e *MockHistoryRepository_Expecter) GetMostVisited(ctx interface{}, days interface{}) *MockHistoryRepository_GetMostVisited_Call {
2559+ return &MockHistoryRepository_GetMostVisited_Call{Call: _e.mock.On("GetMostVisited", ctx, days)}
2560+}
2561+
2562+func (_c *MockHistoryRepository_GetMostVisited_Call) Run(run func(ctx context.Context, days int)) *MockHistoryRepository_GetMostVisited_Call {
2563+ _c.Call.Run(func(args mock.Arguments) {
2564+ var arg0 context.Context
2565+ if args[0] != nil {
2566+ arg0 = args[0].(context.Context)
2567+ }
2568+ var arg1 int
2569+ if args[1] != nil {
2570+ arg1 = args[1].(int)
2571+ }
2572+ run(
2573+ arg0,
2574+ arg1,
2575+ )
2576+ })
2577+ return _c
2578+}
2579+
2580+func (_c *MockHistoryRepository_GetMostVisited_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetMostVisited_Call {
2581+ _c.Call.Return(historyEntrys, err)
2582+ return _c
2583+}
2584+
2585+func (_c *MockHistoryRepository_GetMostVisited_Call) RunAndReturn(run func(ctx context.Context, days int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetMostVisited_Call {
2586+ _c.Call.Return(run)
2587+ return _c
2588+}
2589+
2398 // GetRecent provides a mock function for the type MockHistoryRepository2590 // GetRecent provides a mock function for the type MockHistoryRepository
2399 func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error) {2591 func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error) {
2400 ret := _mock.Called(ctx, limit, offset)2592 ret := _mock.Called(ctx, limit, offset)
@@ -2469,6 +2661,74 @@ func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx contex
2469 return _c2661 return _c
2470 }2662 }
2471 2663
2664+// GetRecentSince provides a mock function for the type MockHistoryRepository
2665+func (_mock *MockHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
2666+ ret := _mock.Called(ctx, days)
2667+
2668+ if len(ret) == 0 {
2669+ panic("no return value specified for GetRecentSince")
2670+ }
2671+
2672+ var r0 []*entity.HistoryEntry
2673+ var r1 error
2674+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]*entity.HistoryEntry, error)); ok {
2675+ return returnFunc(ctx, days)
2676+ }
2677+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) []*entity.HistoryEntry); ok {
2678+ r0 = returnFunc(ctx, days)
2679+ } else {
2680+ if ret.Get(0) != nil {
2681+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2682+ }
2683+ }
2684+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
2685+ r1 = returnFunc(ctx, days)
2686+ } else {
2687+ r1 = ret.Error(1)
2688+ }
2689+ return r0, r1
2690+}
2691+
2692+// MockHistoryRepository_GetRecentSince_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentSince'
2693+type MockHistoryRepository_GetRecentSince_Call struct {
2694+ *mock.Call
2695+}
2696+
2697+// GetRecentSince is a helper method to define mock.On call
2698+// - ctx context.Context
2699+// - days int
2700+func (_e *MockHistoryRepository_Expecter) GetRecentSince(ctx interface{}, days interface{}) *MockHistoryRepository_GetRecentSince_Call {
2701+ return &MockHistoryRepository_GetRecentSince_Call{Call: _e.mock.On("GetRecentSince", ctx, days)}
2702+}
2703+
2704+func (_c *MockHistoryRepository_GetRecentSince_Call) Run(run func(ctx context.Context, days int)) *MockHistoryRepository_GetRecentSince_Call {
2705+ _c.Call.Run(func(args mock.Arguments) {
2706+ var arg0 context.Context
2707+ if args[0] != nil {
2708+ arg0 = args[0].(context.Context)
2709+ }
2710+ var arg1 int
2711+ if args[1] != nil {
2712+ arg1 = args[1].(int)
2713+ }
2714+ run(
2715+ arg0,
2716+ arg1,
2717+ )
2718+ })
2719+ return _c
2720+}
2721+
2722+func (_c *MockHistoryRepository_GetRecentSince_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentSince_Call {
2723+ _c.Call.Return(historyEntrys, err)
2724+ return _c
2725+}
2726+
2727+func (_c *MockHistoryRepository_GetRecentSince_Call) RunAndReturn(run func(ctx context.Context, days int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentSince_Call {
2728+ _c.Call.Return(run)
2729+ return _c
2730+}
2731+
2472 // GetStats provides a mock function for the type MockHistoryRepository2732 // GetStats provides a mock function for the type MockHistoryRepository
2473 func (_mock *MockHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) {2733 func (_mock *MockHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
2474 ret := _mock.Called(ctx)2734 ret := _mock.Called(ctx)
M internal/infrastructure/config/defaults.go +2 -2
@@ -7,7 +7,7 @@ const (
77 defaultRetentionDays = 365 // 1 year
88
99 // Dmenu defaults
10- defaultMaxHistoryItems = 20 // items
10+ defaultMaxHistoryDays = 30 // days
1111
1212 // Logging defaults
1313 defaultMaxLogAgeDays = 7 // days
@@ -97,7 +97,7 @@ func DefaultConfig() *Config {
9797 SearchShortcuts: GetDefaultSearchShortcuts(),
9898 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
9999 Dmenu: DmenuConfig{
100- MaxHistoryItems: defaultMaxHistoryItems,
100+ MaxHistoryDays: defaultMaxHistoryDays,
101101 ShowVisitCount: true,
102102 ShowLastVisited: true,
103103 HistoryPrefix: "🕒",
@@ -7,7 +7,7 @@ const (
7 defaultRetentionDays = 365 // 1 year7 defaultRetentionDays = 365 // 1 year
8 8
9 // Dmenu defaults9 // Dmenu defaults
10- defaultMaxHistoryItems = 20 // items10+ defaultMaxHistoryDays = 30 // days
11 11
12 // Logging defaults12 // Logging defaults
13 defaultMaxLogAgeDays = 7 // days13 defaultMaxLogAgeDays = 7 // days
@@ -97,7 +97,7 @@ func DefaultConfig() *Config {
97 SearchShortcuts: GetDefaultSearchShortcuts(),97 SearchShortcuts: GetDefaultSearchShortcuts(),
98 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",98 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
99 Dmenu: DmenuConfig{99 Dmenu: DmenuConfig{
100- MaxHistoryItems: defaultMaxHistoryItems,100+ MaxHistoryDays: defaultMaxHistoryDays,
101 ShowVisitCount: true,101 ShowVisitCount: true,
102 ShowLastVisited: true,102 ShowLastVisited: true,
103 HistoryPrefix: "🕒",103 HistoryPrefix: "🕒",
M internal/infrastructure/config/loader.go +1 -1
@@ -348,7 +348,7 @@ func (m *Manager) setSearchDefaults(defaults *Config) {
348348 }
349349
350350 func (m *Manager) setDmenuDefaults(defaults *Config) {
351- m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems)
351+ m.viper.SetDefault("dmenu.max_history_days", defaults.Dmenu.MaxHistoryDays)
352352 m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)
353353 m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)
354354 m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)
@@ -348,7 +348,7 @@ func (m *Manager) setSearchDefaults(defaults *Config) {
348 }348 }
349 349
350 func (m *Manager) setDmenuDefaults(defaults *Config) {350 func (m *Manager) setDmenuDefaults(defaults *Config) {
351- m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems)351+ m.viper.SetDefault("dmenu.max_history_days", defaults.Dmenu.MaxHistoryDays)
352 m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)352 m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)
353 m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)353 m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)
354 m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)354 m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)
M internal/infrastructure/config/schema.go +1 -1
@@ -286,7 +286,7 @@ func (c *Config) ShortcutURLs() map[string]string {
286286
287287 // DmenuConfig holds dmenu/rofi integration configuration.
288288 type DmenuConfig struct {
289- MaxHistoryItems int `mapstructure:"max_history_items" yaml:"max_history_items" toml:"max_history_items"`
289+ MaxHistoryDays int `mapstructure:"max_history_days" yaml:"max_history_days" toml:"max_history_days"`
290290 ShowVisitCount bool `mapstructure:"show_visit_count" yaml:"show_visit_count" toml:"show_visit_count"`
291291 ShowLastVisited bool `mapstructure:"show_last_visited" yaml:"show_last_visited" toml:"show_last_visited"`
292292 HistoryPrefix string `mapstructure:"history_prefix" yaml:"history_prefix" toml:"history_prefix"`
@@ -286,7 +286,7 @@ func (c *Config) ShortcutURLs() map[string]string {
286 286
287 // DmenuConfig holds dmenu/rofi integration configuration.287 // DmenuConfig holds dmenu/rofi integration configuration.
288 type DmenuConfig struct {288 type DmenuConfig struct {
289- MaxHistoryItems int `mapstructure:"max_history_items" yaml:"max_history_items" toml:"max_history_items"`289+ MaxHistoryDays int `mapstructure:"max_history_days" yaml:"max_history_days" toml:"max_history_days"`
290 ShowVisitCount bool `mapstructure:"show_visit_count" yaml:"show_visit_count" toml:"show_visit_count"`290 ShowVisitCount bool `mapstructure:"show_visit_count" yaml:"show_visit_count" toml:"show_visit_count"`
291 ShowLastVisited bool `mapstructure:"show_last_visited" yaml:"show_last_visited" toml:"show_last_visited"`291 ShowLastVisited bool `mapstructure:"show_last_visited" yaml:"show_last_visited" toml:"show_last_visited"`
292 HistoryPrefix string `mapstructure:"history_prefix" yaml:"history_prefix" toml:"history_prefix"`292 HistoryPrefix string `mapstructure:"history_prefix" yaml:"history_prefix" toml:"history_prefix"`
M internal/infrastructure/config/schema_provider.go +3 -3
@@ -264,10 +264,10 @@ func (*SchemaProvider) getSearchKeys(defaults *Config) []entity.ConfigKeyInfo {
264264 func (*SchemaProvider) getDmenuKeys(defaults *Config) []entity.ConfigKeyInfo {
265265 return []entity.ConfigKeyInfo{
266266 {
267- Key: "dmenu.max_history_items",
267+ Key: "dmenu.max_history_days",
268268 Type: "int",
269- Default: fmt.Sprintf("%d", defaults.Dmenu.MaxHistoryItems),
270- Description: "Maximum history items shown in dmenu",
269+ Default: fmt.Sprintf("%d", defaults.Dmenu.MaxHistoryDays),
270+ Description: "Number of days of history to show in dmenu (0 = all)",
271271 Range: ">=0",
272272 Section: SectionDmenu,
273273 },
@@ -264,10 +264,10 @@ func (*SchemaProvider) getSearchKeys(defaults *Config) []entity.ConfigKeyInfo {
264 func (*SchemaProvider) getDmenuKeys(defaults *Config) []entity.ConfigKeyInfo {264 func (*SchemaProvider) getDmenuKeys(defaults *Config) []entity.ConfigKeyInfo {
265 return []entity.ConfigKeyInfo{265 return []entity.ConfigKeyInfo{
266 {266 {
267- Key: "dmenu.max_history_items",267+ Key: "dmenu.max_history_days",
268 Type: "int",268 Type: "int",
269- Default: fmt.Sprintf("%d", defaults.Dmenu.MaxHistoryItems),269+ Default: fmt.Sprintf("%d", defaults.Dmenu.MaxHistoryDays),
270- Description: "Maximum history items shown in dmenu",270+ Description: "Number of days of history to show in dmenu (0 = all)",
271 Range: ">=0",271 Range: ">=0",
272 Section: SectionDmenu,272 Section: SectionDmenu,
273 },273 },
M internal/infrastructure/config/validation.go +2 -2
@@ -53,8 +53,8 @@ func validateHistory(config *Config) []string {
5353 }
5454
5555 func validateDmenu(config *Config) []string {
56- if config.Dmenu.MaxHistoryItems < 0 {
57- return []string{"dmenu.max_history_items must be non-negative"}
56+ if config.Dmenu.MaxHistoryDays < 0 {
57+ return []string{"dmenu.max_history_days must be non-negative"}
5858 }
5959 return nil
6060 }
@@ -53,8 +53,8 @@ func validateHistory(config *Config) []string {
53 }53 }
54 54
55 func validateDmenu(config *Config) []string {55 func validateDmenu(config *Config) []string {
56- if config.Dmenu.MaxHistoryItems < 0 {56+ if config.Dmenu.MaxHistoryDays < 0 {
57- return []string{"dmenu.max_history_items must be non-negative"}57+ return []string{"dmenu.max_history_days must be non-negative"}
58 }58 }
59 return nil59 return nil
60 }60 }
M internal/infrastructure/persistence/sqlite/history_repo.go +63 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "database/sql"
66 "errors"
7+ "fmt"
78 "time"
89
910 "github.com/bnema/dumber/internal/domain/entity"
@@ -87,6 +88,68 @@ func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*enti
8788 return entries, nil
8889 }
8990
91+func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
92+ if days <= 0 {
93+ return nil, fmt.Errorf("days must be positive, got %d", days)
94+ }
95+ // Format: "-N days" for SQLite datetime modifier
96+ daysModifier := fmt.Sprintf("-%d days", days)
97+ rows, err := r.queries.GetRecentHistorySince(ctx, daysModifier)
98+ if err != nil {
99+ return nil, err
100+ }
101+
102+ entries := make([]*entity.HistoryEntry, len(rows))
103+ for i := range rows {
104+ entries[i] = historyFromRow(rows[i])
105+ }
106+ return entries, nil
107+}
108+
109+func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
110+ if days <= 0 {
111+ return nil, fmt.Errorf("days must be positive, got %d", days)
112+ }
113+ // Format: "-N days" for SQLite datetime modifier
114+ daysModifier := fmt.Sprintf("-%d days", days)
115+ rows, err := r.queries.GetMostVisited(ctx, daysModifier)
116+ if err != nil {
117+ return nil, err
118+ }
119+
120+ entries := make([]*entity.HistoryEntry, len(rows))
121+ for i := range rows {
122+ entries[i] = historyFromRow(rows[i])
123+ }
124+ return entries, nil
125+}
126+
127+func (r *historyRepo) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {
128+ rows, err := r.queries.GetAllRecentHistory(ctx)
129+ if err != nil {
130+ return nil, err
131+ }
132+
133+ entries := make([]*entity.HistoryEntry, len(rows))
134+ for i := range rows {
135+ entries[i] = historyFromRow(rows[i])
136+ }
137+ return entries, nil
138+}
139+
140+func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) {
141+ rows, err := r.queries.GetAllMostVisited(ctx)
142+ if err != nil {
143+ return nil, err
144+ }
145+
146+ entries := make([]*entity.HistoryEntry, len(rows))
147+ for i := range rows {
148+ entries[i] = historyFromRow(rows[i])
149+ }
150+ return entries, nil
151+}
152+
90153 func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error {
91154 return r.queries.IncrementVisitCount(ctx, url)
92155 }
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "database/sql"5 "database/sql"
6 "errors"6 "errors"
7+ "fmt"
7 "time"8 "time"
8 9
9 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
@@ -87,6 +88,68 @@ func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*enti
87 return entries, nil88 return entries, nil
88 }89 }
89 90
91+func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
92+ if days <= 0 {
93+ return nil, fmt.Errorf("days must be positive, got %d", days)
94+ }
95+ // Format: "-N days" for SQLite datetime modifier
96+ daysModifier := fmt.Sprintf("-%d days", days)
97+ rows, err := r.queries.GetRecentHistorySince(ctx, daysModifier)
98+ if err != nil {
99+ return nil, err
100+ }
101+
102+ entries := make([]*entity.HistoryEntry, len(rows))
103+ for i := range rows {
104+ entries[i] = historyFromRow(rows[i])
105+ }
106+ return entries, nil
107+}
108+
109+func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
110+ if days <= 0 {
111+ return nil, fmt.Errorf("days must be positive, got %d", days)
112+ }
113+ // Format: "-N days" for SQLite datetime modifier
114+ daysModifier := fmt.Sprintf("-%d days", days)
115+ rows, err := r.queries.GetMostVisited(ctx, daysModifier)
116+ if err != nil {
117+ return nil, err
118+ }
119+
120+ entries := make([]*entity.HistoryEntry, len(rows))
121+ for i := range rows {
122+ entries[i] = historyFromRow(rows[i])
123+ }
124+ return entries, nil
125+}
126+
127+func (r *historyRepo) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {
128+ rows, err := r.queries.GetAllRecentHistory(ctx)
129+ if err != nil {
130+ return nil, err
131+ }
132+
133+ entries := make([]*entity.HistoryEntry, len(rows))
134+ for i := range rows {
135+ entries[i] = historyFromRow(rows[i])
136+ }
137+ return entries, nil
138+}
139+
140+func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) {
141+ rows, err := r.queries.GetAllMostVisited(ctx)
142+ if err != nil {
143+ return nil, err
144+ }
145+
146+ entries := make([]*entity.HistoryEntry, len(rows))
147+ for i := range rows {
148+ entries[i] = historyFromRow(rows[i])
149+ }
150+ return entries, nil
151+}
152+
90 func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error {153 func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error {
91 return r.queries.IncrementVisitCount(ctx, url)154 return r.queries.IncrementVisitCount(ctx, url)
92 }155 }
A internal/infrastructure/persistence/sqlite/history_repo_test.go +325 -0
@@ -0,0 +1,325 @@
1+package sqlite_test
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
11+ "github.com/bnema/dumber/internal/logging"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func historyTestCtx() context.Context {
17+ logger := logging.NewFromConfigValues("debug", "console")
18+ return logging.WithContext(context.Background(), logger)
19+}
20+
21+func TestHistoryRepository_GetRecentSince(t *testing.T) {
22+ ctx := historyTestCtx()
23+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
24+
25+ db, err := sqlite.NewConnection(ctx, dbPath)
26+ require.NoError(t, err)
27+ t.Cleanup(func() { _ = db.Close() })
28+
29+ repo := sqlite.NewHistoryRepository(db)
30+
31+ // Save some history entries
32+ entries := []*entity.HistoryEntry{
33+ {URL: "https://example.com", Title: "Example"},
34+ {URL: "https://github.com", Title: "GitHub"},
35+ {URL: "https://google.com", Title: "Google"},
36+ }
37+
38+ for _, e := range entries {
39+ require.NoError(t, repo.Save(ctx, e))
40+ }
41+
42+ // Get recent since 30 days (all should be included)
43+ results, err := repo.GetRecentSince(ctx, 30)
44+ require.NoError(t, err)
45+ require.Len(t, results, 3)
46+
47+ // Results should be sorted by last_visited DESC
48+ // Since they were all just inserted, order may vary
49+ urls := make(map[string]bool)
50+ for _, r := range results {
51+ urls[r.URL] = true
52+ }
53+ assert.True(t, urls["https://example.com"])
54+ assert.True(t, urls["https://github.com"])
55+ assert.True(t, urls["https://google.com"])
56+}
57+
58+func TestHistoryRepository_GetRecentSince_EmptyResult(t *testing.T) {
59+ ctx := historyTestCtx()
60+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
61+
62+ db, err := sqlite.NewConnection(ctx, dbPath)
63+ require.NoError(t, err)
64+ t.Cleanup(func() { _ = db.Close() })
65+
66+ repo := sqlite.NewHistoryRepository(db)
67+
68+ // Get recent with no data
69+ results, err := repo.GetRecentSince(ctx, 30)
70+ require.NoError(t, err)
71+ assert.Empty(t, results)
72+}
73+
74+func TestHistoryRepository_GetMostVisited(t *testing.T) {
75+ ctx := historyTestCtx()
76+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
77+
78+ db, err := sqlite.NewConnection(ctx, dbPath)
79+ require.NoError(t, err)
80+ t.Cleanup(func() { _ = db.Close() })
81+
82+ repo := sqlite.NewHistoryRepository(db)
83+
84+ // Save entries with different visit counts
85+ entry1 := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
86+ entry2 := &entity.HistoryEntry{URL: "https://github.com", Title: "GitHub"}
87+ entry3 := &entity.HistoryEntry{URL: "https://google.com", Title: "Google"}
88+
89+ require.NoError(t, repo.Save(ctx, entry1))
90+ require.NoError(t, repo.Save(ctx, entry2))
91+ require.NoError(t, repo.Save(ctx, entry3))
92+
93+ // Increment visit counts to create different popularities
94+ // github: 5 visits, google: 3 visits, example: 1 visit
95+ for i := 0; i < 4; i++ {
96+ require.NoError(t, repo.IncrementVisitCount(ctx, "https://github.com"))
97+ }
98+ for i := 0; i < 2; i++ {
99+ require.NoError(t, repo.IncrementVisitCount(ctx, "https://google.com"))
100+ }
101+
102+ // Get most visited
103+ results, err := repo.GetMostVisited(ctx, 30)
104+ require.NoError(t, err)
105+ require.Len(t, results, 3)
106+
107+ // First result should be github (5 visits)
108+ assert.Equal(t, "https://github.com", results[0].URL)
109+ assert.Equal(t, int64(5), results[0].VisitCount)
110+
111+ // Second should be google (3 visits)
112+ assert.Equal(t, "https://google.com", results[1].URL)
113+ assert.Equal(t, int64(3), results[1].VisitCount)
114+
115+ // Third should be example (1 visit)
116+ assert.Equal(t, "https://example.com", results[2].URL)
117+ assert.Equal(t, int64(1), results[2].VisitCount)
118+}
119+
120+func TestHistoryRepository_GetMostVisited_EmptyResult(t *testing.T) {
121+ ctx := historyTestCtx()
122+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
123+
124+ db, err := sqlite.NewConnection(ctx, dbPath)
125+ require.NoError(t, err)
126+ t.Cleanup(func() { _ = db.Close() })
127+
128+ repo := sqlite.NewHistoryRepository(db)
129+
130+ // Get most visited with no data
131+ results, err := repo.GetMostVisited(ctx, 30)
132+ require.NoError(t, err)
133+ assert.Empty(t, results)
134+}
135+
136+func TestHistoryRepository_GetRecentSince_RespectsTimeFilter(t *testing.T) {
137+ ctx := historyTestCtx()
138+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
139+
140+ db, err := sqlite.NewConnection(ctx, dbPath)
141+ require.NoError(t, err)
142+ t.Cleanup(func() { _ = db.Close() })
143+
144+ repo := sqlite.NewHistoryRepository(db)
145+
146+ // Save an entry
147+ entry := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
148+ require.NoError(t, repo.Save(ctx, entry))
149+
150+ // Query with 1 day filter - should include recently added entry
151+ results, err := repo.GetRecentSince(ctx, 1)
152+ require.NoError(t, err)
153+ require.Len(t, results, 1)
154+
155+ // Query with very large days filter - should still work
156+ results, err = repo.GetRecentSince(ctx, 365)
157+ require.NoError(t, err)
158+ require.Len(t, results, 1)
159+}
160+
161+func TestHistoryRepository_CRUD(t *testing.T) {
162+ ctx := historyTestCtx()
163+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
164+
165+ db, err := sqlite.NewConnection(ctx, dbPath)
166+ require.NoError(t, err)
167+ t.Cleanup(func() { _ = db.Close() })
168+
169+ repo := sqlite.NewHistoryRepository(db)
170+
171+ // Create
172+ entry := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
173+ require.NoError(t, repo.Save(ctx, entry))
174+
175+ // Read
176+ found, err := repo.FindByURL(ctx, "https://example.com")
177+ require.NoError(t, err)
178+ require.NotNil(t, found)
179+ assert.Equal(t, "https://example.com", found.URL)
180+ assert.Equal(t, "Example", found.Title)
181+ assert.Equal(t, int64(1), found.VisitCount)
182+
183+ // Update (save again increments visit count)
184+ require.NoError(t, repo.Save(ctx, entry))
185+ found2, err := repo.FindByURL(ctx, "https://example.com")
186+ require.NoError(t, err)
187+ assert.Equal(t, int64(2), found2.VisitCount)
188+
189+ // Get recent
190+ recent, err := repo.GetRecent(ctx, 10, 0)
191+ require.NoError(t, err)
192+ require.Len(t, recent, 1)
193+
194+ // Delete
195+ require.NoError(t, repo.Delete(ctx, found.ID))
196+
197+ // Verify deleted
198+ deleted, err := repo.FindByURL(ctx, "https://example.com")
199+ require.NoError(t, err)
200+ assert.Nil(t, deleted)
201+}
202+
203+// Ensure the test uses an isolated timestamp for time-sensitive queries
204+func TestHistoryRepository_TimeFilterIntegrity(t *testing.T) {
205+ ctx := historyTestCtx()
206+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
207+
208+ db, err := sqlite.NewConnection(ctx, dbPath)
209+ require.NoError(t, err)
210+ t.Cleanup(func() { _ = db.Close() })
211+
212+ repo := sqlite.NewHistoryRepository(db)
213+
214+ // Save entry
215+ entry := &entity.HistoryEntry{URL: "https://test.com", Title: "Test"}
216+ require.NoError(t, repo.Save(ctx, entry))
217+
218+ // Verify entry was saved with recent timestamp
219+ found, err := repo.FindByURL(ctx, "https://test.com")
220+ require.NoError(t, err)
221+ require.NotNil(t, found)
222+
223+ // LastVisited should be within the last minute
224+ timeDiff := time.Since(found.LastVisited)
225+ assert.Less(t, timeDiff, time.Minute, "LastVisited should be recent, got %v ago", timeDiff)
226+}
227+
228+func TestHistoryRepository_GetRecentSince_RejectsNonPositiveDays(t *testing.T) {
229+ ctx := historyTestCtx()
230+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
231+
232+ db, err := sqlite.NewConnection(ctx, dbPath)
233+ require.NoError(t, err)
234+ t.Cleanup(func() { _ = db.Close() })
235+
236+ repo := sqlite.NewHistoryRepository(db)
237+
238+ // days = 0 should return error
239+ _, err = repo.GetRecentSince(ctx, 0)
240+ require.Error(t, err)
241+ assert.Contains(t, err.Error(), "days must be positive")
242+
243+ // days < 0 should return error
244+ _, err = repo.GetRecentSince(ctx, -5)
245+ require.Error(t, err)
246+ assert.Contains(t, err.Error(), "days must be positive")
247+}
248+
249+func TestHistoryRepository_GetMostVisited_RejectsNonPositiveDays(t *testing.T) {
250+ ctx := historyTestCtx()
251+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
252+
253+ db, err := sqlite.NewConnection(ctx, dbPath)
254+ require.NoError(t, err)
255+ t.Cleanup(func() { _ = db.Close() })
256+
257+ repo := sqlite.NewHistoryRepository(db)
258+
259+ // days = 0 should return error
260+ _, err = repo.GetMostVisited(ctx, 0)
261+ require.Error(t, err)
262+ assert.Contains(t, err.Error(), "days must be positive")
263+
264+ // days < 0 should return error
265+ _, err = repo.GetMostVisited(ctx, -5)
266+ require.Error(t, err)
267+ assert.Contains(t, err.Error(), "days must be positive")
268+}
269+
270+func TestHistoryRepository_GetAllRecentHistory(t *testing.T) {
271+ ctx := historyTestCtx()
272+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
273+
274+ db, err := sqlite.NewConnection(ctx, dbPath)
275+ require.NoError(t, err)
276+ t.Cleanup(func() { _ = db.Close() })
277+
278+ repo := sqlite.NewHistoryRepository(db)
279+
280+ // Save some entries
281+ entries := []*entity.HistoryEntry{
282+ {URL: "https://example.com", Title: "Example"},
283+ {URL: "https://github.com", Title: "GitHub"},
284+ }
285+ for _, e := range entries {
286+ require.NoError(t, repo.Save(ctx, e))
287+ }
288+
289+ // Get all recent history
290+ results, err := repo.GetAllRecentHistory(ctx)
291+ require.NoError(t, err)
292+ require.Len(t, results, 2)
293+}
294+
295+func TestHistoryRepository_GetAllMostVisited(t *testing.T) {
296+ ctx := historyTestCtx()
297+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
298+
299+ db, err := sqlite.NewConnection(ctx, dbPath)
300+ require.NoError(t, err)
301+ t.Cleanup(func() { _ = db.Close() })
302+
303+ repo := sqlite.NewHistoryRepository(db)
304+
305+ // Save entries with different visit counts
306+ entry1 := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
307+ entry2 := &entity.HistoryEntry{URL: "https://github.com", Title: "GitHub"}
308+
309+ require.NoError(t, repo.Save(ctx, entry1))
310+ require.NoError(t, repo.Save(ctx, entry2))
311+
312+ // Increment github visits
313+ for i := 0; i < 3; i++ {
314+ require.NoError(t, repo.IncrementVisitCount(ctx, "https://github.com"))
315+ }
316+
317+ // Get all most visited
318+ results, err := repo.GetAllMostVisited(ctx)
319+ require.NoError(t, err)
320+ require.Len(t, results, 2)
321+
322+ // First should be github (4 visits)
323+ assert.Equal(t, "https://github.com", results[0].URL)
324+ assert.Equal(t, int64(4), results[0].VisitCount)
325+}
@@ -0,0 +1,325 @@
1+package sqlite_test
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
11+ "github.com/bnema/dumber/internal/logging"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func historyTestCtx() context.Context {
17+ logger := logging.NewFromConfigValues("debug", "console")
18+ return logging.WithContext(context.Background(), logger)
19+}
20+
21+func TestHistoryRepository_GetRecentSince(t *testing.T) {
22+ ctx := historyTestCtx()
23+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
24+
25+ db, err := sqlite.NewConnection(ctx, dbPath)
26+ require.NoError(t, err)
27+ t.Cleanup(func() { _ = db.Close() })
28+
29+ repo := sqlite.NewHistoryRepository(db)
30+
31+ // Save some history entries
32+ entries := []*entity.HistoryEntry{
33+ {URL: "https://example.com", Title: "Example"},
34+ {URL: "https://github.com", Title: "GitHub"},
35+ {URL: "https://google.com", Title: "Google"},
36+ }
37+
38+ for _, e := range entries {
39+ require.NoError(t, repo.Save(ctx, e))
40+ }
41+
42+ // Get recent since 30 days (all should be included)
43+ results, err := repo.GetRecentSince(ctx, 30)
44+ require.NoError(t, err)
45+ require.Len(t, results, 3)
46+
47+ // Results should be sorted by last_visited DESC
48+ // Since they were all just inserted, order may vary
49+ urls := make(map[string]bool)
50+ for _, r := range results {
51+ urls[r.URL] = true
52+ }
53+ assert.True(t, urls["https://example.com"])
54+ assert.True(t, urls["https://github.com"])
55+ assert.True(t, urls["https://google.com"])
56+}
57+
58+func TestHistoryRepository_GetRecentSince_EmptyResult(t *testing.T) {
59+ ctx := historyTestCtx()
60+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
61+
62+ db, err := sqlite.NewConnection(ctx, dbPath)
63+ require.NoError(t, err)
64+ t.Cleanup(func() { _ = db.Close() })
65+
66+ repo := sqlite.NewHistoryRepository(db)
67+
68+ // Get recent with no data
69+ results, err := repo.GetRecentSince(ctx, 30)
70+ require.NoError(t, err)
71+ assert.Empty(t, results)
72+}
73+
74+func TestHistoryRepository_GetMostVisited(t *testing.T) {
75+ ctx := historyTestCtx()
76+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
77+
78+ db, err := sqlite.NewConnection(ctx, dbPath)
79+ require.NoError(t, err)
80+ t.Cleanup(func() { _ = db.Close() })
81+
82+ repo := sqlite.NewHistoryRepository(db)
83+
84+ // Save entries with different visit counts
85+ entry1 := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
86+ entry2 := &entity.HistoryEntry{URL: "https://github.com", Title: "GitHub"}
87+ entry3 := &entity.HistoryEntry{URL: "https://google.com", Title: "Google"}
88+
89+ require.NoError(t, repo.Save(ctx, entry1))
90+ require.NoError(t, repo.Save(ctx, entry2))
91+ require.NoError(t, repo.Save(ctx, entry3))
92+
93+ // Increment visit counts to create different popularities
94+ // github: 5 visits, google: 3 visits, example: 1 visit
95+ for i := 0; i < 4; i++ {
96+ require.NoError(t, repo.IncrementVisitCount(ctx, "https://github.com"))
97+ }
98+ for i := 0; i < 2; i++ {
99+ require.NoError(t, repo.IncrementVisitCount(ctx, "https://google.com"))
100+ }
101+
102+ // Get most visited
103+ results, err := repo.GetMostVisited(ctx, 30)
104+ require.NoError(t, err)
105+ require.Len(t, results, 3)
106+
107+ // First result should be github (5 visits)
108+ assert.Equal(t, "https://github.com", results[0].URL)
109+ assert.Equal(t, int64(5), results[0].VisitCount)
110+
111+ // Second should be google (3 visits)
112+ assert.Equal(t, "https://google.com", results[1].URL)
113+ assert.Equal(t, int64(3), results[1].VisitCount)
114+
115+ // Third should be example (1 visit)
116+ assert.Equal(t, "https://example.com", results[2].URL)
117+ assert.Equal(t, int64(1), results[2].VisitCount)
118+}
119+
120+func TestHistoryRepository_GetMostVisited_EmptyResult(t *testing.T) {
121+ ctx := historyTestCtx()
122+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
123+
124+ db, err := sqlite.NewConnection(ctx, dbPath)
125+ require.NoError(t, err)
126+ t.Cleanup(func() { _ = db.Close() })
127+
128+ repo := sqlite.NewHistoryRepository(db)
129+
130+ // Get most visited with no data
131+ results, err := repo.GetMostVisited(ctx, 30)
132+ require.NoError(t, err)
133+ assert.Empty(t, results)
134+}
135+
136+func TestHistoryRepository_GetRecentSince_RespectsTimeFilter(t *testing.T) {
137+ ctx := historyTestCtx()
138+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
139+
140+ db, err := sqlite.NewConnection(ctx, dbPath)
141+ require.NoError(t, err)
142+ t.Cleanup(func() { _ = db.Close() })
143+
144+ repo := sqlite.NewHistoryRepository(db)
145+
146+ // Save an entry
147+ entry := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
148+ require.NoError(t, repo.Save(ctx, entry))
149+
150+ // Query with 1 day filter - should include recently added entry
151+ results, err := repo.GetRecentSince(ctx, 1)
152+ require.NoError(t, err)
153+ require.Len(t, results, 1)
154+
155+ // Query with very large days filter - should still work
156+ results, err = repo.GetRecentSince(ctx, 365)
157+ require.NoError(t, err)
158+ require.Len(t, results, 1)
159+}
160+
161+func TestHistoryRepository_CRUD(t *testing.T) {
162+ ctx := historyTestCtx()
163+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
164+
165+ db, err := sqlite.NewConnection(ctx, dbPath)
166+ require.NoError(t, err)
167+ t.Cleanup(func() { _ = db.Close() })
168+
169+ repo := sqlite.NewHistoryRepository(db)
170+
171+ // Create
172+ entry := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
173+ require.NoError(t, repo.Save(ctx, entry))
174+
175+ // Read
176+ found, err := repo.FindByURL(ctx, "https://example.com")
177+ require.NoError(t, err)
178+ require.NotNil(t, found)
179+ assert.Equal(t, "https://example.com", found.URL)
180+ assert.Equal(t, "Example", found.Title)
181+ assert.Equal(t, int64(1), found.VisitCount)
182+
183+ // Update (save again increments visit count)
184+ require.NoError(t, repo.Save(ctx, entry))
185+ found2, err := repo.FindByURL(ctx, "https://example.com")
186+ require.NoError(t, err)
187+ assert.Equal(t, int64(2), found2.VisitCount)
188+
189+ // Get recent
190+ recent, err := repo.GetRecent(ctx, 10, 0)
191+ require.NoError(t, err)
192+ require.Len(t, recent, 1)
193+
194+ // Delete
195+ require.NoError(t, repo.Delete(ctx, found.ID))
196+
197+ // Verify deleted
198+ deleted, err := repo.FindByURL(ctx, "https://example.com")
199+ require.NoError(t, err)
200+ assert.Nil(t, deleted)
201+}
202+
203+// Ensure the test uses an isolated timestamp for time-sensitive queries
204+func TestHistoryRepository_TimeFilterIntegrity(t *testing.T) {
205+ ctx := historyTestCtx()
206+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
207+
208+ db, err := sqlite.NewConnection(ctx, dbPath)
209+ require.NoError(t, err)
210+ t.Cleanup(func() { _ = db.Close() })
211+
212+ repo := sqlite.NewHistoryRepository(db)
213+
214+ // Save entry
215+ entry := &entity.HistoryEntry{URL: "https://test.com", Title: "Test"}
216+ require.NoError(t, repo.Save(ctx, entry))
217+
218+ // Verify entry was saved with recent timestamp
219+ found, err := repo.FindByURL(ctx, "https://test.com")
220+ require.NoError(t, err)
221+ require.NotNil(t, found)
222+
223+ // LastVisited should be within the last minute
224+ timeDiff := time.Since(found.LastVisited)
225+ assert.Less(t, timeDiff, time.Minute, "LastVisited should be recent, got %v ago", timeDiff)
226+}
227+
228+func TestHistoryRepository_GetRecentSince_RejectsNonPositiveDays(t *testing.T) {
229+ ctx := historyTestCtx()
230+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
231+
232+ db, err := sqlite.NewConnection(ctx, dbPath)
233+ require.NoError(t, err)
234+ t.Cleanup(func() { _ = db.Close() })
235+
236+ repo := sqlite.NewHistoryRepository(db)
237+
238+ // days = 0 should return error
239+ _, err = repo.GetRecentSince(ctx, 0)
240+ require.Error(t, err)
241+ assert.Contains(t, err.Error(), "days must be positive")
242+
243+ // days < 0 should return error
244+ _, err = repo.GetRecentSince(ctx, -5)
245+ require.Error(t, err)
246+ assert.Contains(t, err.Error(), "days must be positive")
247+}
248+
249+func TestHistoryRepository_GetMostVisited_RejectsNonPositiveDays(t *testing.T) {
250+ ctx := historyTestCtx()
251+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
252+
253+ db, err := sqlite.NewConnection(ctx, dbPath)
254+ require.NoError(t, err)
255+ t.Cleanup(func() { _ = db.Close() })
256+
257+ repo := sqlite.NewHistoryRepository(db)
258+
259+ // days = 0 should return error
260+ _, err = repo.GetMostVisited(ctx, 0)
261+ require.Error(t, err)
262+ assert.Contains(t, err.Error(), "days must be positive")
263+
264+ // days < 0 should return error
265+ _, err = repo.GetMostVisited(ctx, -5)
266+ require.Error(t, err)
267+ assert.Contains(t, err.Error(), "days must be positive")
268+}
269+
270+func TestHistoryRepository_GetAllRecentHistory(t *testing.T) {
271+ ctx := historyTestCtx()
272+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
273+
274+ db, err := sqlite.NewConnection(ctx, dbPath)
275+ require.NoError(t, err)
276+ t.Cleanup(func() { _ = db.Close() })
277+
278+ repo := sqlite.NewHistoryRepository(db)
279+
280+ // Save some entries
281+ entries := []*entity.HistoryEntry{
282+ {URL: "https://example.com", Title: "Example"},
283+ {URL: "https://github.com", Title: "GitHub"},
284+ }
285+ for _, e := range entries {
286+ require.NoError(t, repo.Save(ctx, e))
287+ }
288+
289+ // Get all recent history
290+ results, err := repo.GetAllRecentHistory(ctx)
291+ require.NoError(t, err)
292+ require.Len(t, results, 2)
293+}
294+
295+func TestHistoryRepository_GetAllMostVisited(t *testing.T) {
296+ ctx := historyTestCtx()
297+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
298+
299+ db, err := sqlite.NewConnection(ctx, dbPath)
300+ require.NoError(t, err)
301+ t.Cleanup(func() { _ = db.Close() })
302+
303+ repo := sqlite.NewHistoryRepository(db)
304+
305+ // Save entries with different visit counts
306+ entry1 := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
307+ entry2 := &entity.HistoryEntry{URL: "https://github.com", Title: "GitHub"}
308+
309+ require.NoError(t, repo.Save(ctx, entry1))
310+ require.NoError(t, repo.Save(ctx, entry2))
311+
312+ // Increment github visits
313+ for i := 0; i < 3; i++ {
314+ require.NoError(t, repo.IncrementVisitCount(ctx, "https://github.com"))
315+ }
316+
317+ // Get all most visited
318+ results, err := repo.GetAllMostVisited(ctx)
319+ require.NoError(t, err)
320+ require.Len(t, results, 2)
321+
322+ // First should be github (4 visits)
323+ assert.Equal(t, "https://github.com", results[0].URL)
324+ assert.Equal(t, int64(4), results[0].VisitCount)
325+}
M internal/infrastructure/persistence/sqlite/lazy_repos.go +28 -0
@@ -79,6 +79,34 @@ func (r *LazyHistoryRepository) GetRecent(ctx context.Context, limit, offset int
7979 return r.repo.GetRecent(ctx, limit, offset)
8080 }
8181
82+func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
83+ if err := r.init(ctx); err != nil {
84+ return nil, err
85+ }
86+ return r.repo.GetRecentSince(ctx, days)
87+}
88+
89+func (r *LazyHistoryRepository) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
90+ if err := r.init(ctx); err != nil {
91+ return nil, err
92+ }
93+ return r.repo.GetMostVisited(ctx, days)
94+}
95+
96+func (r *LazyHistoryRepository) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {
97+ if err := r.init(ctx); err != nil {
98+ return nil, err
99+ }
100+ return r.repo.GetAllRecentHistory(ctx)
101+}
102+
103+func (r *LazyHistoryRepository) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) {
104+ if err := r.init(ctx); err != nil {
105+ return nil, err
106+ }
107+ return r.repo.GetAllMostVisited(ctx)
108+}
109+
82110 func (r *LazyHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error {
83111 if err := r.init(ctx); err != nil {
84112 return err
@@ -79,6 +79,34 @@ func (r *LazyHistoryRepository) GetRecent(ctx context.Context, limit, offset int
79 return r.repo.GetRecent(ctx, limit, offset)79 return r.repo.GetRecent(ctx, limit, offset)
80 }80 }
81 81
82+func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
83+ if err := r.init(ctx); err != nil {
84+ return nil, err
85+ }
86+ return r.repo.GetRecentSince(ctx, days)
87+}
88+
89+func (r *LazyHistoryRepository) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
90+ if err := r.init(ctx); err != nil {
91+ return nil, err
92+ }
93+ return r.repo.GetMostVisited(ctx, days)
94+}
95+
96+func (r *LazyHistoryRepository) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {
97+ if err := r.init(ctx); err != nil {
98+ return nil, err
99+ }
100+ return r.repo.GetAllRecentHistory(ctx)
101+}
102+
103+func (r *LazyHistoryRepository) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) {
104+ if err := r.init(ctx); err != nil {
105+ return nil, err
106+ }
107+ return r.repo.GetAllMostVisited(ctx)
108+}
109+
82 func (r *LazyHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error {110 func (r *LazyHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error {
83 if err := r.init(ctx); err != nil {111 if err := r.init(ctx); err != nil {
84 return err112 return err
M internal/infrastructure/persistence/sqlite/queries/history.sql +18 -0
@@ -56,3 +56,21 @@ JOIN history h ON fts.rowid = h.id
5656 WHERE fts.url MATCH ?
5757 ORDER BY bm25(history_fts)
5858 LIMIT ?;
59+
60+-- name: GetRecentHistorySince :many
61+SELECT * FROM history
62+WHERE last_visited >= datetime('now', ?)
63+ORDER BY last_visited DESC;
64+
65+-- name: GetMostVisited :many
66+SELECT * FROM history
67+WHERE last_visited >= datetime('now', ?)
68+ORDER BY visit_count DESC, last_visited DESC;
69+
70+-- name: GetAllRecentHistory :many
71+SELECT * FROM history
72+ORDER BY last_visited DESC;
73+
74+-- name: GetAllMostVisited :many
75+SELECT * FROM history
76+ORDER BY visit_count DESC, last_visited DESC;
@@ -56,3 +56,21 @@ JOIN history h ON fts.rowid = h.id
56 WHERE fts.url MATCH ?56 WHERE fts.url MATCH ?
57 ORDER BY bm25(history_fts)57 ORDER BY bm25(history_fts)
58 LIMIT ?;58 LIMIT ?;
59+
60+-- name: GetRecentHistorySince :many
61+SELECT * FROM history
62+WHERE last_visited >= datetime('now', ?)
63+ORDER BY last_visited DESC;
64+
65+-- name: GetMostVisited :many
66+SELECT * FROM history
67+WHERE last_visited >= datetime('now', ?)
68+ORDER BY visit_count DESC, last_visited DESC;
69+
70+-- name: GetAllRecentHistory :many
71+SELECT * FROM history
72+ORDER BY last_visited DESC;
73+
74+-- name: GetAllMostVisited :many
75+SELECT * FROM history
76+ORDER BY visit_count DESC, last_visited DESC;
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +146 -0
@@ -58,6 +58,78 @@ func (q *Queries) DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.Nu
5858 return err
5959 }
6060
61+const GetAllMostVisited = `-- name: GetAllMostVisited :many
62+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
63+ORDER BY visit_count DESC, last_visited DESC
64+`
65+
66+func (q *Queries) GetAllMostVisited(ctx context.Context) ([]History, error) {
67+ rows, err := q.db.QueryContext(ctx, GetAllMostVisited)
68+ if err != nil {
69+ return nil, err
70+ }
71+ defer rows.Close()
72+ items := []History{}
73+ for rows.Next() {
74+ var i History
75+ if err := rows.Scan(
76+ &i.ID,
77+ &i.Url,
78+ &i.Title,
79+ &i.FaviconUrl,
80+ &i.VisitCount,
81+ &i.LastVisited,
82+ &i.CreatedAt,
83+ ); err != nil {
84+ return nil, err
85+ }
86+ items = append(items, i)
87+ }
88+ if err := rows.Close(); err != nil {
89+ return nil, err
90+ }
91+ if err := rows.Err(); err != nil {
92+ return nil, err
93+ }
94+ return items, nil
95+}
96+
97+const GetAllRecentHistory = `-- name: GetAllRecentHistory :many
98+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
99+ORDER BY last_visited DESC
100+`
101+
102+func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) {
103+ rows, err := q.db.QueryContext(ctx, GetAllRecentHistory)
104+ if err != nil {
105+ return nil, err
106+ }
107+ defer rows.Close()
108+ items := []History{}
109+ for rows.Next() {
110+ var i History
111+ if err := rows.Scan(
112+ &i.ID,
113+ &i.Url,
114+ &i.Title,
115+ &i.FaviconUrl,
116+ &i.VisitCount,
117+ &i.LastVisited,
118+ &i.CreatedAt,
119+ ); err != nil {
120+ return nil, err
121+ }
122+ items = append(items, i)
123+ }
124+ if err := rows.Close(); err != nil {
125+ return nil, err
126+ }
127+ if err := rows.Err(); err != nil {
128+ return nil, err
129+ }
130+ return items, nil
131+}
132+
61133 const GetDailyVisitCount = `-- name: GetDailyVisitCount :many
62134 SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visits FROM history WHERE last_visited >= date('now', ?) GROUP BY day ORDER BY day ASC
63135 `
@@ -198,6 +270,43 @@ func (q *Queries) GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistrib
198270 return items, nil
199271 }
200272
273+const GetMostVisited = `-- name: GetMostVisited :many
274+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
275+WHERE last_visited >= datetime('now', ?)
276+ORDER BY visit_count DESC, last_visited DESC
277+`
278+
279+func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error) {
280+ rows, err := q.db.QueryContext(ctx, GetMostVisited, datetime)
281+ if err != nil {
282+ return nil, err
283+ }
284+ defer rows.Close()
285+ items := []History{}
286+ for rows.Next() {
287+ var i History
288+ if err := rows.Scan(
289+ &i.ID,
290+ &i.Url,
291+ &i.Title,
292+ &i.FaviconUrl,
293+ &i.VisitCount,
294+ &i.LastVisited,
295+ &i.CreatedAt,
296+ ); err != nil {
297+ return nil, err
298+ }
299+ items = append(items, i)
300+ }
301+ if err := rows.Close(); err != nil {
302+ return nil, err
303+ }
304+ if err := rows.Err(); err != nil {
305+ return nil, err
306+ }
307+ return items, nil
308+}
309+
201310 const GetRecentHistory = `-- name: GetRecentHistory :many
202311 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?
203312 `
@@ -238,6 +347,43 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara
238347 return items, nil
239348 }
240349
350+const GetRecentHistorySince = `-- name: GetRecentHistorySince :many
351+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
352+WHERE last_visited >= datetime('now', ?)
353+ORDER BY last_visited DESC
354+`
355+
356+func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error) {
357+ rows, err := q.db.QueryContext(ctx, GetRecentHistorySince, datetime)
358+ if err != nil {
359+ return nil, err
360+ }
361+ defer rows.Close()
362+ items := []History{}
363+ for rows.Next() {
364+ var i History
365+ if err := rows.Scan(
366+ &i.ID,
367+ &i.Url,
368+ &i.Title,
369+ &i.FaviconUrl,
370+ &i.VisitCount,
371+ &i.LastVisited,
372+ &i.CreatedAt,
373+ ); err != nil {
374+ return nil, err
375+ }
376+ items = append(items, i)
377+ }
378+ if err := rows.Close(); err != nil {
379+ return nil, err
380+ }
381+ if err := rows.Err(); err != nil {
382+ return nil, err
383+ }
384+ return items, nil
385+}
386+
241387 const IncrementVisitCount = `-- name: IncrementVisitCount :exec
242388 UPDATE history
243389 SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP
@@ -58,6 +58,78 @@ func (q *Queries) DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.Nu
58 return err58 return err
59 }59 }
60 60
61+const GetAllMostVisited = `-- name: GetAllMostVisited :many
62+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
63+ORDER BY visit_count DESC, last_visited DESC
64+`
65+
66+func (q *Queries) GetAllMostVisited(ctx context.Context) ([]History, error) {
67+ rows, err := q.db.QueryContext(ctx, GetAllMostVisited)
68+ if err != nil {
69+ return nil, err
70+ }
71+ defer rows.Close()
72+ items := []History{}
73+ for rows.Next() {
74+ var i History
75+ if err := rows.Scan(
76+ &i.ID,
77+ &i.Url,
78+ &i.Title,
79+ &i.FaviconUrl,
80+ &i.VisitCount,
81+ &i.LastVisited,
82+ &i.CreatedAt,
83+ ); err != nil {
84+ return nil, err
85+ }
86+ items = append(items, i)
87+ }
88+ if err := rows.Close(); err != nil {
89+ return nil, err
90+ }
91+ if err := rows.Err(); err != nil {
92+ return nil, err
93+ }
94+ return items, nil
95+}
96+
97+const GetAllRecentHistory = `-- name: GetAllRecentHistory :many
98+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
99+ORDER BY last_visited DESC
100+`
101+
102+func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) {
103+ rows, err := q.db.QueryContext(ctx, GetAllRecentHistory)
104+ if err != nil {
105+ return nil, err
106+ }
107+ defer rows.Close()
108+ items := []History{}
109+ for rows.Next() {
110+ var i History
111+ if err := rows.Scan(
112+ &i.ID,
113+ &i.Url,
114+ &i.Title,
115+ &i.FaviconUrl,
116+ &i.VisitCount,
117+ &i.LastVisited,
118+ &i.CreatedAt,
119+ ); err != nil {
120+ return nil, err
121+ }
122+ items = append(items, i)
123+ }
124+ if err := rows.Close(); err != nil {
125+ return nil, err
126+ }
127+ if err := rows.Err(); err != nil {
128+ return nil, err
129+ }
130+ return items, nil
131+}
132+
61 const GetDailyVisitCount = `-- name: GetDailyVisitCount :many133 const GetDailyVisitCount = `-- name: GetDailyVisitCount :many
62 SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visits FROM history WHERE last_visited >= date('now', ?) GROUP BY day ORDER BY day ASC134 SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visits FROM history WHERE last_visited >= date('now', ?) GROUP BY day ORDER BY day ASC
63 `135 `
@@ -198,6 +270,43 @@ func (q *Queries) GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistrib
198 return items, nil270 return items, nil
199 }271 }
200 272
273+const GetMostVisited = `-- name: GetMostVisited :many
274+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
275+WHERE last_visited >= datetime('now', ?)
276+ORDER BY visit_count DESC, last_visited DESC
277+`
278+
279+func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error) {
280+ rows, err := q.db.QueryContext(ctx, GetMostVisited, datetime)
281+ if err != nil {
282+ return nil, err
283+ }
284+ defer rows.Close()
285+ items := []History{}
286+ for rows.Next() {
287+ var i History
288+ if err := rows.Scan(
289+ &i.ID,
290+ &i.Url,
291+ &i.Title,
292+ &i.FaviconUrl,
293+ &i.VisitCount,
294+ &i.LastVisited,
295+ &i.CreatedAt,
296+ ); err != nil {
297+ return nil, err
298+ }
299+ items = append(items, i)
300+ }
301+ if err := rows.Close(); err != nil {
302+ return nil, err
303+ }
304+ if err := rows.Err(); err != nil {
305+ return nil, err
306+ }
307+ return items, nil
308+}
309+
201 const GetRecentHistory = `-- name: GetRecentHistory :many310 const GetRecentHistory = `-- name: GetRecentHistory :many
202 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?311 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?
203 `312 `
@@ -238,6 +347,43 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara
238 return items, nil347 return items, nil
239 }348 }
240 349
350+const GetRecentHistorySince = `-- name: GetRecentHistorySince :many
351+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
352+WHERE last_visited >= datetime('now', ?)
353+ORDER BY last_visited DESC
354+`
355+
356+func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error) {
357+ rows, err := q.db.QueryContext(ctx, GetRecentHistorySince, datetime)
358+ if err != nil {
359+ return nil, err
360+ }
361+ defer rows.Close()
362+ items := []History{}
363+ for rows.Next() {
364+ var i History
365+ if err := rows.Scan(
366+ &i.ID,
367+ &i.Url,
368+ &i.Title,
369+ &i.FaviconUrl,
370+ &i.VisitCount,
371+ &i.LastVisited,
372+ &i.CreatedAt,
373+ ); err != nil {
374+ return nil, err
375+ }
376+ items = append(items, i)
377+ }
378+ if err := rows.Close(); err != nil {
379+ return nil, err
380+ }
381+ if err := rows.Err(); err != nil {
382+ return nil, err
383+ }
384+ return items, nil
385+}
386+
241 const IncrementVisitCount = `-- name: IncrementVisitCount :exec387 const IncrementVisitCount = `-- name: IncrementVisitCount :exec
242 UPDATE history388 UPDATE history
243 SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP389 SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +4 -0
@@ -32,6 +32,8 @@ type Querier interface {
3232 GetActiveBrowserSession(ctx context.Context) (Session, error)
3333 GetAllFavorites(ctx context.Context) ([]Favorite, error)
3434 GetAllFolders(ctx context.Context) ([]FavoriteFolder, error)
35+ GetAllMostVisited(ctx context.Context) ([]History, error)
36+ GetAllRecentHistory(ctx context.Context) ([]History, error)
3537 GetAllSessionStates(ctx context.Context) ([]SessionState, error)
3638 GetAllTags(ctx context.Context) ([]FavoriteTag, error)
3739 GetAllWhitelistedDomains(ctx context.Context) ([]string, error)
@@ -47,7 +49,9 @@ type Querier interface {
4749 GetHistoryByURL(ctx context.Context, url string) (History, error)
4850 GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error)
4951 GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error)
52+ GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error)
5053 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)
54+ GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error)
5155 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)
5256 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)
5357 GetSessionByID(ctx context.Context, id string) (Session, error)
@@ -32,6 +32,8 @@ type Querier interface {
32 GetActiveBrowserSession(ctx context.Context) (Session, error)32 GetActiveBrowserSession(ctx context.Context) (Session, error)
33 GetAllFavorites(ctx context.Context) ([]Favorite, error)33 GetAllFavorites(ctx context.Context) ([]Favorite, error)
34 GetAllFolders(ctx context.Context) ([]FavoriteFolder, error)34 GetAllFolders(ctx context.Context) ([]FavoriteFolder, error)
35+ GetAllMostVisited(ctx context.Context) ([]History, error)
36+ GetAllRecentHistory(ctx context.Context) ([]History, error)
35 GetAllSessionStates(ctx context.Context) ([]SessionState, error)37 GetAllSessionStates(ctx context.Context) ([]SessionState, error)
36 GetAllTags(ctx context.Context) ([]FavoriteTag, error)38 GetAllTags(ctx context.Context) ([]FavoriteTag, error)
37 GetAllWhitelistedDomains(ctx context.Context) ([]string, error)39 GetAllWhitelistedDomains(ctx context.Context) ([]string, error)
@@ -47,7 +49,9 @@ type Querier interface {
47 GetHistoryByURL(ctx context.Context, url string) (History, error)49 GetHistoryByURL(ctx context.Context, url string) (History, error)
48 GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error)50 GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error)
49 GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error)51 GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error)
52+ GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error)
50 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)53 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)
54+ GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error)
51 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)55 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)
52 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)56 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)
53 GetSessionByID(ctx context.Context, id string) (Session, error)57 GetSessionByID(ctx context.Context, id string) (Session, error)

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