Skip to content

Commit 29d89ac

Browse files
authored
feat(skills): registry card polish and persisted pagination state (#2102)
* fix(skills): replace registry namespace with normalized repo label on cards and detail page * feat(pagination): add last-page shortcut to shared pagination component * feat(skills): pin registry pagination to viewport bottom and persist page size * fix(skills): use host-agnostic icon for detail page repo badge * fix(main): reuse DB page-size validator in uiPreferences IPC handler * fix(skills): validate persisted registry limit and gate query on preference load
1 parent 844afc7 commit 29d89ac

16 files changed

Lines changed: 501 additions & 23 deletions

File tree

main/src/ipc-handlers/ui-preferences.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { ipcMain } from 'electron'
22
import {
3+
getPageSizePreference,
34
getViewModePreference,
5+
isValidPageSize,
6+
setPageSizePreference,
47
setViewModePreference,
8+
UI_PAGE_SIZE_PREFERENCE_KEYS,
59
UI_PREFERENCE_KEYS,
10+
type UiPageSizeKey,
611
type UiPreferenceKey,
712
type ViewMode,
813
} from '../ui-preferences'
@@ -16,6 +21,13 @@ function isUiPreferenceKey(key: unknown): key is UiPreferenceKey {
1621
)
1722
}
1823

24+
function isUiPageSizeKey(key: unknown): key is UiPageSizeKey {
25+
return (
26+
typeof key === 'string' &&
27+
(UI_PAGE_SIZE_PREFERENCE_KEYS as readonly string[]).includes(key)
28+
)
29+
}
30+
1931
function isViewMode(value: unknown): value is ViewMode {
2032
return value === 'card' || value === 'table'
2133
}
@@ -34,4 +46,18 @@ export function register() {
3446
setViewModePreference(key, value)
3547
}
3648
)
49+
50+
ipcMain.handle(
51+
'ui-preferences:get-page-size',
52+
(_event, key: unknown): number | undefined =>
53+
isUiPageSizeKey(key) ? getPageSizePreference(key) : undefined
54+
)
55+
56+
ipcMain.handle(
57+
'ui-preferences:set-page-size',
58+
(_event, key: unknown, value: unknown): void => {
59+
if (!isUiPageSizeKey(key) || !isValidPageSize(value)) return
60+
setPageSizePreference(key, value)
61+
}
62+
)
3763
}

main/src/ui-preferences.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,56 @@ export function setViewModePreference(
5454
log.error(`[DB] Failed to write UI preference "${key}":`, err)
5555
}
5656
}
57+
58+
export const UI_PAGE_SIZE_PREFERENCE_KEYS = [
59+
'ui.pageSize.skillsRegistry',
60+
] as const
61+
62+
export type UiPageSizeKey = (typeof UI_PAGE_SIZE_PREFERENCE_KEYS)[number]
63+
64+
function isValidPageSizeKey(key: string): key is UiPageSizeKey {
65+
return (UI_PAGE_SIZE_PREFERENCE_KEYS as readonly string[]).includes(key)
66+
}
67+
68+
export function isValidPageSize(value: unknown): value is number {
69+
return (
70+
typeof value === 'number' &&
71+
Number.isInteger(value) &&
72+
value > 0 &&
73+
value <= 1000
74+
)
75+
}
76+
77+
/**
78+
* Reads a persisted page-size preference. Returns `undefined` when no
79+
* preference has been stored yet or when the persisted value is malformed,
80+
* so callers can fall back to a hardcoded default or URL param.
81+
*/
82+
export function getPageSizePreference(key: UiPageSizeKey): number | undefined {
83+
try {
84+
const raw = readSetting(key)
85+
if (!raw) return undefined
86+
const parsed = Number(raw)
87+
if (!isValidPageSize(parsed)) return undefined
88+
return parsed
89+
} catch (err) {
90+
log.error(`[DB] Failed to read page size preference "${key}":`, err)
91+
return undefined
92+
}
93+
}
94+
95+
export function setPageSizePreference(key: UiPageSizeKey, value: number): void {
96+
if (!isValidPageSizeKey(key)) {
97+
log.warn(`[DB] Refusing to write unknown page size key: ${key}`)
98+
return
99+
}
100+
if (!isValidPageSize(value)) {
101+
log.warn(`[DB] Refusing to write invalid page size: ${value}`)
102+
return
103+
}
104+
try {
105+
writeSetting(key, String(value))
106+
} catch (err) {
107+
log.error(`[DB] Failed to write page size preference "${key}":`, err)
108+
}
109+
}

