Skip to content

Commit c92887e

Browse files
Merge pull request #207 from laststance/codex/installed-search-count-display
feat(installed): add search count display setting
2 parents a3b2d3b + 6f9aa3e commit c92887e

12 files changed

Lines changed: 630 additions & 12 deletions
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import {
2+
mkdirSync,
3+
mkdtempSync,
4+
realpathSync,
5+
rmSync,
6+
writeFileSync,
7+
} from 'node:fs'
8+
import { tmpdir } from 'node:os'
9+
import { join } from 'node:path'
10+
11+
import type { Page } from '@playwright/test'
12+
13+
import type { Settings } from '@/shared/settings'
14+
15+
import { test, expect } from '../fixtures/electron-app'
16+
import { readSettingsFile, writeSettingsFile } from '../helpers/settings-file'
17+
18+
const SEARCH_COUNT_SKILLS = [
19+
{ name: 'alpha-count-e2e', source: 'laststance/skills' },
20+
{ name: 'beta-count-e2e', source: 'laststance/skills' },
21+
{ name: 'gamma-count-e2e', source: 'pbakaus/impeccable' },
22+
]
23+
24+
type SearchCountDisplaySetting = Settings['installedSearchCountDisplay']
25+
type IsolatedHomeUse = (home: string) => Promise<void>
26+
27+
/**
28+
* Write one source skill folder that the real scanner will count after app launch.
29+
* @param home - Isolated E2E HOME used by the Electron fixture.
30+
* @param skillName - Folder name and SKILL.md title for the staged skill.
31+
* @returns void after the source skill exists on disk.
32+
* @example
33+
* stageSourceSkill('/tmp/home', 'alpha-count-e2e')
34+
*/
35+
function stageSourceSkill(home: string, skillName: string): void {
36+
const sourcePath = join(home, '.agents', 'skills', skillName)
37+
mkdirSync(sourcePath, { recursive: true })
38+
writeFileSync(
39+
join(sourcePath, 'SKILL.md'),
40+
`---\nname: ${skillName}\ndescription: ${skillName} description\n---\n# ${skillName}\n`,
41+
'utf8',
42+
)
43+
}
44+
45+
/**
46+
* Write the skills CLI lockfile so the real scanner exposes deterministic repo facets.
47+
* @param home - Isolated E2E HOME used by the Electron fixture.
48+
* @returns void after `.skill-lock.json` maps each staged skill to its repo.
49+
* @example
50+
* stageSkillLock('/tmp/home')
51+
*/
52+
function stageSkillLock(home: string): void {
53+
const lockPath = join(home, '.agents', '.skill-lock.json')
54+
const skills = Object.fromEntries(
55+
SEARCH_COUNT_SKILLS.map((skill) => [
56+
skill.name,
57+
{
58+
source: skill.source,
59+
sourceType: 'github',
60+
sourceUrl: `https://github.com/${skill.source}.git`,
61+
},
62+
]),
63+
)
64+
65+
writeFileSync(lockPath, JSON.stringify({ skills }, null, 2), 'utf8')
66+
}
67+
68+
/**
69+
* Stage the complete Installed-count HOME before Electron starts scanning.
70+
* @param home - Isolated E2E HOME used by the Electron fixture.
71+
* @returns void after source skills and repo metadata are on disk.
72+
* @example
73+
* stageInstalledCountHome('/tmp/home')
74+
*/
75+
function stageInstalledCountHome(home: string): void {
76+
mkdirSync(join(home, '.agents', 'skills'), { recursive: true })
77+
for (const skill of SEARCH_COUNT_SKILLS) {
78+
stageSourceSkill(home, skill.name)
79+
}
80+
stageSkillLock(home)
81+
}
82+
83+
/**
84+
* Provide an isolated HOME with only the three Installed-count skills staged.
85+
* @param use - Playwright fixture continuation that launches Electron after setup.
86+
* @param display - Optional persisted count placement to write before launch.
87+
* @returns Promise that resolves after the fixture HOME is cleaned up.
88+
* @example
89+
* await useInstalledCountHome(use, 'inline')
90+
*/
91+
async function useInstalledCountHome(
92+
use: IsolatedHomeUse,
93+
display?: SearchCountDisplaySetting,
94+
): Promise<void> {
95+
const home = realpathSync.native(
96+
mkdtempSync(join(tmpdir(), 'skills-desktop-e2e-search-count-')),
97+
)
98+
try {
99+
stageInstalledCountHome(home)
100+
if (display) {
101+
writeSettingsFile(home, { installedSearchCountDisplay: display })
102+
}
103+
await use(home)
104+
} finally {
105+
rmSync(home, { recursive: true, force: true })
106+
}
107+
}
108+
109+
/**
110+
* Dispatch Installed search and repo filters in one renderer transaction.
111+
* @param page - Electron renderer page under test.
112+
* @param filters - Search query and selected repository ids to apply.
113+
* @returns Promise that resolves once Redux has the requested filter state.
114+
* @example
115+
* await applyInstalledFilters(appWindow, { query: 'missing', sources: [] })
116+
*/
117+
async function applyInstalledFilters(
118+
page: Page,
119+
filters: { query: string; sources: string[] },
120+
): Promise<void> {
121+
await page.evaluate(({ query, sources }) => {
122+
const store = window.__store__
123+
if (!store) throw new Error('window.__store__ is not exposed')
124+
store.dispatch({
125+
type: 'ui/setSearchQuery',
126+
payload: query,
127+
})
128+
store.dispatch({
129+
type: 'ui/setSelectedSources',
130+
payload: sources,
131+
})
132+
}, filters)
133+
}
134+
135+
const installedCountTest = test.extend<{ isolatedHome: string }>({
136+
// eslint-disable-next-line no-empty-pattern
137+
isolatedHome: async ({}, use) => {
138+
await useInstalledCountHome(use)
139+
},
140+
})
141+
142+
const inlineInstalledCountTest = test.extend<{ isolatedHome: string }>({
143+
// eslint-disable-next-line no-empty-pattern
144+
isolatedHome: async ({}, use) => {
145+
await useInstalledCountHome(use, 'inline')
146+
},
147+
})
148+
149+
installedCountTest(
150+
'Installed tab badge tracks the current visible count and Marketplace stays count-free',
151+
async ({ appWindow }) => {
152+
// Arrange / Assert
153+
await expect(
154+
appWindow.getByRole('tab', {
155+
name: /^Installed, 3 skills visible$/,
156+
}),
157+
).toBeVisible()
158+
await expect(
159+
appWindow.getByRole('tab', { name: /^Marketplace$/ }),
160+
).toBeVisible()
161+
162+
// Act
163+
await applyInstalledFilters(appWindow, {
164+
query: 'alpha-count',
165+
sources: [],
166+
})
167+
168+
// Assert
169+
await expect(
170+
appWindow.getByRole('tab', {
171+
name: /^Installed, 1 skill visible$/,
172+
}),
173+
).toBeVisible()
174+
175+
// Act
176+
await applyInstalledFilters(appWindow, {
177+
query: '',
178+
sources: ['pbakaus/impeccable'],
179+
})
180+
181+
// Assert
182+
await expect(
183+
appWindow.getByRole('tab', {
184+
name: /^Installed, 1 skill visible$/,
185+
}),
186+
).toBeVisible()
187+
188+
// Act
189+
await applyInstalledFilters(appWindow, {
190+
query: 'missing-count-e2e',
191+
sources: [],
192+
})
193+
194+
// Assert
195+
await expect(
196+
appWindow.getByRole('tab', {
197+
name: /^Installed, 0 skills visible$/,
198+
}),
199+
).toBeVisible()
200+
await expect(
201+
appWindow.getByRole('tab', { name: /^Marketplace$/ }),
202+
).toBeVisible()
203+
},
204+
)
205+
206+
inlineInstalledCountTest(
207+
'persisted inline mode moves the count into the toolbar and removes the tab badge',
208+
async ({ appWindow, isolatedHome }) => {
209+
// Arrange / Assert
210+
await appWindow.waitForFunction(() => {
211+
const store = window.__store__
212+
if (!store) return false
213+
const state = store.getState() as {
214+
settings?: { installedSearchCountDisplay?: string }
215+
}
216+
return state.settings?.installedSearchCountDisplay === 'inline'
217+
})
218+
await expect(
219+
appWindow.getByRole('tab', { name: /^Installed$/ }),
220+
).toBeVisible()
221+
await expect(
222+
appWindow.getByRole('tab', {
223+
name: /^Installed, 3 skills visible$/,
224+
}),
225+
).toHaveCount(0)
226+
await expect(
227+
appWindow
228+
.locator('[aria-live="polite"]')
229+
.filter({ hasText: /^3 skills$/ }),
230+
).toBeVisible()
231+
232+
const persisted = readSettingsFile(isolatedHome) as {
233+
installedSearchCountDisplay?: string
234+
} | null
235+
expect(persisted?.installedSearchCountDisplay).toBe('inline')
236+
},
237+
)

