diff --git a/src/renderer/src/components/cmd-j/PaletteFilterChips.tsx b/src/renderer/src/components/cmd-j/PaletteFilterChips.tsx index 4261e0e2e8e..e29915e0cba 100644 --- a/src/renderer/src/components/cmd-j/PaletteFilterChips.tsx +++ b/src/renderer/src/components/cmd-j/PaletteFilterChips.tsx @@ -30,13 +30,13 @@ export default function PaletteFilterChips({ id, label: hostLabels.get(id) ?? id })), - ...filter.projectKeys.map((id) => ({ + ...filter.repoIds.map((id) => ({ field: 'project' as const, id, label: projectLabels.get(id) ?? id })) ] - }, [filter.hostIds, filter.projectKeys, model.hosts, model.projects]) + }, [filter.hostIds, filter.repoIds, model.hosts, model.projects]) if (!isPaletteFilterActive(filter) || chips.length === 0) { return null diff --git a/src/renderer/src/components/cmd-j/PaletteFilterMenu.tsx b/src/renderer/src/components/cmd-j/PaletteFilterMenu.tsx index d9f8b77ac7a..fe792e86c56 100644 --- a/src/renderer/src/components/cmd-j/PaletteFilterMenu.tsx +++ b/src/renderer/src/components/cmd-j/PaletteFilterMenu.tsx @@ -93,11 +93,11 @@ export default function PaletteFilterMenu({ field: 'project', heading: translate('worktreeJumpPalette.filter.projects', 'Projects'), options: model.projects, - selected: filter.projectKeys + selected: filter.repoIds }) } return entries - }, [filter.hostIds, filter.projectKeys, model.hosts, model.projects]) + }, [filter.hostIds, filter.repoIds, model.hosts, model.projects]) // Stale field falls back to root if its group disappeared mid-session. const activeGroup = diff --git a/src/renderer/src/components/cmd-j/palette-filter-options.test.ts b/src/renderer/src/components/cmd-j/palette-filter-options.test.ts index 021e92a2359..cb2a7a3a14a 100644 --- a/src/renderer/src/components/cmd-j/palette-filter-options.test.ts +++ b/src/renderer/src/components/cmd-j/palette-filter-options.test.ts @@ -66,15 +66,17 @@ const buildModel = (worktrees: readonly Worktree[]) => buildPaletteFilterModel({ repos, worktrees, hostOptions, projects, projectHostSetups }) describe('buildPaletteFilterModel', () => { - it('collapses the repos of one project into a single row', () => { + it('keeps filter options repo-granular while retaining project-row membership', () => { const model = buildModel([worktree('w1', 'r1'), worktree('w2', 'r2'), worktree('w3', 'r3')]) expect(model.repoIdsByProjectKey.get('project:p1')).toEqual(['r1', 'r2']) expect(model.projects.map((option) => [option.id, option.label, option.count])).toEqual([ - ['project:p1', 'Orca', 2], - ['repo:r3', 'Solo', 1] + ['r1', 'Orca', 1], + ['r2', 'Orca (builder)', 1], + ['r3', 'Solo', 1] ]) - expect(model.projects[0]?.searchText).toBe('orca') + expect(model.projects[0]?.searchText).toContain('orca') + expect(model.projects[0]?.searchText).toContain(path.join('/repos', 'r1')) }) it('counts a worktree against its own host stamp, not its repo host', () => { @@ -88,8 +90,8 @@ describe('buildPaletteFilterModel', () => { ['local', 1], ['ssh:ssh-1', 2] ]) - // Host stamp does not move the workspace out of its project row. - expect(model.projects.find((option) => option.id === 'project:p1')?.count).toBe(3) + expect(model.projects.find((option) => option.id === 'r1')?.count).toBe(2) + expect(model.projects.find((option) => option.id === 'r2')?.count).toBe(1) }) it('omits archived worktrees from every count', () => { @@ -99,26 +101,37 @@ describe('buildPaletteFilterModel', () => { worktree('w3', 'r3', { isArchived: true }) ]) - expect(model.hosts.map((option) => option.id)).toEqual(['local']) - expect(model.hosts[0]?.count).toBe(1) - expect(model.projects.map((option) => option.id)).toEqual(['project:p1']) + expect(model.hosts.map((option) => [option.id, option.count])).toEqual([ + ['local', 1], + ['ssh:ssh-1', 0] + ]) + expect(model.projects.map((option) => [option.id, option.count])).toEqual([ + ['r1', 1], + ['r2', 0], + ['r3', 0] + ]) }) - it('offers no options at all when there is nothing to narrow', () => { + it('retains options while worktrees are loading', () => { const model = buildModel([]) - expect(model.hosts).toEqual([]) - expect(model.projects).toEqual([]) - // The mapping still resolves so a lingering selection prunes cleanly. + expect(model.hosts.map((option) => [option.id, option.count])).toEqual([ + ['local', 0], + ['ssh:ssh-1', 0] + ]) + expect(model.projects.map((option) => [option.id, option.count])).toEqual([ + ['r1', 0], + ['r2', 0], + ['r3', 0] + ]) expect(model.repoIdsByProjectKey.get('project:p1')).toEqual(['r1', 'r2']) expect(model.hostIdByRepoId.get('r2')).toBe('ssh:ssh-1') }) - it('sorts project rows by workspace count then label', () => { + it('sorts repository options by workspace count then label', () => { const model = buildModel([worktree('w1', 'r3'), worktree('w2', 'r1'), worktree('w3', 'r2')]) - // Orca has 2 workspaces, Solo has 1 — popularity beats alpha. - expect(model.projects.map((option) => option.label)).toEqual(['Orca', 'Solo']) + expect(model.projects.map((option) => option.label)).toEqual(['Orca', 'Orca (builder)', 'Solo']) }) it('prefers a busier project ahead of an alphabetically earlier quiet one', () => { @@ -131,7 +144,8 @@ describe('buildPaletteFilterModel', () => { expect(model.projects.map((option) => [option.label, option.count])).toEqual([ ['Solo', 3], - ['Orca', 1] + ['Orca', 1], + ['Orca (builder)', 0] ]) }) }) diff --git a/src/renderer/src/components/cmd-j/palette-filter-options.ts b/src/renderer/src/components/cmd-j/palette-filter-options.ts index b455354886c..ba3215f3167 100644 --- a/src/renderer/src/components/cmd-j/palette-filter-options.ts +++ b/src/renderer/src/components/cmd-j/palette-filter-options.ts @@ -42,6 +42,7 @@ function toFilterOption({ export type PaletteFilterModel = { hosts: readonly PaletteFilterOption[] + /** Repository-granular so the palette can preserve the sidebar's exact project scope. */ projects: readonly PaletteFilterOption[] /** A project row can span several repos (Project.sourceRepoIds), so selection resolves through this. */ repoIdsByProjectKey: ReadonlyMap @@ -86,29 +87,25 @@ export function resolveRepoFilterHostId( return hostIdByRepoId.get(repoId) ?? defaultHostId } -type ProjectRow = { key: string; label: string; repoIds: string[] } - -function buildProjectRows( +function buildRepoIdsByProjectKey( repos: readonly Repo[], repoMap: Map, grouping: ProjectGroupingModel -): { rows: ProjectRow[]; keyByRepoId: Map } { - const rows = new Map() - const keyByRepoId = new Map() +): Map { + const repoIdsByProjectKey = new Map() for (const repo of repos) { const target = getProjectHeaderRevealTarget(repo.id, repoMap, grouping) if (!target.repo) { continue } - const existing = rows.get(target.key) - if (existing) { - existing.repoIds.push(repo.id) + const repoIds = repoIdsByProjectKey.get(target.key) + if (repoIds) { + repoIds.push(repo.id) } else { - rows.set(target.key, { key: target.key, label: target.label, repoIds: [repo.id] }) + repoIdsByProjectKey.set(target.key, [repo.id]) } - keyByRepoId.set(repo.id, target.key) } - return { rows: [...rows.values()], keyByRepoId } + return repoIdsByProjectKey } export function buildPaletteFilterModel({ @@ -128,49 +125,43 @@ export function buildPaletteFilterModel({ }): PaletteFilterModel { const repoMap = new Map(repos.map((repo) => [repo.id, repo])) const hostIdByRepoId = buildRepoHostIndex(repos) - const { rows, keyByRepoId } = buildProjectRows(repos, repoMap, { projects, projectHostSetups }) + const repoIdsByProjectKey = buildRepoIdsByProjectKey(repos, repoMap, { + projects, + projectHostSetups + }) const worktreeCountByHostId = new Map() - const worktreeCountByProjectKey = new Map() + const worktreeCountByRepoId = new Map() for (const worktree of worktrees) { if (worktree.isArchived) { continue } const hostId = resolveWorktreeFilterHostId(worktree, hostIdByRepoId, defaultHostId) worktreeCountByHostId.set(hostId, (worktreeCountByHostId.get(hostId) ?? 0) + 1) - const projectKey = keyByRepoId.get(worktree.repoId) - if (projectKey) { - worktreeCountByProjectKey.set( - projectKey, - (worktreeCountByProjectKey.get(projectKey) ?? 0) + 1 - ) - } + worktreeCountByRepoId.set( + worktree.repoId, + (worktreeCountByRepoId.get(worktree.repoId) ?? 0) + 1 + ) } - // Why: options are gated on a live workspace count, not on configuration — an - // option that can only ever yield an empty list is a trap, and it also keeps - // stale selections self-healing through reconcilePaletteFilter. // Registry order (local first, then SSH/runtime) matches the sidebar host headers. - const hosts = hostOptions - .filter((host) => (worktreeCountByHostId.get(host.id) ?? 0) > 0) - .map((host) => - toFilterOption({ - id: host.id, - label: host.label, - detail: host.detail, - count: worktreeCountByHostId.get(host.id) ?? 0 - }) - ) + const hosts = hostOptions.map((host) => + toFilterOption({ + id: host.id, + label: host.label, + detail: host.detail, + count: worktreeCountByHostId.get(host.id) ?? 0 + }) + ) - // Popularity first so a long project list surfaces busy workspaces without search. - const projectOptions = rows - .filter((row) => (worktreeCountByProjectKey.get(row.key) ?? 0) > 0) - .map((row) => + // Keep repository IDs aligned with the sidebar; project grouping remains a row concern. + const projectOptions = repos + .map((repo) => toFilterOption({ - id: row.key, - label: row.label, - detail: '', - count: worktreeCountByProjectKey.get(row.key) ?? 0 + id: repo.id, + label: repo.displayName, + detail: repo.path, + count: worktreeCountByRepoId.get(repo.id) ?? 0 }) ) .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label) || a.id.localeCompare(b.id)) @@ -178,7 +169,7 @@ export function buildPaletteFilterModel({ return { hosts, projects: projectOptions, - repoIdsByProjectKey: new Map(rows.map((row) => [row.key, row.repoIds])), + repoIdsByProjectKey, hostIdByRepoId, defaultHostId } diff --git a/src/renderer/src/components/cmd-j/palette-filter.test.ts b/src/renderer/src/components/cmd-j/palette-filter.test.ts index 913a9c4a3d9..2c8076a7985 100644 --- a/src/renderer/src/components/cmd-j/palette-filter.test.ts +++ b/src/renderer/src/components/cmd-j/palette-filter.test.ts @@ -8,8 +8,6 @@ import { EMPTY_PALETTE_FILTER, getPaletteFilterSelectionCount, isPaletteFilterActive, - PALETTE_FILTER_MAX_SELECTIONS_PER_FIELD, - reconcilePaletteFilter, togglePaletteFilterValue, type PaletteFilterState } from './palette-filter' @@ -28,7 +26,7 @@ const option = (id: string, count = 1) => ({ // r1 + r2 are two repos behind one project row; r3 is a standalone repo row. const model: PaletteFilterModel = { hosts: [option('local'), option('ssh:builder'), option('runtime:env-1')], - projects: [option('project:p1'), option('repo:r3')], + projects: [option('r1'), option('r2'), option('r3')], repoIdsByProjectKey: new Map([ ['project:p1', ['r1', 'r2']], ['repo:r3', ['r3']] @@ -41,17 +39,17 @@ const model: PaletteFilterModel = { defaultHostId: LOCAL_EXECUTION_HOST_ID } -const filterOf = (hostIds: string[], projectKeys: string[]): PaletteFilterState => ({ +const filterOf = (hostIds: string[], repoIds: string[]): PaletteFilterState => ({ hostIds, - projectKeys + repoIds }) describe('palette filter state', () => { it('reports activity and selection count across both fields', () => { expect(isPaletteFilterActive(EMPTY_PALETTE_FILTER)).toBe(false) expect(getPaletteFilterSelectionCount(EMPTY_PALETTE_FILTER)).toBe(0) - expect(isPaletteFilterActive(filterOf([], ['project:p1']))).toBe(true) - expect(getPaletteFilterSelectionCount(filterOf(['local'], ['project:p1']))).toBe(2) + expect(isPaletteFilterActive(filterOf([], ['r1']))).toBe(true) + expect(getPaletteFilterSelectionCount(filterOf(['local'], ['r1']))).toBe(2) }) it('toggles values on and off, keeping each field sorted', () => { @@ -59,7 +57,7 @@ describe('palette filter state', () => { const withBothHosts = togglePaletteFilterValue(withHost, 'host', 'local') expect(withBothHosts.hostIds).toEqual(['local', 'ssh:builder']) - expect(withBothHosts.projectKeys).toEqual([]) + expect(withBothHosts.repoIds).toEqual([]) expect(togglePaletteFilterValue(withBothHosts, 'host', 'local').hostIds).toEqual([ 'ssh:builder' ]) @@ -69,69 +67,21 @@ describe('palette filter state', () => { const filter = togglePaletteFilterValue( togglePaletteFilterValue(EMPTY_PALETTE_FILTER, 'host', 'local'), 'project', - 'project:p1' + 'r1' ) expect(clearPaletteFilterField(filter, 'project')).toEqual(filterOf(['local'], [])) - expect(clearPaletteFilterField(filter, 'host')).toEqual(filterOf([], ['project:p1'])) + expect(clearPaletteFilterField(filter, 'host')).toEqual(filterOf([], ['r1'])) }) - it('refuses selections past the per-field cap', () => { - const saturated = filterOf( - Array.from({ length: PALETTE_FILTER_MAX_SELECTIONS_PER_FIELD }, (_, i) => `ssh:host-${i}`), - [] + it('bulk-adds every matching id without duplicating', () => { + const withOne = addPaletteFilterValues(EMPTY_PALETTE_FILTER, 'project', ['r1', 'r3', 'r1']) + expect(withOne.repoIds).toEqual(['r1', 'r3']) + + const manyIds = Array.from({ length: 501 }, (_, index) => `repo-${index}`) + expect(addPaletteFilterValues(EMPTY_PALETTE_FILTER, 'project', manyIds).repoIds).toHaveLength( + 501 ) - - const next = togglePaletteFilterValue(saturated, 'host', 'ssh:one-too-many') - - expect(next.hostIds).toHaveLength(PALETTE_FILTER_MAX_SELECTIONS_PER_FIELD) - expect(next.hostIds).not.toContain('ssh:one-too-many') - // Deselecting still works at the cap, so the user is never stuck. - expect(togglePaletteFilterValue(saturated, 'host', 'ssh:host-0').hostIds).toHaveLength( - PALETTE_FILTER_MAX_SELECTIONS_PER_FIELD - 1 - ) - }) - - it('bulk-adds matching ids up to the per-field cap without duplicating', () => { - const withOne = addPaletteFilterValues(EMPTY_PALETTE_FILTER, 'project', [ - 'project:p1', - 'repo:r3', - 'project:p1' - ]) - expect(withOne.projectKeys).toEqual(['project:p1', 'repo:r3']) - - const nearCap = filterOf( - Array.from( - { length: PALETTE_FILTER_MAX_SELECTIONS_PER_FIELD - 1 }, - (_, i) => `ssh:host-${i}` - ), - [] - ) - const filled = addPaletteFilterValues(nearCap, 'host', ['ssh:a', 'ssh:b']) - expect(filled.hostIds).toHaveLength(PALETTE_FILTER_MAX_SELECTIONS_PER_FIELD) - expect(filled.hostIds).toContain('ssh:a') - expect(filled.hostIds).not.toContain('ssh:b') - }) -}) - -describe('reconcilePaletteFilter', () => { - it('returns the same reference when every selection still exists', () => { - const filter = filterOf(['local'], ['project:p1']) - - expect(reconcilePaletteFilter(filter, model)).toBe(filter) - expect(reconcilePaletteFilter(EMPTY_PALETTE_FILTER, model)).toBe(EMPTY_PALETTE_FILTER) - }) - - it('drops selections whose host or project disappeared', () => { - const filter = filterOf(['local', 'ssh:deleted'], ['project:p1', 'repo:removed']) - - expect(reconcilePaletteFilter(filter, model)).toEqual(filterOf(['local'], ['project:p1'])) - }) - - it('empties a filter whose every selection is gone', () => { - const reconciled = reconcilePaletteFilter(filterOf(['ssh:deleted'], []), model) - - expect(isPaletteFilterActive(reconciled)).toBe(false) }) }) @@ -156,11 +106,11 @@ describe('buildPaletteFilterPredicate', () => { expect(local?.matchesWorktree({ repoId: 'never-seen' })).toBe(true) }) - it('matches every repo behind a multi-repo project row', () => { - const predicate = buildPaletteFilterPredicate(filterOf([], ['project:p1']), model) + it('keeps repository filtering exact within a multi-repo project row', () => { + const predicate = buildPaletteFilterPredicate(filterOf([], ['r1']), model) expect(predicate?.matchesWorktree({ repoId: 'r1' })).toBe(true) - expect(predicate?.matchesWorktree({ repoId: 'r2' })).toBe(true) + expect(predicate?.matchesWorktree({ repoId: 'r2' })).toBe(false) expect(predicate?.matchesWorktree({ repoId: 'r3' })).toBe(false) expect(predicate?.matchesProjectRowKey('project:p1')).toBe(true) expect(predicate?.matchesProjectRowKey('repo:r3')).toBe(false) @@ -180,21 +130,19 @@ describe('buildPaletteFilterPredicate', () => { }) it('ORs within a field and ANDs across fields', () => { - const ored = buildPaletteFilterPredicate(filterOf([], ['project:p1', 'repo:r3']), model) + const ored = buildPaletteFilterPredicate(filterOf([], ['r1', 'r3']), model) expect(ored?.matchesWorktree({ repoId: 'r1' })).toBe(true) expect(ored?.matchesWorktree({ repoId: 'r3' })).toBe(true) - // Project p1 spans local (r1) and ssh:builder (r2); adding the host axis - // narrows to the intersection rather than widening the result set. - const anded = buildPaletteFilterPredicate(filterOf(['local'], ['project:p1']), model) + const anded = buildPaletteFilterPredicate(filterOf(['local'], ['r1', 'r2']), model) expect(anded?.matchesWorktree({ repoId: 'r1' })).toBe(true) expect(anded?.matchesWorktree({ repoId: 'r2' })).toBe(false) expect(anded?.matchesProjectRowKey('project:p1')).toBe(true) expect(anded?.matchesProjectRowKey('repo:r3')).toBe(false) }) - it('never matches a stale project key that resolves to no repos', () => { - const predicate = buildPaletteFilterPredicate(filterOf([], ['project:gone']), model) + it('never matches a stale repository id', () => { + const predicate = buildPaletteFilterPredicate(filterOf([], ['repo:gone']), model) expect(predicate?.matchesWorktree({ repoId: 'r1' })).toBe(false) expect(predicate?.matchesProjectRowKey('project:p1')).toBe(false) @@ -206,10 +154,7 @@ describe('buildPaletteFilterPredicate', () => { expect(hostOnly?.matchesGroupHostId('local')).toBe(false) // A group header belongs to no project, so any project selection excludes it. - const withProject = buildPaletteFilterPredicate( - filterOf(['ssh:builder'], ['project:p1']), - model - ) + const withProject = buildPaletteFilterPredicate(filterOf(['ssh:builder'], ['r2']), model) expect(withProject?.matchesGroupHostId('ssh:builder')).toBe(false) }) }) @@ -218,60 +163,58 @@ describe('buildPaletteFilterFromSidebarScope', () => { const allHosts = { workspaceHostScope: 'all', visibleWorkspaceHostIds: null } as const it('opens unfiltered when the sidebar shows every host and project', () => { - expect(buildPaletteFilterFromSidebarScope({ ...allHosts, filterRepoIds: [] }, model)).toBe( + expect(buildPaletteFilterFromSidebarScope({ ...allHosts, filterRepoIds: [] })).toBe( EMPTY_PALETTE_FILTER ) }) it('seeds the host chips from the sidebar host scope', () => { expect( - buildPaletteFilterFromSidebarScope( - { workspaceHostScope: 'ssh:builder', visibleWorkspaceHostIds: null, filterRepoIds: [] }, - model - ) + buildPaletteFilterFromSidebarScope({ + workspaceHostScope: 'ssh:builder', + visibleWorkspaceHostIds: null, + filterRepoIds: [] + }) ).toEqual(filterOf(['ssh:builder'], [])) expect( - buildPaletteFilterFromSidebarScope( - { - workspaceHostScope: 'all', - visibleWorkspaceHostIds: ['runtime:env-1', 'local'], - filterRepoIds: [] - }, - model - ) + buildPaletteFilterFromSidebarScope({ + workspaceHostScope: 'all', + visibleWorkspaceHostIds: ['runtime:env-1', 'local'], + filterRepoIds: [] + }) ).toEqual(filterOf(['local', 'runtime:env-1'], [])) }) - it('maps sidebar repo picks onto the project rows that contain them', () => { - expect( - buildPaletteFilterFromSidebarScope({ ...allHosts, filterRepoIds: ['r2'] }, model) - ).toEqual(filterOf([], ['project:p1'])) + it('preserves sidebar repository picks exactly', () => { + expect(buildPaletteFilterFromSidebarScope({ ...allHosts, filterRepoIds: ['r2'] })).toEqual( + filterOf([], ['r2']) + ) + + const predicate = buildPaletteFilterPredicate(filterOf([], ['r2']), model) + expect(predicate?.matchesWorktree({ repoId: 'r1' })).toBe(false) + expect(predicate?.matchesWorktree({ repoId: 'r2' })).toBe(true) }) - it('treats a scope that covers every option as no filter', () => { - // Every host explicitly listed is the same as "all hosts": no chips to clear. + it('preserves explicit selections even when they currently cover every known option', () => { expect( - buildPaletteFilterFromSidebarScope( - { - workspaceHostScope: 'all', - visibleWorkspaceHostIds: ['local', 'ssh:builder', 'runtime:env-1'], - filterRepoIds: ['r1', 'r3'] - }, - model - ) - ).toBe(EMPTY_PALETTE_FILTER) + buildPaletteFilterFromSidebarScope({ + workspaceHostScope: 'all', + visibleWorkspaceHostIds: ['local', 'ssh:builder', 'runtime:env-1'], + filterRepoIds: ['r1', 'r2', 'r3'] + }) + ).toEqual(filterOf(['local', 'runtime:env-1', 'ssh:builder'], ['r1', 'r2', 'r3'])) }) - it('falls back to a global search when the scope matches nothing the palette can show', () => { - expect( - buildPaletteFilterFromSidebarScope( - { - workspaceHostScope: 'ssh:gone', - visibleWorkspaceHostIds: null, - filterRepoIds: ['r-gone'] - }, - model - ) - ).toBe(EMPTY_PALETTE_FILTER) + it('preserves empty or stale scopes instead of widening to a global search', () => { + const filter = buildPaletteFilterFromSidebarScope({ + workspaceHostScope: 'ssh:gone', + visibleWorkspaceHostIds: null, + filterRepoIds: ['r-gone'] + }) + + expect(filter).toEqual(filterOf(['ssh:gone'], ['r-gone'])) + expect(buildPaletteFilterPredicate(filter, model)?.matchesWorktree({ repoId: 'r1' })).toBe( + false + ) }) }) diff --git a/src/renderer/src/components/cmd-j/palette-filter.ts b/src/renderer/src/components/cmd-j/palette-filter.ts index 51e1952c5e6..7a257701404 100644 --- a/src/renderer/src/components/cmd-j/palette-filter.ts +++ b/src/renderer/src/components/cmd-j/palette-filter.ts @@ -15,31 +15,23 @@ export type PaletteFilterField = 'host' | 'project' */ export type PaletteFilterState = { hostIds: readonly string[] - projectKeys: readonly string[] + repoIds: readonly string[] } -export const EMPTY_PALETTE_FILTER: PaletteFilterState = { hostIds: [], projectKeys: [] } - -/** Guard against a pathological selection blowing up the predicate's Set build. */ -export const PALETTE_FILTER_MAX_SELECTIONS_PER_FIELD = 500 +export const EMPTY_PALETTE_FILTER: PaletteFilterState = { hostIds: [], repoIds: [] } export function isPaletteFilterActive(filter: PaletteFilterState): boolean { - return filter.hostIds.length > 0 || filter.projectKeys.length > 0 + return filter.hostIds.length > 0 || filter.repoIds.length > 0 } export function getPaletteFilterSelectionCount(filter: PaletteFilterState): number { - return filter.hostIds.length + filter.projectKeys.length + return filter.hostIds.length + filter.repoIds.length } function toggleValue(values: readonly string[], id: string): readonly string[] { if (values.includes(id)) { return values.filter((value) => value !== id) } - // Why: same reference on the capped no-op — a fresh array would invalidate - // every downstream search memo for a click that changed nothing. - if (values.length >= PALETTE_FILTER_MAX_SELECTIONS_PER_FIELD) { - return values - } return [...values, id].sort() } @@ -50,29 +42,26 @@ export function togglePaletteFilterValue( ): PaletteFilterState { return field === 'host' ? { ...filter, hostIds: toggleValue(filter.hostIds, id) } - : { ...filter, projectKeys: toggleValue(filter.projectKeys, id) } + : { ...filter, repoIds: toggleValue(filter.repoIds, id) } } function addValues(values: readonly string[], ids: readonly string[]): readonly string[] { - if (ids.length === 0 || values.length >= PALETTE_FILTER_MAX_SELECTIONS_PER_FIELD) { + if (ids.length === 0) { return values } const merged = new Set(values) const sizeBefore = merged.size for (const id of ids) { - if (merged.size >= PALETTE_FILTER_MAX_SELECTIONS_PER_FIELD) { - break - } merged.add(id) } - // Why: same reference when nothing new fit — keeps search memos stable. + // Why: same reference when nothing was added keeps search memos stable. if (merged.size === sizeBefore) { return values } return [...merged].sort() } -/** Bulk-add for "Select all matching"; respects the per-field cap and de-dupes. */ +/** Bulk-add for "Select all matching"; de-dupes while preserving stable no-ops. */ export function addPaletteFilterValues( filter: PaletteFilterState, field: PaletteFilterField, @@ -80,78 +69,36 @@ export function addPaletteFilterValues( ): PaletteFilterState { return field === 'host' ? { ...filter, hostIds: addValues(filter.hostIds, ids) } - : { ...filter, projectKeys: addValues(filter.projectKeys, ids) } + : { ...filter, repoIds: addValues(filter.repoIds, ids) } } export function clearPaletteFilterField( filter: PaletteFilterState, field: PaletteFilterField ): PaletteFilterState { - return field === 'host' ? { ...filter, hostIds: [] } : { ...filter, projectKeys: [] } -} - -function pruneToAvailable(values: readonly string[], available: ReadonlySet): string[] { - return values.filter((value) => available.has(value)) -} - -/** - * Drops selections whose host or project disappeared (repo removed, SSH target - * deleted). Without this a stale id would silently empty the palette forever. - * Returns the same reference when nothing changed so memo deps stay stable. - */ -export function reconcilePaletteFilter( - filter: PaletteFilterState, - model: PaletteFilterModel -): PaletteFilterState { - if (!isPaletteFilterActive(filter)) { - return filter - } - const hostIds = pruneToAvailable(filter.hostIds, new Set(model.hosts.map((host) => host.id))) - const projectKeys = pruneToAvailable( - filter.projectKeys, - new Set(model.projects.map((project) => project.id)) - ) - if ( - hostIds.length === filter.hostIds.length && - projectKeys.length === filter.projectKeys.length - ) { - return filter - } - return { hostIds, projectKeys } + return field === 'host' ? { ...filter, hostIds: [] } : { ...filter, repoIds: [] } } type SidebarScopeForPaletteFilter = Parameters[0] & { filterRepoIds: readonly string[] } -/** Omits fields that select every available option because they do not narrow results. */ +function sortedUnique(values: Iterable): string[] { + return [...new Set(values)].sort() +} + +/** Seeds the palette from the sidebar's exact host and repository scope. */ export function buildPaletteFilterFromSidebarScope( - scope: SidebarScopeForPaletteFilter, - model: PaletteFilterModel + scope: SidebarScopeForPaletteFilter ): PaletteFilterState { const visibleHostIds = getVisibleWorkspaceHostIdSet(scope) - let hostIds: readonly string[] = [] - if (visibleHostIds) { - const available = model.hosts.filter((host) => visibleHostIds.has(host.id as ExecutionHostId)) - hostIds = available.length < model.hosts.length ? available.map((host) => host.id).sort() : [] - } + const hostIds = visibleHostIds ? sortedUnique(visibleHostIds) : [] + const repoIds = sortedUnique(scope.filterRepoIds) - let projectKeys: readonly string[] = [] - if (scope.filterRepoIds.length > 0) { - const selectedRepoIds = new Set(scope.filterRepoIds) - const available = model.projects.filter((project) => - (model.repoIdsByProjectKey.get(project.id) ?? []).some((repoId) => - selectedRepoIds.has(repoId) - ) - ) - projectKeys = - available.length < model.projects.length ? available.map((project) => project.id).sort() : [] - } - - if (hostIds.length === 0 && projectKeys.length === 0) { + if (hostIds.length === 0 && repoIds.length === 0) { return EMPTY_PALETTE_FILTER } - return { hostIds, projectKeys } + return { hostIds, repoIds } } export type PaletteFilterPredicate = { @@ -175,20 +122,12 @@ export function buildPaletteFilterPredicate( } const selectedHostIds = filter.hostIds.length > 0 ? new Set(filter.hostIds) : null - const selectedProjectKeys = filter.projectKeys.length > 0 ? new Set(filter.projectKeys) : null - let selectedRepoIds: Set | null = null - if (selectedProjectKeys) { - selectedRepoIds = new Set() - for (const projectKey of selectedProjectKeys) { - for (const repoId of model.repoIdsByProjectKey.get(projectKey) ?? []) { - selectedRepoIds.add(repoId) - } - } - } + const selectedRepoIds = filter.repoIds.length > 0 ? new Set(filter.repoIds) : null return { matchesProjectRowKey: (rowKey) => { - if (selectedProjectKeys && !selectedProjectKeys.has(rowKey)) { + const rowRepoIds = model.repoIdsByProjectKey.get(rowKey) ?? [] + if (selectedRepoIds && !rowRepoIds.some((repoId) => selectedRepoIds.has(repoId))) { return false } if (!selectedHostIds) { @@ -196,7 +135,7 @@ export function buildPaletteFilterPredicate( } // Why: the row survives if *any* of its repos is on a selected host — a // project checked out on both local and SSH is still reachable from either. - return (model.repoIdsByProjectKey.get(rowKey) ?? []).some((repoId) => + return rowRepoIds.some((repoId) => selectedHostIds.has( resolveRepoFilterHostId(repoId, model.hostIdByRepoId, model.defaultHostId) ) diff --git a/src/renderer/src/components/use-worktree-jump-palette-filter.ts b/src/renderer/src/components/use-worktree-jump-palette-filter.ts index ec238cff920..1b08858d43b 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-filter.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-filter.ts @@ -1,11 +1,10 @@ -import { useEffect, useMemo } from 'react' +import { useMemo } from 'react' import { buildSidebarHostOptions } from '@/components/sidebar/sidebar-host-options' import { getProjectGroupExecutionHostIdForRows } from '@/components/sidebar/worktree-list/listing/host-filtering' import { buildPaletteFilterModel } from '@/components/cmd-j/palette-filter-options' import { buildPaletteFilterPredicate, - isPaletteFilterActive, - reconcilePaletteFilter + isPaletteFilterActive } from '@/components/cmd-j/palette-filter' import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides' @@ -26,7 +25,7 @@ type WorktreeJumpPaletteFilterInput = Pick< | 'projectHostSetups' | 'projectGroups' > & - Pick + Pick export function useWorktreeJumpPaletteFilter({ repos, @@ -39,8 +38,7 @@ export function useWorktreeJumpPaletteFilter({ projects, projectHostSetups, projectGroups, - rawFilter, - setRawFilter + rawFilter }: WorktreeJumpPaletteFilterInput) { const repoMap = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos]) const repoByHostIdentity = useMemo( @@ -83,14 +81,7 @@ export function useWorktreeJumpPaletteFilter({ }), [allWorktrees, defaultHostId, hostOptions, projectHostSetups, projects, repos] ) - const filter = useMemo( - () => reconcilePaletteFilter(rawFilter, filterModel), - [rawFilter, filterModel] - ) - useEffect(() => { - setRawFilter((current) => reconcilePaletteFilter(current, filterModel)) - // oxlint-disable-next-line react-hooks/exhaustive-deps -- local-state setter identity is stable across extraction. - }, [filterModel]) + const filter = rawFilter const filterActive = isPaletteFilterActive(filter) const hostFilterActive = filter.hostIds.length > 0 const filterPredicate = useMemo( diff --git a/src/renderer/src/components/use-worktree-jump-palette-selection-lifecycle.ts b/src/renderer/src/components/use-worktree-jump-palette-selection-lifecycle.ts index 6cb1a33463c..50e0d52178a 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-selection-lifecycle.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-selection-lifecycle.ts @@ -12,7 +12,6 @@ import { } from '@/lib/worktree-palette-create-action' import { useAppStore } from '@/store' import { CREATE_WORKSPACE_QUICK_ACTION_ITEM_ID } from './worktree-jump-palette-model' -import type { WorktreeJumpPaletteFilter } from './use-worktree-jump-palette-filter' import type { WorktreeJumpPaletteListEntries } from './use-worktree-jump-palette-list-entries' import type { WorktreeJumpPaletteLocalState } from './use-worktree-jump-palette-local-state' import type { WorktreeJumpPaletteOpenTabs } from './use-worktree-jump-palette-open-tabs' @@ -24,7 +23,6 @@ import type { WorktreeJumpPaletteWorktrees } from './use-worktree-jump-palette-w type WorktreeJumpPaletteSelectionLifecycleInput = WorktreeJumpPaletteStoreState & WorktreeJumpPaletteLocalState & - Pick & WorktreeJumpPaletteOpenTabs & WorktreeJumpPaletteProjectTargets & WorktreeJumpPaletteQuickActions & @@ -59,7 +57,6 @@ export function useWorktreeJumpPaletteSelectionLifecycle({ setQuery, setSelectedItemId, setRawFilter, - filterModel, selectionMovedByUserRef, taskSourceUrl, listRef, @@ -109,7 +106,7 @@ export function useWorktreeJumpPaletteSelectionLifecycle({ setQuery('') setSelectedItemId('') selectionMovedByUserRef.current = false - setRawFilter(buildPaletteFilterFromSidebarScope(appState, filterModel)) + setRawFilter(buildPaletteFilterFromSidebarScope(appState)) listRef.current?.scrollTo(0, 0) } if (!visible && wasVisibleRef.current) { diff --git a/tests/e2e/worktree-jump-palette-filter.spec.ts b/tests/e2e/worktree-jump-palette-filter.spec.ts index 9c5d6146ba1..19c5b8d7abc 100644 --- a/tests/e2e/worktree-jump-palette-filter.spec.ts +++ b/tests/e2e/worktree-jump-palette-filter.spec.ts @@ -8,7 +8,11 @@ const REMOTE_WORKSPACE = 'E2E Palette Remote Workspace' const REMOTE_HOST = 'E2E Palette Builder' const SEARCH_PLACEHOLDER = 'Search chats, terminals, worktrees, settings, and actions...' -type PaletteFilterFixture = { localWorktreeId: string; remoteWorktreeId: string } +type PaletteFilterFixture = { + localRepoId: string + localWorktreeId: string + remoteWorktreeId: string +} async function seedPaletteFilterFixture(page: Page): Promise { return page.evaluate( @@ -31,13 +35,14 @@ async function seedPaletteFilterFixture(page: Page): Promise - project.sourceRepoIds.includes(sourceRepo.id) - ? { ...project, displayName: localProject } - : project - ) store.setState({ repos: [ ...state.repos.map((repo) => @@ -68,7 +66,6 @@ async function seedPaletteFilterFixture(page: Page): Promise { await expect(filterTrigger(orcaPage)).not.toContainText('1') }) + test('opens with the sidebar repository scope without widening it', async ({ orcaPage }) => { + const fixture = await seedPaletteFilterFixture(orcaPage) + await orcaPage.evaluate((repoId) => { + window.__store?.getState().setFilterRepoIds([repoId]) + }, fixture.localRepoId) + + await openPalette(orcaPage) + await palette(orcaPage).getByPlaceholder(SEARCH_PLACEHOLDER).fill('E2E Palette') + + await expect(filterTrigger(orcaPage)).toContainText('1') + await expect(palette(orcaPage).getByLabel(`Remove filter ${LOCAL_PROJECT}`)).toBeVisible() + await expect(worktreeRow(orcaPage, fixture.localWorktreeId)).toBeVisible() + await expect(worktreeRow(orcaPage, fixture.remoteWorktreeId)).toHaveCount(0) + }) + test('pressing Enter creates a worktree from a typed name', async ({ orcaPage }) => { const createDialog = await openComposerFromTypedName(orcaPage)