preload/src/api/ui-preferences.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ipcRenderer } from 'electron'
22
import type {
3+
UiPageSizeKey,
34
UiPreferenceKey,
45
ViewMode,
56
} from '../../../main/src/ui-preferences'
@@ -10,12 +11,18 @@ export const uiPreferencesApi = {
1011
ipcRenderer.invoke('ui-preferences:get-view-mode', key),
1112
setViewMode: (key: UiPreferenceKey, value: ViewMode): Promise<void> =>
1213
ipcRenderer.invoke('ui-preferences:set-view-mode', key, value),
14+
getPageSize: (key: UiPageSizeKey): Promise<number | undefined> =>
15+
ipcRenderer.invoke('ui-preferences:get-page-size', key),
16+
setPageSize: (key: UiPageSizeKey, value: number): Promise<void> =>
17+
ipcRenderer.invoke('ui-preferences:set-page-size', key, value),
1318
},
1419
}
1520

1621
export interface UiPreferencesAPI {
1722
uiPreferences: {
1823
getViewMode: (key: UiPreferenceKey) => Promise<ViewMode>
1924
setViewMode: (key: UiPreferenceKey, value: ViewMode) => Promise<void>
25+
getPageSize: (key: UiPageSizeKey) => Promise<number | undefined>
26+
setPageSize: (key: UiPageSizeKey, value: number) => Promise<void>
2027
}
2128
}

renderer/src/common/components/ui/__tests__/pagination.test.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ describe('Pagination', () => {
3434
expect(
3535
screen.getByRole('button', { name: /go to next page/i })
3636
).toBeVisible()
37+
expect(
38+
screen.getByRole('button', { name: /go to last page/i })
39+
).toBeVisible()
3740
})
3841

