detached 3ef9418

fix(gui): add missing @lucide/svelte dependency to fix runtime error

commit
3ef94184479a5a4930dda0af542e3f93bf7a7037
author
bamen · · 2025-09-15 14:03 UTC
parent
3aefd86
signature
unsigned
9 files · +228 -35
M gui/package-lock.json +12 -18 generated file: collapsed
M gui/package.json +3 -0
@@ -29,5 +29,8 @@
2929 "typescript": "^5.3.0",
3030 "typescript-eslint": "^8.43.0",
3131 "vite": "^5.0.0"
32+ },
33+ "dependencies": {
34+ "@lucide/svelte": "^0.544.0"
3235 }
3336 }
@@ -29,5 +29,8 @@
29 "typescript": "^5.3.0",29 "typescript": "^5.3.0",
30 "typescript-eslint": "^8.43.0",30 "typescript-eslint": "^8.43.0",
31 "vite": "^5.0.0"31 "vite": "^5.0.0"
32+ },
33+ "dependencies": {
34+ "@lucide/svelte": "^0.544.0"
32 }35 }
33 }36 }
M gui/src/pages/Homepage.svelte +101 -12
@@ -33,7 +33,13 @@
3333 let topVisitedLoading = $state(true);
3434 let shortcutsLoading = $state(true);
3535 let statsLoading = $state(true);
36- let deletingIds = $state<Set<number>>(new Set());
36+ let deletingIds = $state<number[]>([]);
37+ let historyOffset = $state(0);
38+ let hasMoreHistory = $state(true);
39+ let loadingMoreHistory = $state(false);
40+
41+ // Function for checking if an item is being deleted
42+ const isDeleting = (id: number) => deletingIds.includes(id);
3743
3844 // Initialize shortcuts (these are browser shortcuts, not dynamic from API)
3945 const initializeShortcuts = () => {
@@ -109,18 +115,50 @@
109115 if (response.ok) {
110116 const data = await response.json();
111117 historyItems = Array.isArray(data) ? data : [];
118+ historyOffset = historyItems.length;
119+ hasMoreHistory = data.length === 20; // If we got full batch, there might be more
112120 } else {
113121 console.warn('Failed to fetch history:', response.status);
114122 historyItems = [];
123+ hasMoreHistory = false;
115124 }
116125 } catch (error) {
117126 console.error('Error fetching history:', error);
118127 historyItems = [];
128+ hasMoreHistory = false;
119129 } finally {
120130 historyLoading = false;
121131 }
122132 };
123133
134+ // Load more history items for infinite scroll
135+ const loadMoreHistory = async () => {
136+ if (loadingMoreHistory || !hasMoreHistory) return;
137+
138+ loadingMoreHistory = true;
139+ try {
140+ const response = await fetch(`dumb://homepage/api/history/recent?limit=20&offset=${historyOffset}`);
141+ if (response.ok) {
142+ const data = await response.json();
143+ if (Array.isArray(data) && data.length > 0) {
144+ historyItems = [...historyItems, ...data];
145+ historyOffset += data.length;
146+ hasMoreHistory = data.length === 20; // If we got full batch, there might be more
147+ } else {
148+ hasMoreHistory = false;
149+ }
150+ } else {
151+ console.warn('Failed to fetch more history:', response.status);
152+ hasMoreHistory = false;
153+ }
154+ } catch (error) {
155+ console.error('Error fetching more history:', error);
156+ hasMoreHistory = false;
157+ } finally {
158+ loadingMoreHistory = false;
159+ }
160+ };
161+
124162 // Navigate to a URL when history item is clicked
125163 const navigateTo = (url: string) => {
126164 window.location.href = url;
@@ -130,8 +168,10 @@
130168 const deleteHistoryEntry = async (id: number, event: Event) => {
131169 event.stopPropagation(); // Prevent navigation
132170
133- // Add to deleting set for animation
134- deletingIds = new Set([...deletingIds, id]);
171+ // Add to deleting array for animation
172+ if (!deletingIds.includes(id)) {
173+ deletingIds = [...deletingIds, id];
174+ }
135175
136176 try {
137177 const response = await fetch(`dumb://homepage/api/history/delete?id=${id}`);
@@ -139,23 +179,25 @@
139179 // Wait a bit for the fade animation to complete
140180 await new Promise(resolve => setTimeout(resolve, 200));
141181
142- // Refresh history after successful deletion
182+ // Reset pagination and refresh history after successful deletion
183+ historyOffset = 0;
184+ hasMoreHistory = true;
143185 await fetchHistory();
144186 await fetchStats();
145187 await fetchTopVisited();
146188 } else {
147189 console.error('Failed to delete history entry:', response.status);
148- // Remove from deleting set if failed
149- deletingIds = new Set([...deletingIds].filter(deletingId => deletingId !== id));
190+ // Remove from deleting array if failed
191+ deletingIds = deletingIds.filter(i => i !== id);
150192 }
151193 } catch (error) {
152194 console.error('Error deleting history entry:', error);
153- // Remove from deleting set if failed
154- deletingIds = new Set([...deletingIds].filter(deletingId => deletingId !== id));
195+ // Remove from deleting array if failed
196+ deletingIds = deletingIds.filter(i => i !== id);
155197 }
156198
157- // Remove from deleting set after refresh
158- deletingIds = new Set([...deletingIds].filter(deletingId => deletingId !== id));
199+ // Remove from deleting array after refresh
200+ deletingIds = deletingIds.filter(i => i !== id);
159201 };
160202
161203 // Get domain from URL
@@ -227,9 +269,36 @@
227269 }
228270 };
229271
272+ // Intersection Observer for infinite scroll
273+ let historyScrollSentinel = $state<HTMLElement | null>(null);
274+
275+ const setupInfiniteScroll = () => {
276+ if (!historyScrollSentinel) return;
277+
278+ const observer = new IntersectionObserver(
279+ (entries) => {
280+ const entry = entries[0];
281+ if (entry && entry.isIntersecting && hasMoreHistory && !loadingMoreHistory) {
282+ loadMoreHistory();
283+ }
284+ },
285+ {
286+ threshold: 0.1,
287+ rootMargin: '50px'
288+ }
289+ );
290+
291+ observer.observe(historyScrollSentinel);
292+
293+ return () => observer.disconnect();
294+ };
295+
230296 onMount(() => {
231297 initializeShortcuts();
232- fetchHistory();
298+ fetchHistory().then(() => {
299+ // Setup infinite scroll after initial history is loaded
300+ setTimeout(setupInfiniteScroll, 100);
301+ });
233302 fetchStats();
234303 fetchTopVisited();
235304 });
@@ -252,7 +321,7 @@
252321 {#each historyItems as item (item.url)}
253322 <div
254323 class="history-item"
255- class:deleting={deletingIds.has(item.id)}
324+ class:deleting={isDeleting(item.id)}
256325 role="button"
257326 tabindex="0"
258327 onclick={() => navigateTo(item.url)}
@@ -309,6 +378,14 @@
309378 </div>
310379 </div>
311380 {/each}
381+
382+ <!-- Infinite scroll sentinel and loading indicator -->
383+ {#if hasMoreHistory}
384+ <div bind:this={historyScrollSentinel} class="scroll-sentinel"></div>
385+ {#if loadingMoreHistory}
386+ <div class="loading-more">Loading more history...</div>
387+ {/if}
388+ {/if}
312389 {/if}
313390 </div>
314391 </div>
@@ -725,6 +802,18 @@
725802 color: #888;
726803 }
727804
805+ .scroll-sentinel {
806+ height: 1px;
807+ width: 100%;
808+ }
809+
810+ .loading-more {
811+ padding: 1rem;
812+ text-align: center;
813+ color: #888;
814+ font-size: 0.9rem;
815+ }
816+
728817 .empty-state {
729818 padding: 3rem 2rem;
730819 text-align: center;
@@ -33,7 +33,13 @@
33 let topVisitedLoading = $state(true);33 let topVisitedLoading = $state(true);
34 let shortcutsLoading = $state(true);34 let shortcutsLoading = $state(true);
35 let statsLoading = $state(true);35 let statsLoading = $state(true);
36- let deletingIds = $state<Set<number>>(new Set());36+ let deletingIds = $state<number[]>([]);
37+ let historyOffset = $state(0);
38+ let hasMoreHistory = $state(true);
39+ let loadingMoreHistory = $state(false);
40+
41+ // Function for checking if an item is being deleted
42+ const isDeleting = (id: number) => deletingIds.includes(id);
37 43
38 // Initialize shortcuts (these are browser shortcuts, not dynamic from API)44 // Initialize shortcuts (these are browser shortcuts, not dynamic from API)
39 const initializeShortcuts = () => {45 const initializeShortcuts = () => {
@@ -109,18 +115,50 @@
109 if (response.ok) {115 if (response.ok) {
110 const data = await response.json();116 const data = await response.json();
111 historyItems = Array.isArray(data) ? data : [];117 historyItems = Array.isArray(data) ? data : [];
118+ historyOffset = historyItems.length;
119+ hasMoreHistory = data.length === 20; // If we got full batch, there might be more
112 } else {120 } else {
113 console.warn('Failed to fetch history:', response.status);121 console.warn('Failed to fetch history:', response.status);
114 historyItems = [];122 historyItems = [];
123+ hasMoreHistory = false;
115 }124 }
116 } catch (error) {125 } catch (error) {
117 console.error('Error fetching history:', error);126 console.error('Error fetching history:', error);
118 historyItems = [];127 historyItems = [];
128+ hasMoreHistory = false;
119 } finally {129 } finally {
120 historyLoading = false;130 historyLoading = false;
121 }131 }
122 };132 };
123 133
134+ // Load more history items for infinite scroll
135+ const loadMoreHistory = async () => {
136+ if (loadingMoreHistory || !hasMoreHistory) return;
137+
138+ loadingMoreHistory = true;
139+ try {
140+ const response = await fetch(`dumb://homepage/api/history/recent?limit=20&offset=${historyOffset}`);
141+ if (response.ok) {
142+ const data = await response.json();
143+ if (Array.isArray(data) && data.length > 0) {
144+ historyItems = [...historyItems, ...data];
145+ historyOffset += data.length;
146+ hasMoreHistory = data.length === 20; // If we got full batch, there might be more
147+ } else {
148+ hasMoreHistory = false;
149+ }
150+ } else {
151+ console.warn('Failed to fetch more history:', response.status);
152+ hasMoreHistory = false;
153+ }
154+ } catch (error) {
155+ console.error('Error fetching more history:', error);
156+ hasMoreHistory = false;
157+ } finally {
158+ loadingMoreHistory = false;
159+ }
160+ };
161+
124 // Navigate to a URL when history item is clicked162 // Navigate to a URL when history item is clicked
125 const navigateTo = (url: string) => {163 const navigateTo = (url: string) => {
126 window.location.href = url;164 window.location.href = url;
@@ -130,8 +168,10 @@
130 const deleteHistoryEntry = async (id: number, event: Event) => {168 const deleteHistoryEntry = async (id: number, event: Event) => {
131 event.stopPropagation(); // Prevent navigation169 event.stopPropagation(); // Prevent navigation
132 170
133- // Add to deleting set for animation171+ // Add to deleting array for animation
134- deletingIds = new Set([...deletingIds, id]);172+ if (!deletingIds.includes(id)) {
173+ deletingIds = [...deletingIds, id];
174+ }
135 175
136 try {176 try {
137 const response = await fetch(`dumb://homepage/api/history/delete?id=${id}`);177 const response = await fetch(`dumb://homepage/api/history/delete?id=${id}`);
@@ -139,23 +179,25 @@
139 // Wait a bit for the fade animation to complete179 // Wait a bit for the fade animation to complete
140 await new Promise(resolve => setTimeout(resolve, 200));180 await new Promise(resolve => setTimeout(resolve, 200));
141 181
142- // Refresh history after successful deletion182+ // Reset pagination and refresh history after successful deletion
183+ historyOffset = 0;
184+ hasMoreHistory = true;
143 await fetchHistory();185 await fetchHistory();
144 await fetchStats();186 await fetchStats();
145 await fetchTopVisited();187 await fetchTopVisited();
146 } else {188 } else {
147 console.error('Failed to delete history entry:', response.status);189 console.error('Failed to delete history entry:', response.status);
148- // Remove from deleting set if failed190+ // Remove from deleting array if failed
149- deletingIds = new Set([...deletingIds].filter(deletingId => deletingId !== id));191+ deletingIds = deletingIds.filter(i => i !== id);
150 }192 }
151 } catch (error) {193 } catch (error) {
152 console.error('Error deleting history entry:', error);194 console.error('Error deleting history entry:', error);
153- // Remove from deleting set if failed195+ // Remove from deleting array if failed
154- deletingIds = new Set([...deletingIds].filter(deletingId => deletingId !== id));196+ deletingIds = deletingIds.filter(i => i !== id);
155 }197 }
156 198
157- // Remove from deleting set after refresh199+ // Remove from deleting array after refresh
158- deletingIds = new Set([...deletingIds].filter(deletingId => deletingId !== id));200+ deletingIds = deletingIds.filter(i => i !== id);
159 };201 };
160 202
161 // Get domain from URL203 // Get domain from URL
@@ -227,9 +269,36 @@
227 }269 }
228 };270 };
229 271
272+ // Intersection Observer for infinite scroll
273+ let historyScrollSentinel = $state<HTMLElement | null>(null);
274+
275+ const setupInfiniteScroll = () => {
276+ if (!historyScrollSentinel) return;
277+
278+ const observer = new IntersectionObserver(
279+ (entries) => {
280+ const entry = entries[0];
281+ if (entry && entry.isIntersecting && hasMoreHistory && !loadingMoreHistory) {
282+ loadMoreHistory();
283+ }
284+ },
285+ {
286+ threshold: 0.1,
287+ rootMargin: '50px'
288+ }
289+ );
290+
291+ observer.observe(historyScrollSentinel);
292+
293+ return () => observer.disconnect();
294+ };
295+
230 onMount(() => {296 onMount(() => {
231 initializeShortcuts();297 initializeShortcuts();
232- fetchHistory();298+ fetchHistory().then(() => {
299+ // Setup infinite scroll after initial history is loaded
300+ setTimeout(setupInfiniteScroll, 100);
301+ });
233 fetchStats();302 fetchStats();
234 fetchTopVisited();303 fetchTopVisited();
235 });304 });
@@ -252,7 +321,7 @@
252 {#each historyItems as item (item.url)}321 {#each historyItems as item (item.url)}
253 <div322 <div
254 class="history-item"323 class="history-item"
255- class:deleting={deletingIds.has(item.id)}324+ class:deleting={isDeleting(item.id)}
256 role="button"325 role="button"
257 tabindex="0"326 tabindex="0"
258 onclick={() => navigateTo(item.url)}327 onclick={() => navigateTo(item.url)}
@@ -309,6 +378,14 @@
309 </div>378 </div>
310 </div>379 </div>
311 {/each}380 {/each}
381+
382+ <!-- Infinite scroll sentinel and loading indicator -->
383+ {#if hasMoreHistory}
384+ <div bind:this={historyScrollSentinel} class="scroll-sentinel"></div>
385+ {#if loadingMoreHistory}
386+ <div class="loading-more">Loading more history...</div>
387+ {/if}
388+ {/if}
312 {/if}389 {/if}
313 </div>390 </div>
314 </div>391 </div>
@@ -725,6 +802,18 @@
725 color: #888;802 color: #888;
726 }803 }
727 804
805+ .scroll-sentinel {
806+ height: 1px;
807+ width: 100%;
808+ }
809+
810+ .loading-more {
811+ padding: 1rem;
812+ text-align: center;
813+ color: #888;
814+ font-size: 0.9rem;
815+ }
816+
728 .empty-state {817 .empty-state {
729 padding: 3rem 2rem;818 padding: 3rem 2rem;
730 text-align: center;819 text-align: center;
M internal/app/api/routes.go +34 -5
@@ -58,19 +58,48 @@ func (h *Handler) HandleConfig(cfg *config.Config) (string, []byte, bool) {
5858 // HandleHistoryRecent handles GET /history/recent endpoint
5959 func (h *Handler) HandleHistoryRecent(u *neturl.URL) (string, []byte, bool) {
6060 log.Printf("[api] GET /history/recent%s", u.RawQuery)
61- // Parse limit
61+ // Parse limit and offset
6262 q := u.Query()
63- limit := 50
63+ limit := 20
6464 if l := q.Get("limit"); l != "" {
6565 if n, err := strconv.Atoi(l); err == nil && n > 0 {
6666 limit = n
6767 }
6868 }
69+ offset := 0
70+ if o := q.Get("offset"); o != "" {
71+ if n, err := strconv.Atoi(o); err == nil && n >= 0 {
72+ offset = n
73+ }
74+ }
75+
6976 ctx := context.Background()
70- entries, err := h.browserService.GetRecentHistory(ctx, limit)
71- if err != nil {
72- return constants.ContentTypeJSON, []byte("[]"), true
77+ var entries []interface{}
78+
79+ // Use paginated version if offset is provided
80+ if offset > 0 || q.Has("offset") {
81+ histEntries, err := h.browserService.GetRecentHistoryWithOffset(ctx, limit, offset)
82+ if err != nil {
83+ return constants.ContentTypeJSON, []byte("[]"), true
84+ }
85+ // Convert to interface slice for JSON marshaling
86+ entries = make([]interface{}, len(histEntries))
87+ for i, entry := range histEntries {
88+ entries[i] = entry
89+ }
90+ } else {
91+ // Use original method for backward compatibility
92+ histEntries, err := h.browserService.GetRecentHistory(ctx, limit)
93+ if err != nil {
94+ return constants.ContentTypeJSON, []byte("[]"), true
95+ }
96+ // Convert to interface slice for JSON marshaling
97+ entries = make([]interface{}, len(histEntries))
98+ for i, entry := range histEntries {
99+ entries[i] = entry
100+ }
73101 }
102+
74103 b, _ := json.Marshal(entries)
75104 return constants.ContentTypeJSON, b, true
76105 }
@@ -58,19 +58,48 @@ func (h *Handler) HandleConfig(cfg *config.Config) (string, []byte, bool) {
58 // HandleHistoryRecent handles GET /history/recent endpoint58 // HandleHistoryRecent handles GET /history/recent endpoint
59 func (h *Handler) HandleHistoryRecent(u *neturl.URL) (string, []byte, bool) {59 func (h *Handler) HandleHistoryRecent(u *neturl.URL) (string, []byte, bool) {
60 log.Printf("[api] GET /history/recent%s", u.RawQuery)60 log.Printf("[api] GET /history/recent%s", u.RawQuery)
61- // Parse limit61+ // Parse limit and offset
62 q := u.Query()62 q := u.Query()
63- limit := 5063+ limit := 20
64 if l := q.Get("limit"); l != "" {64 if l := q.Get("limit"); l != "" {
65 if n, err := strconv.Atoi(l); err == nil && n > 0 {65 if n, err := strconv.Atoi(l); err == nil && n > 0 {
66 limit = n66 limit = n
67 }67 }
68 }68 }
69+ offset := 0
70+ if o := q.Get("offset"); o != "" {
71+ if n, err := strconv.Atoi(o); err == nil && n >= 0 {
72+ offset = n
73+ }
74+ }
75+
69 ctx := context.Background()76 ctx := context.Background()
70- entries, err := h.browserService.GetRecentHistory(ctx, limit)77+ var entries []interface{}
71- if err != nil {78+
72- return constants.ContentTypeJSON, []byte("[]"), true79+ // Use paginated version if offset is provided
80+ if offset > 0 || q.Has("offset") {
81+ histEntries, err := h.browserService.GetRecentHistoryWithOffset(ctx, limit, offset)
82+ if err != nil {
83+ return constants.ContentTypeJSON, []byte("[]"), true
84+ }
85+ // Convert to interface slice for JSON marshaling
86+ entries = make([]interface{}, len(histEntries))
87+ for i, entry := range histEntries {
88+ entries[i] = entry
89+ }
90+ } else {
91+ // Use original method for backward compatibility
92+ histEntries, err := h.browserService.GetRecentHistory(ctx, limit)
93+ if err != nil {
94+ return constants.ContentTypeJSON, []byte("[]"), true
95+ }
96+ // Convert to interface slice for JSON marshaling
97+ entries = make([]interface{}, len(histEntries))
98+ for i, entry := range histEntries {
99+ entries[i] = entry
100+ }
73 }101 }
102+
74 b, _ := json.Marshal(entries)103 b, _ := json.Marshal(entries)
75 return constants.ContentTypeJSON, b, true104 return constants.ContentTypeJSON, b, true
76 }105 }
M internal/db/history.sql.go +38 -0
@@ -73,6 +73,44 @@ func (q *Queries) GetHistory(ctx context.Context, limit int64) ([]History, error
7373 return items, nil
7474 }
7575
76+const GetHistoryWithOffset = `-- name: GetHistoryWithOffset :many
77+SELECT id, url, title, visit_count, last_visited, created_at, favicon_url
78+FROM history
79+ORDER BY last_visited DESC
80+LIMIT ? OFFSET ?
81+`
82+
83+func (q *Queries) GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error) {
84+ rows, err := q.db.QueryContext(ctx, GetHistoryWithOffset, limit, offset)
85+ if err != nil {
86+ return nil, err
87+ }
88+ defer rows.Close()
89+ items := []History{}
90+ for rows.Next() {
91+ var i History
92+ if err := rows.Scan(
93+ &i.ID,
94+ &i.Url,
95+ &i.Title,
96+ &i.VisitCount,
97+ &i.LastVisited,
98+ &i.CreatedAt,
99+ &i.FaviconUrl,
100+ ); err != nil {
101+ return nil, err
102+ }
103+ items = append(items, i)
104+ }
105+ if err := rows.Close(); err != nil {
106+ return nil, err
107+ }
108+ if err := rows.Err(); err != nil {
109+ return nil, err
110+ }
111+ return items, nil
112+}
113+
76114 const GetShortcuts = `-- name: GetShortcuts :many
77115 SELECT id, shortcut, url_template, description, created_at
78116 FROM shortcuts
@@ -73,6 +73,44 @@ func (q *Queries) GetHistory(ctx context.Context, limit int64) ([]History, error
73 return items, nil73 return items, nil
74 }74 }
75 75
76+const GetHistoryWithOffset = `-- name: GetHistoryWithOffset :many
77+SELECT id, url, title, visit_count, last_visited, created_at, favicon_url
78+FROM history
79+ORDER BY last_visited DESC
80+LIMIT ? OFFSET ?
81+`
82+
83+func (q *Queries) GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error) {
84+ rows, err := q.db.QueryContext(ctx, GetHistoryWithOffset, limit, offset)
85+ if err != nil {
86+ return nil, err
87+ }
88+ defer rows.Close()
89+ items := []History{}
90+ for rows.Next() {
91+ var i History
92+ if err := rows.Scan(
93+ &i.ID,
94+ &i.Url,
95+ &i.Title,
96+ &i.VisitCount,
97+ &i.LastVisited,
98+ &i.CreatedAt,
99+ &i.FaviconUrl,
100+ ); err != nil {
101+ return nil, err
102+ }
103+ items = append(items, i)
104+ }
105+ if err := rows.Close(); err != nil {
106+ return nil, err
107+ }
108+ if err := rows.Err(); err != nil {
109+ return nil, err
110+ }
111+ return items, nil
112+}
113+
76 const GetShortcuts = `-- name: GetShortcuts :many114 const GetShortcuts = `-- name: GetShortcuts :many
77 SELECT id, shortcut, url_template, description, created_at115 SELECT id, shortcut, url_template, description, created_at
78 FROM shortcuts116 FROM shortcuts
M internal/db/interfaces.go +1 -0
@@ -17,6 +17,7 @@ type ZoomQuerier interface {
1717 // HistoryQuerier defines the interface for history-related database operations
1818 type HistoryQuerier interface {
1919 GetHistory(ctx context.Context, limit int64) ([]History, error)
20+ GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)
2021 SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error)
2122 AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error
2223 UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error
@@ -17,6 +17,7 @@ type ZoomQuerier interface {
17 // HistoryQuerier defines the interface for history-related database operations17 // HistoryQuerier defines the interface for history-related database operations
18 type HistoryQuerier interface {18 type HistoryQuerier interface {
19 GetHistory(ctx context.Context, limit int64) ([]History, error)19 GetHistory(ctx context.Context, limit int64) ([]History, error)
20+ GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)
20 SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error)21 SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error)
21 AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error22 AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error
22 UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error23 UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error
M internal/db/querier.go +1 -0
@@ -20,6 +20,7 @@ type Querier interface {
2020 DeleteZoomLevel(ctx context.Context, domain string) error
2121 GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error)
2222 GetHistory(ctx context.Context, limit int64) ([]History, error)
23+ GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)
2324 GetShortcuts(ctx context.Context) ([]Shortcut, error)
2425 // Get zoom level for a specific domain
2526 GetZoomLevel(ctx context.Context, domain string) (float64, error)
@@ -20,6 +20,7 @@ type Querier interface {
20 DeleteZoomLevel(ctx context.Context, domain string) error20 DeleteZoomLevel(ctx context.Context, domain string) error
21 GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error)21 GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error)
22 GetHistory(ctx context.Context, limit int64) ([]History, error)22 GetHistory(ctx context.Context, limit int64) ([]History, error)
23+ GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)
23 GetShortcuts(ctx context.Context) ([]Shortcut, error)24 GetShortcuts(ctx context.Context) ([]Shortcut, error)
24 // Get zoom level for a specific domain25 // Get zoom level for a specific domain
25 GetZoomLevel(ctx context.Context, domain string) (float64, error)26 GetZoomLevel(ctx context.Context, domain string) (float64, error)
M internal/db/queries/history.sql +6 -0
@@ -4,6 +4,12 @@ FROM history
44 ORDER BY last_visited DESC
55 LIMIT ?;
66
7+-- name: GetHistoryWithOffset :many
8+SELECT *
9+FROM history
10+ORDER BY last_visited DESC
11+LIMIT ? OFFSET ?;
12+
713 -- name: AddOrUpdateHistory :exec
814 INSERT INTO history (url, title)
915 VALUES (?, ?)
@@ -4,6 +4,12 @@ FROM history
4 ORDER BY last_visited DESC4 ORDER BY last_visited DESC
5 LIMIT ?;5 LIMIT ?;
6 6
7+-- name: GetHistoryWithOffset :many
8+SELECT *
9+FROM history
10+ORDER BY last_visited DESC
11+LIMIT ? OFFSET ?;
12+
7 -- name: AddOrUpdateHistory :exec13 -- name: AddOrUpdateHistory :exec
8 INSERT INTO history (url, title) 14 INSERT INTO history (url, title)
9 VALUES (?, ?)15 VALUES (?, ?)
M services/browser_service.go +32 -0
@@ -250,6 +250,38 @@ func (s *BrowserService) GetRecentHistory(ctx context.Context, limit int) ([]His
250250 return result, nil
251251 }
252252
253+// GetRecentHistoryWithOffset returns recent browser history entries with pagination support.
254+func (s *BrowserService) GetRecentHistoryWithOffset(ctx context.Context, limit, offset int) ([]HistoryEntry, error) {
255+ if limit <= 0 {
256+ limit = 20 // Default limit for pagination
257+ }
258+ if offset < 0 {
259+ offset = 0
260+ }
261+
262+ entries, err := s.dbQueries.GetHistoryWithOffset(ctx, int64(limit), int64(offset))
263+ if err != nil {
264+ return nil, err
265+ }
266+
267+ result := make([]HistoryEntry, len(entries))
268+ for i, entry := range entries {
269+ // Defensive cast to int32 to prevent overflow
270+ vc := clampToInt32(entry.VisitCount.Int64)
271+ result[i] = HistoryEntry{
272+ ID: entry.ID,
273+ URL: entry.Url,
274+ Title: entry.Title.String,
275+ FaviconURL: entry.FaviconUrl.String,
276+ VisitCount: vc,
277+ LastVisited: entry.LastVisited.Time,
278+ CreatedAt: entry.CreatedAt.Time,
279+ }
280+ }
281+
282+ return result, nil
283+}
284+
253285 // SearchHistory searches browser history.
254286 func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit int) ([]HistoryEntry, error) {
255287 if query == "" {
@@ -250,6 +250,38 @@ func (s *BrowserService) GetRecentHistory(ctx context.Context, limit int) ([]His
250 return result, nil250 return result, nil
251 }251 }
252 252
253+// GetRecentHistoryWithOffset returns recent browser history entries with pagination support.
254+func (s *BrowserService) GetRecentHistoryWithOffset(ctx context.Context, limit, offset int) ([]HistoryEntry, error) {
255+ if limit <= 0 {
256+ limit = 20 // Default limit for pagination
257+ }
258+ if offset < 0 {
259+ offset = 0
260+ }
261+
262+ entries, err := s.dbQueries.GetHistoryWithOffset(ctx, int64(limit), int64(offset))
263+ if err != nil {
264+ return nil, err
265+ }
266+
267+ result := make([]HistoryEntry, len(entries))
268+ for i, entry := range entries {
269+ // Defensive cast to int32 to prevent overflow
270+ vc := clampToInt32(entry.VisitCount.Int64)
271+ result[i] = HistoryEntry{
272+ ID: entry.ID,
273+ URL: entry.Url,
274+ Title: entry.Title.String,
275+ FaviconURL: entry.FaviconUrl.String,
276+ VisitCount: vc,
277+ LastVisited: entry.LastVisited.Time,
278+ CreatedAt: entry.CreatedAt.Time,
279+ }
280+ }
281+
282+ return result, nil
283+}
284+
253 // SearchHistory searches browser history.285 // SearchHistory searches browser history.
254 func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit int) ([]HistoryEntry, error) {286 func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit int) ([]HistoryEntry, error) {
255 if query == "" {287 if query == "" {

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