detached 9a9b787

refactor(history): use application history port

commit
9a9b787cfbcd2c9c0ae0ea31d81e27b0967339ee
author
brice · · 2026-06-13 06:25 UTC
parent
bf8765a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +65 -114
M internal/ui/browser_window.go +10 -6
@@ -301,7 +301,7 @@ func (bw *browserWindow) initHistorySidebar(ctx context.Context, a *App) {
301301 // buildHistorySidebarConfig constructs the HistorySidebarConfig for the given
302302 // browser window. Extracted from initHistorySidebar for testability.
303303 func (a *App) buildHistorySidebarConfig(ctx context.Context, bw *browserWindow) component.HistorySidebarConfig {
304- var historyUC *usecase.SearchHistoryUseCase
304+ var historyUC port.HomepageHistory
305305 if a.deps != nil {
306306 historyUC = a.deps.HistoryUC
307307 }
@@ -363,15 +363,19 @@ func (bw *browserWindow) showHistorySidebar(widthCfg ...window.SidebarWidthConfi
363363 // applySidebarWidthConfig extracts the config-backed sidebar width and
364364 // applies it via the MainWindow.SetSidebarWidth path. It is called during
365365 // initialization and can be reused if config is reloaded at runtime.
366+func historySidebarWidthConfig(widthPx int) window.SidebarWidthConfig {
367+ cfg := window.SidebarDefaultWidth()
368+ if widthPx > 0 {
369+ cfg.WidthPx = widthPx
370+ }
371+ return cfg
372+}
373+
366374 func (bw *browserWindow) applySidebarWidthConfig(a *App) {
367375 if bw == nil || bw.mainWindow == nil || a == nil || a.deps == nil || a.deps.Config == nil {
368376 return
369377 }
370- widthCfg := window.SidebarDefaultWidth()
371- if w := a.deps.Config.SidebarWidth; w > 0 {
372- widthCfg.WidthPx = w
373- }
374- bw.mainWindow.SetSidebarWidth(widthCfg)
378+ bw.mainWindow.SetSidebarWidth(historySidebarWidthConfig(a.deps.Config.SidebarWidth))
375379 }
376380
377381 // hideHistorySidebar hides the sidebar. Callers should also restore focus
@@ -301,7 +301,7 @@ func (bw *browserWindow) initHistorySidebar(ctx context.Context, a *App) {
301 // buildHistorySidebarConfig constructs the HistorySidebarConfig for the given301 // buildHistorySidebarConfig constructs the HistorySidebarConfig for the given
302 // browser window. Extracted from initHistorySidebar for testability.302 // browser window. Extracted from initHistorySidebar for testability.
303 func (a *App) buildHistorySidebarConfig(ctx context.Context, bw *browserWindow) component.HistorySidebarConfig {303 func (a *App) buildHistorySidebarConfig(ctx context.Context, bw *browserWindow) component.HistorySidebarConfig {
304- var historyUC *usecase.SearchHistoryUseCase304+ var historyUC port.HomepageHistory
305 if a.deps != nil {305 if a.deps != nil {
306 historyUC = a.deps.HistoryUC306 historyUC = a.deps.HistoryUC
307 }307 }
@@ -363,15 +363,19 @@ func (bw *browserWindow) showHistorySidebar(widthCfg ...window.SidebarWidthConfi
363 // applySidebarWidthConfig extracts the config-backed sidebar width and363 // applySidebarWidthConfig extracts the config-backed sidebar width and
364 // applies it via the MainWindow.SetSidebarWidth path. It is called during364 // applies it via the MainWindow.SetSidebarWidth path. It is called during
365 // initialization and can be reused if config is reloaded at runtime.365 // initialization and can be reused if config is reloaded at runtime.
366+func historySidebarWidthConfig(widthPx int) window.SidebarWidthConfig {
367+ cfg := window.SidebarDefaultWidth()
368+ if widthPx > 0 {
369+ cfg.WidthPx = widthPx
370+ }
371+ return cfg
372+}
373+
366 func (bw *browserWindow) applySidebarWidthConfig(a *App) {374 func (bw *browserWindow) applySidebarWidthConfig(a *App) {
367 if bw == nil || bw.mainWindow == nil || a == nil || a.deps == nil || a.deps.Config == nil {375 if bw == nil || bw.mainWindow == nil || a == nil || a.deps == nil || a.deps.Config == nil {
368 return376 return
369 }377 }
370- widthCfg := window.SidebarDefaultWidth()378+ bw.mainWindow.SetSidebarWidth(historySidebarWidthConfig(a.deps.Config.SidebarWidth))
371- if w := a.deps.Config.SidebarWidth; w > 0 {
372- widthCfg.WidthPx = w
373- }
374- bw.mainWindow.SetSidebarWidth(widthCfg)
375 }379 }
376 380
377 // hideHistorySidebar hides the sidebar. Callers should also restore focus381 // hideHistorySidebar hides the sidebar. Callers should also restore focus
M internal/ui/browser_window_test.go +5 -37
@@ -1774,50 +1774,18 @@ func TestApp_HideAndRestoreFocusForBrowserWindow_NilBWIsSafe(t *testing.T) {
17741774 }
17751775
17761776 // =============================================================================
1777-// applySidebarWidthConfig tests
1777+// Sidebar width config tests
17781778 // =============================================================================
17791779
1780-// TestBrowserWindow_ApplySidebarWidthConfig_ConfigValue verifies that
1781-// applySidebarWidthConfig reads the SidebarWidth from deps.Config and
1782-// passes it to SetSidebarWidth as the width config, overriding the default.
1783-func TestBrowserWindow_ApplySidebarWidthConfig_ConfigValue(t *testing.T) {
1784- mw := &window.MainWindow{}
1785- bw := &browserWindow{mainWindow: mw}
1786-
1787- app := &App{
1788- deps: &Dependencies{
1789- Config: &config.Config{
1790- SidebarWidth: 350,
1791- },
1792- },
1793- }
1794-
1795- bw.applySidebarWidthConfig(app)
1796-
1797- // SetSidebarWidth should have been called with WidthPx = 350.
1798- cfg := mw.LastSidebarWidthCfg()
1780+func TestHistorySidebarWidthConfig_ConfigValue(t *testing.T) {
1781+ cfg := historySidebarWidthConfig(350)
17991782 assert.Equal(t, 350, cfg.WidthPx, "should apply config-backed width of 350px")
18001783 assert.Equal(t, 280, cfg.MinPx, "should keep default min clamp")
18011784 assert.Equal(t, 380, cfg.MaxPx, "should keep default max clamp")
18021785 }
18031786
1804-// TestBrowserWindow_ApplySidebarWidthConfig_DefaultValue verifies that when
1805-// SidebarWidth is 0 (unset), applySidebarWidthConfig passes the default 320px.
1806-func TestBrowserWindow_ApplySidebarWidthConfig_DefaultValue(t *testing.T) {
1807- mw := &window.MainWindow{}
1808- bw := &browserWindow{mainWindow: mw}
1809-
1810- app := &App{
1811- deps: &Dependencies{
1812- Config: &config.Config{
1813- SidebarWidth: 0,
1814- },
1815- },
1816- }
1817-
1818- bw.applySidebarWidthConfig(app)
1819-
1820- cfg := mw.LastSidebarWidthCfg()
1787+func TestHistorySidebarWidthConfig_DefaultValue(t *testing.T) {
1788+ cfg := historySidebarWidthConfig(0)
18211789 assert.Equal(t, 320, cfg.WidthPx, "should use default width of 320px when config is 0")
18221790 assert.Equal(t, 280, cfg.MinPx, "should keep default min clamp")
18231791 assert.Equal(t, 380, cfg.MaxPx, "should keep default max clamp")
@@ -1774,50 +1774,18 @@ func TestApp_HideAndRestoreFocusForBrowserWindow_NilBWIsSafe(t *testing.T) {
1774 }1774 }
1775 1775
1776 // =============================================================================1776 // =============================================================================
1777-// applySidebarWidthConfig tests1777+// Sidebar width config tests
1778 // =============================================================================1778 // =============================================================================
1779 1779
1780-// TestBrowserWindow_ApplySidebarWidthConfig_ConfigValue verifies that1780+func TestHistorySidebarWidthConfig_ConfigValue(t *testing.T) {
1781-// applySidebarWidthConfig reads the SidebarWidth from deps.Config and1781+ cfg := historySidebarWidthConfig(350)
1782-// passes it to SetSidebarWidth as the width config, overriding the default.
1783-func TestBrowserWindow_ApplySidebarWidthConfig_ConfigValue(t *testing.T) {
1784- mw := &window.MainWindow{}
1785- bw := &browserWindow{mainWindow: mw}
1786-
1787- app := &App{
1788- deps: &Dependencies{
1789- Config: &config.Config{
1790- SidebarWidth: 350,
1791- },
1792- },
1793- }
1794-
1795- bw.applySidebarWidthConfig(app)
1796-
1797- // SetSidebarWidth should have been called with WidthPx = 350.
1798- cfg := mw.LastSidebarWidthCfg()
1799 assert.Equal(t, 350, cfg.WidthPx, "should apply config-backed width of 350px")1782 assert.Equal(t, 350, cfg.WidthPx, "should apply config-backed width of 350px")
1800 assert.Equal(t, 280, cfg.MinPx, "should keep default min clamp")1783 assert.Equal(t, 280, cfg.MinPx, "should keep default min clamp")
1801 assert.Equal(t, 380, cfg.MaxPx, "should keep default max clamp")1784 assert.Equal(t, 380, cfg.MaxPx, "should keep default max clamp")
1802 }1785 }
1803 1786
1804-// TestBrowserWindow_ApplySidebarWidthConfig_DefaultValue verifies that when1787+func TestHistorySidebarWidthConfig_DefaultValue(t *testing.T) {
1805-// SidebarWidth is 0 (unset), applySidebarWidthConfig passes the default 320px.1788+ cfg := historySidebarWidthConfig(0)
1806-func TestBrowserWindow_ApplySidebarWidthConfig_DefaultValue(t *testing.T) {
1807- mw := &window.MainWindow{}
1808- bw := &browserWindow{mainWindow: mw}
1809-
1810- app := &App{
1811- deps: &Dependencies{
1812- Config: &config.Config{
1813- SidebarWidth: 0,
1814- },
1815- },
1816- }
1817-
1818- bw.applySidebarWidthConfig(app)
1819-
1820- cfg := mw.LastSidebarWidthCfg()
1821 assert.Equal(t, 320, cfg.WidthPx, "should use default width of 320px when config is 0")1789 assert.Equal(t, 320, cfg.WidthPx, "should use default width of 320px when config is 0")
1822 assert.Equal(t, 280, cfg.MinPx, "should keep default min clamp")1790 assert.Equal(t, 280, cfg.MinPx, "should keep default min clamp")
1823 assert.Equal(t, 380, cfg.MaxPx, "should keep default max clamp")1791 assert.Equal(t, 380, cfg.MaxPx, "should keep default max clamp")
M internal/ui/component/history_sidebar.go +5 -4
@@ -10,7 +10,8 @@ import (
1010 "github.com/bnema/puregotk/v4/gtk"
1111 "github.com/bnema/puregotk/v4/pango"
1212
13- "github.com/bnema/dumber/internal/application/usecase"
13+ "github.com/bnema/dumber/internal/application/dto"
14+ "github.com/bnema/dumber/internal/application/port"
1415 "github.com/bnema/dumber/internal/domain/entity"
1516 "github.com/bnema/dumber/internal/logging"
1617 "github.com/bnema/dumber/internal/ui/layout"
@@ -53,7 +54,7 @@ type HistorySidebar struct {
5354 listBox *gtk.ListBox
5455
5556 // Dependencies
56- historyUC *usecase.SearchHistoryUseCase
57+ historyUC port.HomepageHistory
5758 onURL func(ctx context.Context, url string)
5859 onOpenInNewPane func(ctx context.Context, url string) error
5960 onNavigateKeepOpen func(ctx context.Context, url string)
@@ -102,7 +103,7 @@ type HistorySidebar struct {
102103 // HistorySidebarConfig holds configuration for creating a HistorySidebar.
103104 type HistorySidebarConfig struct {
104105 // HistoryUC provides history query and delete operations.
105- HistoryUC *usecase.SearchHistoryUseCase
106+ HistoryUC port.HomepageHistory
106107
107108 // OnNavigate is called when the user activates a history entry.
108109 // The default Enter / click behavior keeps the sidebar open after navigating.
@@ -765,7 +766,7 @@ func (hs *HistorySidebar) doFTSearch(query string, gen uint64) {
765766 }
766767
767768 go func() {
768- out, err := uc.Search(hs.ctx, usecase.SearchInput{Query: query, Limit: sidebarSearchLimit})
769+ out, err := uc.Search(hs.ctx, dto.HistorySearchInput{Query: query, Limit: sidebarSearchLimit})
769770 var entries []*entity.HistoryEntry
770771 if out != nil {
771772 entries = make([]*entity.HistoryEntry, len(out.Matches))
@@ -10,7 +10,8 @@ import (
10 "github.com/bnema/puregotk/v4/gtk"10 "github.com/bnema/puregotk/v4/gtk"
11 "github.com/bnema/puregotk/v4/pango"11 "github.com/bnema/puregotk/v4/pango"
12 12
13- "github.com/bnema/dumber/internal/application/usecase"13+ "github.com/bnema/dumber/internal/application/dto"
14+ "github.com/bnema/dumber/internal/application/port"
14 "github.com/bnema/dumber/internal/domain/entity"15 "github.com/bnema/dumber/internal/domain/entity"
15 "github.com/bnema/dumber/internal/logging"16 "github.com/bnema/dumber/internal/logging"
16 "github.com/bnema/dumber/internal/ui/layout"17 "github.com/bnema/dumber/internal/ui/layout"
@@ -53,7 +54,7 @@ type HistorySidebar struct {
53 listBox *gtk.ListBox54 listBox *gtk.ListBox
54 55
55 // Dependencies56 // Dependencies
56- historyUC *usecase.SearchHistoryUseCase57+ historyUC port.HomepageHistory
57 onURL func(ctx context.Context, url string)58 onURL func(ctx context.Context, url string)
58 onOpenInNewPane func(ctx context.Context, url string) error59 onOpenInNewPane func(ctx context.Context, url string) error
59 onNavigateKeepOpen func(ctx context.Context, url string)60 onNavigateKeepOpen func(ctx context.Context, url string)
@@ -102,7 +103,7 @@ type HistorySidebar struct {
102 // HistorySidebarConfig holds configuration for creating a HistorySidebar.103 // HistorySidebarConfig holds configuration for creating a HistorySidebar.
103 type HistorySidebarConfig struct {104 type HistorySidebarConfig struct {
104 // HistoryUC provides history query and delete operations.105 // HistoryUC provides history query and delete operations.
105- HistoryUC *usecase.SearchHistoryUseCase106+ HistoryUC port.HomepageHistory
106 107
107 // OnNavigate is called when the user activates a history entry.108 // OnNavigate is called when the user activates a history entry.
108 // The default Enter / click behavior keeps the sidebar open after navigating.109 // The default Enter / click behavior keeps the sidebar open after navigating.
@@ -765,7 +766,7 @@ func (hs *HistorySidebar) doFTSearch(query string, gen uint64) {
765 }766 }
766 767
767 go func() {768 go func() {
768- out, err := uc.Search(hs.ctx, usecase.SearchInput{Query: query, Limit: sidebarSearchLimit})769+ out, err := uc.Search(hs.ctx, dto.HistorySearchInput{Query: query, Limit: sidebarSearchLimit})
769 var entries []*entity.HistoryEntry770 var entries []*entity.HistoryEntry
770 if out != nil {771 if out != nil {
771 entries = make([]*entity.HistoryEntry, len(out.Matches))772 entries = make([]*entity.HistoryEntry, len(out.Matches))
M internal/ui/component/history_sidebar_search_test.go +43 -57
@@ -7,11 +7,12 @@ import (
77 "testing"
88 "time"
99
10- "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/application/dto"
11+ appportmocks "github.com/bnema/dumber/internal/application/port/mocks"
1112 "github.com/bnema/dumber/internal/domain/entity"
12- "github.com/bnema/dumber/internal/domain/repository"
1313 "github.com/rs/zerolog"
1414 "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
1516 "github.com/stretchr/testify/require"
1617 )
1718
@@ -119,51 +120,27 @@ func TestApplySearchResults_EmptyResultsApplied(t *testing.T) {
119120 }
120121
121122 // =============================================================================
122-// doFTSearch seam: controllable HistoryUC with fake repo
123+// doFTSearch seam: controllable history port mock
123124 // =============================================================================
124125
125-// fakeHistoryRepo implements repository.HistoryRepository minimally for
126-// sidebar search tests. Only Search and GetRecent are required; unused
127-// methods panic.
128-type fakeHistoryRepo struct {
129- repository.HistoryRepository
130- searchFn func(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error)
131- getRecentFn func(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
132-}
133-
134-func (f *fakeHistoryRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
135- if f.searchFn != nil {
136- return f.searchFn(ctx, query, limit)
137- }
138- return []entity.HistoryMatch{}, nil
139-}
140-
141-func (f *fakeHistoryRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
142- if f.getRecentFn != nil {
143- return f.getRecentFn(ctx, limit, offset)
144- }
145- // Permanently block if no handler set — safe for tests that want to
146- // hold a fetch in flight while the test controls timing.
147- <-make(chan struct{})
148- return nil, nil
126+func newMockHomepageHistory(t *testing.T) *appportmocks.MockHomepageHistory {
127+ return appportmocks.NewMockHomepageHistory(t)
149128 }
150129
151130 func TestDoFTSearch_WithFakeUC_StaleGenerationDropsResults(t *testing.T) {
152131 searchCalled := make(chan struct{}, 1)
153132
154- repo := &fakeHistoryRepo{
155- searchFn: func(_ context.Context, query string, _ int) ([]entity.HistoryMatch, error) {
156- searchCalled <- struct{}{}
157- return []entity.HistoryMatch{
158- {Entry: &entity.HistoryEntry{ID: 1, URL: "https://result.com", Title: "Result", LastVisited: time.Now()}},
159- }, nil
160- },
161- }
162- fakeUC := usecase.NewSearchHistoryUseCase(repo)
133+ history := newMockHomepageHistory(t)
134+ history.EXPECT().Search(mock.Anything, dto.HistorySearchInput{Query: "test", Limit: sidebarSearchLimit}).RunAndReturn(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
135+ searchCalled <- struct{}{}
136+ return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{
137+ {Entry: &entity.HistoryEntry{ID: 1, URL: "https://result.com", Title: "Result", LastVisited: time.Now()}},
138+ }}, nil
139+ })
163140
164141 hs := newTestSidebarSearchHarness()
165142 hs.ctx = context.Background()
166- hs.historyUC = fakeUC
143+ hs.historyUC = history
167144 hs.searchGen = 1
168145
169146 // Start search with gen=1
@@ -192,19 +169,17 @@ func TestDoFTSearch_WithFakeUC_StaleGenerationDropsResults(t *testing.T) {
192169 func TestDoFTSearch_WithFakeUC_CurrentGenApplied(t *testing.T) {
193170 searchCalled := make(chan struct{}, 1)
194171
195- repo := &fakeHistoryRepo{
196- searchFn: func(_ context.Context, query string, _ int) ([]entity.HistoryMatch, error) {
197- searchCalled <- struct{}{}
198- return []entity.HistoryMatch{
199- {Entry: &entity.HistoryEntry{ID: 1, URL: "https://live.com", Title: "Live", LastVisited: time.Now()}},
200- }, nil
201- },
202- }
203- fakeUC := usecase.NewSearchHistoryUseCase(repo)
172+ history := newMockHomepageHistory(t)
173+ history.EXPECT().Search(mock.Anything, dto.HistorySearchInput{Query: "live", Limit: sidebarSearchLimit}).RunAndReturn(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
174+ searchCalled <- struct{}{}
175+ return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{
176+ {Entry: &entity.HistoryEntry{ID: 1, URL: "https://live.com", Title: "Live", LastVisited: time.Now()}},
177+ }}, nil
178+ })
204179
205180 hs := newTestSidebarSearchHarness()
206181 hs.ctx = context.Background()
207- hs.historyUC = fakeUC
182+ hs.historyUC = history
208183 hs.searchGen = 1
209184
210185 // Start the search and wait for the use case to be invoked.
@@ -236,9 +211,16 @@ func TestDoFTSearch_WithFakeUC_CurrentGenApplied(t *testing.T) {
236211 // TestHistorySidebar_ReloadPreservesQuery verifies that Reload preserves the
237212 // active query while resetting browse/search state before the refreshed load.
238213 func TestHistorySidebar_ReloadPreservesQuery(t *testing.T) {
214+ searchCalled := make(chan struct{}, 1)
215+ history := newMockHomepageHistory(t)
216+ history.EXPECT().Search(mock.Anything, dto.HistorySearchInput{Query: "preserved", Limit: sidebarSearchLimit}).RunAndReturn(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
217+ searchCalled <- struct{}{}
218+ return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{}}, nil
219+ })
220+
239221 hs := newTestSidebarSearchHarness()
240222 hs.currentQuery = "preserved"
241- hs.historyUC = usecase.NewSearchHistoryUseCase(&fakeHistoryRepo{})
223+ hs.historyUC = history
242224 hs.ctx = context.Background()
243225
244226 hs.mu.Lock()
@@ -254,6 +236,12 @@ func TestHistorySidebar_ReloadPreservesQuery(t *testing.T) {
254236
255237 hs.Reload()
256238
239+ select {
240+ case <-searchCalled:
241+ case <-time.After(time.Second):
242+ t.Fatal("timed out waiting for reload search to be invoked")
243+ }
244+
257245 hs.mu.RLock()
258246 assert.Equal(t, "preserved", hs.currentQuery, "query must be preserved after Reload")
259247 assert.False(t, hs.loadDone, "loadDone must be reset")
@@ -278,18 +266,16 @@ func TestFetchPage_StaleGenerationDoesNotMutateLoadingState(t *testing.T) {
278266 getRecentCalled := make(chan struct{})
279267 proceed := make(chan struct{})
280268
281- repo := &fakeHistoryRepo{
282- getRecentFn: func(_ context.Context, _, _ int) ([]*entity.HistoryEntry, error) {
283- close(getRecentCalled)
284- <-proceed
285- return []*entity.HistoryEntry{}, nil
286- },
287- }
288- fakeUC := usecase.NewSearchHistoryUseCase(repo)
269+ history := newMockHomepageHistory(t)
270+ history.EXPECT().GetRecent(mock.Anything, sidebarPageSize, 0).RunAndReturn(func(context.Context, int, int) ([]*entity.HistoryEntry, error) {
271+ close(getRecentCalled)
272+ <-proceed
273+ return []*entity.HistoryEntry{}, nil
274+ })
289275
290276 hs := newTestSidebarSearchHarness()
291277 hs.ctx = context.Background()
292- hs.historyUC = fakeUC
278+ hs.historyUC = history
293279
294280 // Simulate: gen 1 started, then gen 2 started (e.g. by Reload/Show).
295281 // The second call incremented loadGen and set isLoading/loadStarted.
@@ -7,11 +7,12 @@ import (
7 "testing"7 "testing"
8 "time"8 "time"
9 9
10- "github.com/bnema/dumber/internal/application/usecase"10+ "github.com/bnema/dumber/internal/application/dto"
11+ appportmocks "github.com/bnema/dumber/internal/application/port/mocks"
11 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
12- "github.com/bnema/dumber/internal/domain/repository"
13 "github.com/rs/zerolog"13 "github.com/rs/zerolog"
14 "github.com/stretchr/testify/assert"14 "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
15 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
16 )17 )
17 18
@@ -119,51 +120,27 @@ func TestApplySearchResults_EmptyResultsApplied(t *testing.T) {
119 }120 }
120 121
121 // =============================================================================122 // =============================================================================
122-// doFTSearch seam: controllable HistoryUC with fake repo123+// doFTSearch seam: controllable history port mock
123 // =============================================================================124 // =============================================================================
124 125
125-// fakeHistoryRepo implements repository.HistoryRepository minimally for126+func newMockHomepageHistory(t *testing.T) *appportmocks.MockHomepageHistory {
126-// sidebar search tests. Only Search and GetRecent are required; unused127+ return appportmocks.NewMockHomepageHistory(t)
127-// methods panic.
128-type fakeHistoryRepo struct {
129- repository.HistoryRepository
130- searchFn func(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error)
131- getRecentFn func(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
132-}
133-
134-func (f *fakeHistoryRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
135- if f.searchFn != nil {
136- return f.searchFn(ctx, query, limit)
137- }
138- return []entity.HistoryMatch{}, nil
139-}
140-
141-func (f *fakeHistoryRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
142- if f.getRecentFn != nil {
143- return f.getRecentFn(ctx, limit, offset)
144- }
145- // Permanently block if no handler set — safe for tests that want to
146- // hold a fetch in flight while the test controls timing.
147- <-make(chan struct{})
148- return nil, nil
149 }128 }
150 129
151 func TestDoFTSearch_WithFakeUC_StaleGenerationDropsResults(t *testing.T) {130 func TestDoFTSearch_WithFakeUC_StaleGenerationDropsResults(t *testing.T) {
152 searchCalled := make(chan struct{}, 1)131 searchCalled := make(chan struct{}, 1)
153 132
154- repo := &fakeHistoryRepo{133+ history := newMockHomepageHistory(t)
155- searchFn: func(_ context.Context, query string, _ int) ([]entity.HistoryMatch, error) {134+ history.EXPECT().Search(mock.Anything, dto.HistorySearchInput{Query: "test", Limit: sidebarSearchLimit}).RunAndReturn(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
156- searchCalled <- struct{}{}135+ searchCalled <- struct{}{}
157- return []entity.HistoryMatch{136+ return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{
158- {Entry: &entity.HistoryEntry{ID: 1, URL: "https://result.com", Title: "Result", LastVisited: time.Now()}},137+ {Entry: &entity.HistoryEntry{ID: 1, URL: "https://result.com", Title: "Result", LastVisited: time.Now()}},
159- }, nil138+ }}, nil
160- },139+ })
161- }
162- fakeUC := usecase.NewSearchHistoryUseCase(repo)
163 140
164 hs := newTestSidebarSearchHarness()141 hs := newTestSidebarSearchHarness()
165 hs.ctx = context.Background()142 hs.ctx = context.Background()
166- hs.historyUC = fakeUC143+ hs.historyUC = history
167 hs.searchGen = 1144 hs.searchGen = 1
168 145
169 // Start search with gen=1146 // Start search with gen=1
@@ -192,19 +169,17 @@ func TestDoFTSearch_WithFakeUC_StaleGenerationDropsResults(t *testing.T) {
192 func TestDoFTSearch_WithFakeUC_CurrentGenApplied(t *testing.T) {169 func TestDoFTSearch_WithFakeUC_CurrentGenApplied(t *testing.T) {
193 searchCalled := make(chan struct{}, 1)170 searchCalled := make(chan struct{}, 1)
194 171
195- repo := &fakeHistoryRepo{172+ history := newMockHomepageHistory(t)
196- searchFn: func(_ context.Context, query string, _ int) ([]entity.HistoryMatch, error) {173+ history.EXPECT().Search(mock.Anything, dto.HistorySearchInput{Query: "live", Limit: sidebarSearchLimit}).RunAndReturn(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
197- searchCalled <- struct{}{}174+ searchCalled <- struct{}{}
198- return []entity.HistoryMatch{175+ return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{
199- {Entry: &entity.HistoryEntry{ID: 1, URL: "https://live.com", Title: "Live", LastVisited: time.Now()}},176+ {Entry: &entity.HistoryEntry{ID: 1, URL: "https://live.com", Title: "Live", LastVisited: time.Now()}},
200- }, nil177+ }}, nil
201- },178+ })
202- }
203- fakeUC := usecase.NewSearchHistoryUseCase(repo)
204 179
205 hs := newTestSidebarSearchHarness()180 hs := newTestSidebarSearchHarness()
206 hs.ctx = context.Background()181 hs.ctx = context.Background()
207- hs.historyUC = fakeUC182+ hs.historyUC = history
208 hs.searchGen = 1183 hs.searchGen = 1
209 184
210 // Start the search and wait for the use case to be invoked.185 // Start the search and wait for the use case to be invoked.
@@ -236,9 +211,16 @@ func TestDoFTSearch_WithFakeUC_CurrentGenApplied(t *testing.T) {
236 // TestHistorySidebar_ReloadPreservesQuery verifies that Reload preserves the211 // TestHistorySidebar_ReloadPreservesQuery verifies that Reload preserves the
237 // active query while resetting browse/search state before the refreshed load.212 // active query while resetting browse/search state before the refreshed load.
238 func TestHistorySidebar_ReloadPreservesQuery(t *testing.T) {213 func TestHistorySidebar_ReloadPreservesQuery(t *testing.T) {
214+ searchCalled := make(chan struct{}, 1)
215+ history := newMockHomepageHistory(t)
216+ history.EXPECT().Search(mock.Anything, dto.HistorySearchInput{Query: "preserved", Limit: sidebarSearchLimit}).RunAndReturn(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
217+ searchCalled <- struct{}{}
218+ return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{}}, nil
219+ })
220+
239 hs := newTestSidebarSearchHarness()221 hs := newTestSidebarSearchHarness()
240 hs.currentQuery = "preserved"222 hs.currentQuery = "preserved"
241- hs.historyUC = usecase.NewSearchHistoryUseCase(&fakeHistoryRepo{})223+ hs.historyUC = history
242 hs.ctx = context.Background()224 hs.ctx = context.Background()
243 225
244 hs.mu.Lock()226 hs.mu.Lock()
@@ -254,6 +236,12 @@ func TestHistorySidebar_ReloadPreservesQuery(t *testing.T) {
254 236
255 hs.Reload()237 hs.Reload()
256 238
239+ select {
240+ case <-searchCalled:
241+ case <-time.After(time.Second):
242+ t.Fatal("timed out waiting for reload search to be invoked")
243+ }
244+
257 hs.mu.RLock()245 hs.mu.RLock()
258 assert.Equal(t, "preserved", hs.currentQuery, "query must be preserved after Reload")246 assert.Equal(t, "preserved", hs.currentQuery, "query must be preserved after Reload")
259 assert.False(t, hs.loadDone, "loadDone must be reset")247 assert.False(t, hs.loadDone, "loadDone must be reset")
@@ -278,18 +266,16 @@ func TestFetchPage_StaleGenerationDoesNotMutateLoadingState(t *testing.T) {
278 getRecentCalled := make(chan struct{})266 getRecentCalled := make(chan struct{})
279 proceed := make(chan struct{})267 proceed := make(chan struct{})
280 268
281- repo := &fakeHistoryRepo{269+ history := newMockHomepageHistory(t)
282- getRecentFn: func(_ context.Context, _, _ int) ([]*entity.HistoryEntry, error) {270+ history.EXPECT().GetRecent(mock.Anything, sidebarPageSize, 0).RunAndReturn(func(context.Context, int, int) ([]*entity.HistoryEntry, error) {
283- close(getRecentCalled)271+ close(getRecentCalled)
284- <-proceed272+ <-proceed
285- return []*entity.HistoryEntry{}, nil273+ return []*entity.HistoryEntry{}, nil
286- },274+ })
287- }
288- fakeUC := usecase.NewSearchHistoryUseCase(repo)
289 275
290 hs := newTestSidebarSearchHarness()276 hs := newTestSidebarSearchHarness()
291 hs.ctx = context.Background()277 hs.ctx = context.Background()
292- hs.historyUC = fakeUC278+ hs.historyUC = history
293 279
294 // Simulate: gen 1 started, then gen 2 started (e.g. by Reload/Show).280 // Simulate: gen 1 started, then gen 2 started (e.g. by Reload/Show).
295 // The second call incremented loadGen and set isLoading/loadStarted.281 // The second call incremented loadGen and set isLoading/loadStarted.
M internal/ui/window/main_window.go +2 -10
@@ -38,9 +38,8 @@ type MainWindow struct {
3838 sidebarBox *gtk.Box // Vertical container for sidebar (hidden by default)
3939 currentContent *gtk.Widget // Track current content for removal on tab switch
4040
41- tabBarPosition string // "top" or "bottom"
42- lastSidebarWidthCfg SidebarWidthConfig // last config passed to SetSidebarWidth (zero-value = unset; test seam)
43- logger zerolog.Logger
41+ tabBarPosition string // "top" or "bottom"
42+ logger zerolog.Logger
4443 }
4544
4645 // New creates a new main browser window.
@@ -228,7 +227,6 @@ func (mw *MainWindow) SidebarBox() *gtk.Box {
228227 // config's [MinPx, MaxPx] bounds. Using the zero-value SidebarWidthConfig{}
229228 // sets sensible defaults (320px clamped to [280, 380]).
230229 func (mw *MainWindow) SetSidebarWidth(cfg SidebarWidthConfig) {
231- mw.lastSidebarWidthCfg = cfg // record for testability
232230 if mw.sidebarBox == nil {
233231 return
234232 }
@@ -269,12 +267,6 @@ func (mw *MainWindow) IsSidebarVisible() bool {
269267 return mw.sidebarBox.GetVisible()
270268 }
271269
272-// LastSidebarWidthCfg returns the last SidebarWidthConfig passed to
273-// SetSidebarWidth. Returns the zero value if never called (test seam).
274-func (mw *MainWindow) LastSidebarWidthCfg() SidebarWidthConfig {
275- return mw.lastSidebarWidthCfg
276-}
277-
278270 // SetSidebarWidget replaces the current sidebar content widget.
279271 func (mw *MainWindow) SetSidebarWidget(widget *gtk.Widget) {
280272 if mw.sidebarBox == nil {
@@ -38,9 +38,8 @@ type MainWindow struct {
38 sidebarBox *gtk.Box // Vertical container for sidebar (hidden by default)38 sidebarBox *gtk.Box // Vertical container for sidebar (hidden by default)
39 currentContent *gtk.Widget // Track current content for removal on tab switch39 currentContent *gtk.Widget // Track current content for removal on tab switch
40 40
41- tabBarPosition string // "top" or "bottom"41+ tabBarPosition string // "top" or "bottom"
42- lastSidebarWidthCfg SidebarWidthConfig // last config passed to SetSidebarWidth (zero-value = unset; test seam)42+ logger zerolog.Logger
43- logger zerolog.Logger
44 }43 }
45 44
46 // New creates a new main browser window.45 // New creates a new main browser window.
@@ -228,7 +227,6 @@ func (mw *MainWindow) SidebarBox() *gtk.Box {
228 // config's [MinPx, MaxPx] bounds. Using the zero-value SidebarWidthConfig{}227 // config's [MinPx, MaxPx] bounds. Using the zero-value SidebarWidthConfig{}
229 // sets sensible defaults (320px clamped to [280, 380]).228 // sets sensible defaults (320px clamped to [280, 380]).
230 func (mw *MainWindow) SetSidebarWidth(cfg SidebarWidthConfig) {229 func (mw *MainWindow) SetSidebarWidth(cfg SidebarWidthConfig) {
231- mw.lastSidebarWidthCfg = cfg // record for testability
232 if mw.sidebarBox == nil {230 if mw.sidebarBox == nil {
233 return231 return
234 }232 }
@@ -269,12 +267,6 @@ func (mw *MainWindow) IsSidebarVisible() bool {
269 return mw.sidebarBox.GetVisible()267 return mw.sidebarBox.GetVisible()
270 }268 }
271 269
272-// LastSidebarWidthCfg returns the last SidebarWidthConfig passed to
273-// SetSidebarWidth. Returns the zero value if never called (test seam).
274-func (mw *MainWindow) LastSidebarWidthCfg() SidebarWidthConfig {
275- return mw.lastSidebarWidthCfg
276-}
277-
278 // SetSidebarWidget replaces the current sidebar content widget.270 // SetSidebarWidget replaces the current sidebar content widget.
279 func (mw *MainWindow) SetSidebarWidget(widget *gtk.Widget) {271 func (mw *MainWindow) SetSidebarWidget(widget *gtk.Widget) {
280 if mw.sidebarBox == nil {272 if mw.sidebarBox == nil {

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