3942
it('uses a custom item label', () => {
@@ -53,14 +56,20 @@ describe('Pagination', () => {
5356
expect(
5457
screen.getByRole('button', { name: /go to next page/i })
5558
).toBeEnabled()
59+
expect(
60+
screen.getByRole('button', { name: /go to last page/i })
61+
).toBeEnabled()
5662
})
5763

58-
it('disables next on the last page', () => {
64+
it('disables next and last on the last page', () => {
5965
setup({ page: 9, pageSize: 12, total: 100 })
6066

6167
expect(
6268
screen.getByRole('button', { name: /go to next page/i })
6369
).toBeDisabled()
70+
expect(
71+
screen.getByRole('button', { name: /go to last page/i })
72+
).toBeDisabled()
6473
expect(
6574
screen.getByRole('button', { name: /go to previous page/i })
6675
).toBeEnabled()
@@ -80,6 +89,9 @@ describe('Pagination', () => {
8089

8190
await user.click(screen.getByRole('button', { name: /go to first page/i }))
8291
expect(onPageChange).toHaveBeenCalledWith(1)
92+
93+
await user.click(screen.getByRole('button', { name: /go to last page/i }))
94+
expect(onPageChange).toHaveBeenCalledWith(9)
8395
})
8496

8597
it('invokes onPageSizeChange when a new size is selected', async () => {

renderer/src/common/components/ui/pagination.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
ChevronFirstIcon,
3+
ChevronLastIcon,
34
ChevronLeftIcon,
45
ChevronRightIcon,
56
} from 'lucide-react'
@@ -99,6 +100,15 @@ export function Pagination({
99100
>
100101
<ChevronRightIcon />
101102
</Button>
103+
<Button
104+
variant="ghost"
105+
size="icon"
106+
aria-label="Go to last page"
107+
disabled={isLastPage}
108+
onClick={() => onPageChange(totalPages)}
109+
>
110+
<ChevronLastIcon />
111+
</Button>
102112
</div>
103113
<div className="flex items-center gap-2">
104114
<p className="text-secondary-foreground text-sm whitespace-nowrap">
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { describe, expect, it, vi, beforeEach } from 'vitest'
2+
import { act, renderHook, waitFor } from '@testing-library/react'
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4+
import type { ReactNode } from 'react'
5+
import { usePageSizePreference } from '../use-page-size-preference'
6+
7+
function makeWrapper() {
8+
const queryClient = new QueryClient({
9+
defaultOptions: {
10+
queries: { retry: false },
11+
mutations: { retry: false },
12+
},
13+
})
14+
return ({ children }: { children: ReactNode }) => (
15+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
16+
)
17+
}
18+
19+
describe('usePageSizePreference', () => {
20+
beforeEach(() => {
21+
window.electronAPI.uiPreferences.getPageSize = vi
22+
.fn()
23+
.mockResolvedValue(undefined)
24+
window.electronAPI.uiPreferences.setPageSize = vi
25+
.fn()
26+
.mockResolvedValue(undefined)
27+
})
28+
29+
it('returns undefined while the initial read is in flight', () => {
30+
const { result } = renderHook(
31+
() => usePageSizePreference('ui.pageSize.skillsRegistry'),
32+
{ wrapper: makeWrapper() }
33+
)
34+
35+
expect(result.current.pageSize).toBeUndefined()
36+
})
37+
38+
it('reads the persisted page size from the main process', async () => {
39+
window.electronAPI.uiPreferences.getPageSize = vi.fn().mockResolvedValue(50)
40+
41+
const { result } = renderHook(
42+
() => usePageSizePreference('ui.pageSize.skillsRegistry'),
43+
{ wrapper: makeWrapper() }
44+
)
45+
46+
await waitFor(() => {
47+
expect(result.current.pageSize).toBe(50)
48+
})
49+
expect(window.electronAPI.uiPreferences.getPageSize).toHaveBeenCalledWith(
50+
'ui.pageSize.skillsRegistry'
51+
)
52+
})
53+
54+
it('leaves pageSize undefined when nothing is persisted', async () => {
55+
const { result } = renderHook(
56+
() => usePageSizePreference('ui.pageSize.skillsRegistry'),
57+
{ wrapper: makeWrapper() }
58+
)
59+
60+
await waitFor(() => {
61+
expect(result.current.isLoading).toBe(false)
62+
})
63+
expect(result.current.pageSize).toBeUndefined()
64+
})
65+
66+
it('persists the new page size via IPC and updates state optimistically', async () => {
67+
const { result } = renderHook(
68+
() => usePageSizePreference('ui.pageSize.skillsRegistry'),
69+
{ wrapper: makeWrapper() }
70+
)
71+
72+
await waitFor(() => {
73+
expect(result.current.isLoading).toBe(false)
74+
})
75+
76+
act(() => {
77+
result.current.setPageSize(24)
78+
})
79+
80+
await waitFor(() => {
81+
expect(result.current.pageSize).toBe(24)
82+
})
83+
84+
await waitFor(() => {
85+
expect(window.electronAPI.uiPreferences.setPageSize).toHaveBeenCalledWith(
86+
'ui.pageSize.skillsRegistry',
87+
24
88+
)
89+
})
90+
})
91+
})
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2+
import log from 'electron-log/renderer'
3+
import { useCallback } from 'react'
4+
import type { UiPageSizeKey } from '../../../../main/src/ui-preferences'
5+
6+
function pageSizePreferenceQueryKey(key: UiPageSizeKey) {
7+
return ['ui-preference', 'page-size', key] as const
8+
}
9+
10+
/**
11+
* Reads and writes a persisted paginated-list page-size preference backed by
12+
* the main-process SQLite `settings` table via IPC. Mirrors `useViewPreference`.
13+
*
14+
* Returns `undefined` until the initial read resolves so callers can fall
15+
* back to a URL search param or a hardcoded default without flashing the
16+
* wrong size.
17+
*/
18+
export function usePageSizePreference(key: UiPageSizeKey) {
19+
const queryClient = useQueryClient()
20+
21+
const { data, isPending } = useQuery({
22+
queryKey: pageSizePreferenceQueryKey(key),
23+
queryFn: async (): Promise<number | null> => {
24+
try {
25+
const value = await window.electronAPI.uiPreferences.getPageSize(key)
26+
return typeof value === 'number' ? value : null
27+
} catch (error) {
28+
log.error(`Failed to read page size preference "${key}":`, error)
29+
return null
30+
}
31+
},
32+
staleTime: Infinity,
33+
refetchOnWindowFocus: false,
34+
})
35+
36+
const { mutate } = useMutation({
37+
mutationFn: async (value: number) => {
38+
await window.electronAPI.uiPreferences.setPageSize(key, value)
39+
return value
40+
},
41+
onMutate: async (value: number) => {
42+
await queryClient.cancelQueries({
43+
queryKey: pageSizePreferenceQueryKey(key),
44+
})
45+
const previous = queryClient.getQueryData<number | null>(
46+
pageSizePreferenceQueryKey(key)
47+
)
48+
queryClient.setQueryData(pageSizePreferenceQueryKey(key), value)
49+
return { previous }
50+
},
51+
onError: (error, _value, context) => {
52+
log.error(`Failed to persist page size preference "${key}":`, error)
53+
if (context?.previous !== undefined) {
54+
queryClient.setQueryData(
55+
pageSizePreferenceQueryKey(key),
56+
context.previous
57+
)
58+
}
59+
},
60+
})
61+
62+
const setPageSize = useCallback(
63+
(value: number) => {
64+
mutate(value)
65+
},
66+
[mutate]
67+
)
68+
69+
return {
70+
pageSize: data ?? undefined,
71+
isLoading: isPending,
72+
setPageSize,
73+
}
74+
}

renderer/src/common/mocks/electronAPI.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ function createElectronStub(): Partial<ElectronAPI> {
4343
uiPreferences: {
4444
getViewMode: vi.fn().mockResolvedValue('card'),
4545
setViewMode: vi.fn().mockResolvedValue(undefined),
46+
getPageSize: vi.fn().mockResolvedValue(undefined),
47+
setPageSize: vi.fn().mockResolvedValue(undefined),
4648
} as ElectronAPI['uiPreferences'],
4749
chat: {
4850
stream: vi.fn(),

0 commit comments

Comments
 (0)