src/main/ipc/ipc-schemas.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,13 +535,31 @@ describe('settings:set lockstep with SettingsSchema', () => {
535535
expect(schema.safeParse([{ autoDownloadUpdates: true }]).success).toBe(true)
536536
})
537537

538+
it('lets the user persist the Installed search count display placement', () => {
539+
// Arrange / Act / Assert
540+
expect(
541+
schema.safeParse([{ installedSearchCountDisplay: 'inline' }]).success,
542+
).toBe(true)
543+
expect(
544+
schema.safeParse([{ installedSearchCountDisplay: 'tab' }]).success,
545+
).toBe(true)
546+
})
547+
538548
it('blocks a non-boolean auto-download toggle from reaching disk', () => {
539549
// Arrange / Act / Assert
540550
expect(schema.safeParse([{ autoDownloadUpdates: 'yes' }]).success).toBe(
541551
false,
542552
)
543553
})
544554

555+
it('blocks an unknown Installed search count display placement from reaching disk', () => {
556+
// Arrange / Act / Assert
557+
expect(
558+
schema.safeParse([{ installedSearchCountDisplay: 'marketplace' }])
559+
.success,
560+
).toBe(false)
561+
})
562+
545563
it('blocks an unknown terminal preset from reaching disk', () => {
546564
// Arrange / Act / Assert
547565
expect(
@@ -630,6 +648,14 @@ describe('settings:set lockstep with SettingsSchema', () => {
630648
expect('autoDownloadUpdates' in parsed[0]).toBe(false)
631649
})
632650

651+
it('does not wipe a persisted Installed search count placement when an unrelated setting is saved', () => {
652+
// Arrange / Act
653+
const parsed = schema.parse([{ defaultSkillTab: 'info' }]) as [object]
654+
655+
// Assert
656+
expect('installedSearchCountDisplay' in parsed[0]).toBe(false)
657+
})
658+
633659
it('lets the user persist an explicit hiddenAgentIds list', () => {
634660
// Arrange / Act / Assert
635661
expect(

src/main/ipc/ipc-schemas.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { z } from 'zod'
33
import { AGENT_IDS, TERMINAL_APP_IDS } from '@/shared/constants'
44
import type { IpcInvokeChannel } from '@/shared/ipc-contract'
55
import {
6+
INSTALLED_SEARCH_COUNT_DISPLAY_OPTIONS,
67
SettingsSchema,
78
WINDOW_BACKGROUND_BLUR_RADIUS_SCHEMA,
89
} from '@/shared/settings'
@@ -360,6 +361,11 @@ export const IPC_ARG_SCHEMAS: Partial<Record<IpcInvokeChannel, z.ZodTuple>> = {
360361
// unrelated partial settings writes do not reset blur to zero.
361362
windowBackgroundBlurRadius:
362363
WINDOW_BACKGROUND_BLUR_RADIUS_SCHEMA.optional(),
364+
// Search-count display preference. Keep this as a non-defaulting enum
365+
// so unrelated partial settings writes do not reset the user's choice.
366+
installedSearchCountDisplay: z
367+
.enum(INSTALLED_SEARCH_COUNT_DISPLAY_OPTIONS)
368+
.optional(),
363369
// Strict z.enum here — renderers should only ever emit valid ids.
364370
// Intentionally NOT chained off `SettingsSchema.shape.hiddenAgentIds`:
365371
// that field carries a `.default([])` for forgiving disk reads, and

src/main/services/settings.test.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect } from 'vitest'
22

3-
import type { Settings } from '@/shared/settings'
3+
import { DEFAULT_SETTINGS, type Settings } from '@/shared/settings'
44

55
import { areSettingsEqual } from './settings'
66

@@ -13,13 +13,7 @@ import { areSettingsEqual } from './settings'
1313
* the saved dimensions are identical.
1414
*/
1515
describe('areSettingsEqual', () => {
16-
const baseSettings: Settings = {
17-
defaultSkillTab: 'files',
18-
preferredTerminal: 'terminal',
19-
windowBackgroundBlurRadius: 0,
20-
hiddenAgentIds: [],
21-
autoDownloadUpdates: false,
22-
}
16+
const baseSettings: Settings = DEFAULT_SETTINGS
2317

2418
it('treats two settings with identical primitive fields as unchanged so no redundant save fires', () => {
2519
// Arrange

src/renderer/settings/sections/Appearance.browser.test.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,30 @@ async function createStore(
4141
}
4242

4343
describe('Settings → Appearance', () => {
44+
it('persists Toolbar text when the Installed search count display is changed', async () => {
45+
// Arrange
46+
const store = await createStore()
47+
const { Appearance } = await import('./Appearance')
48+
const screen = await render(
49+
<Provider store={store}>
50+
<Appearance />
51+
</Provider>,
52+
)
53+
54+
// Act
55+
await screen.getByRole('radio', { name: /Toolbar text/i }).click()
56+
57+
// Assert
58+
await expect.poll(() => mockSettingsSet.mock.calls.length).toBe(1)
59+
expect(mockSettingsSet).toHaveBeenCalledWith({
60+
installedSearchCountDisplay: 'inline',
61+
})
62+
const settingsState = store.getState() as {
63+
settings: typeof DEFAULT_SETTINGS
64+
}
65+
expect(settingsState.settings.installedSearchCountDisplay).toBe('inline')
66+
})
67+
4468
it('persists the new window blur radius when the opacity slider moves', async () => {
4569
// Arrange
4670
const store = await createStore()

0 commit comments

Comments
 (0)