Make palette filter repo-granular to preserve sidebar scope

Filter options now list individual repositories instead of grouping
multi-repo projects into single rows. This preserves the exact
repository scope shown in the sidebar when opening Cmd+J, rather than
widening selections to entire projects. Removes per-field selection cap
and stale-value reconciliation, simplifying the filter lifecycle.
This commit is contained in:
Jinjing
2026-09-06 13:07:05 -07:00
parent 370341e85e
commit 9258d12e09
9 changed files with 187 additions and 296 deletions
@@ -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
@@ -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 =
@@ -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]
])
})
})
@@ -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<string, readonly string[]>
@@ -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<string, Repo>,
grouping: ProjectGroupingModel
): { rows: ProjectRow[]; keyByRepoId: Map<string, string> } {
const rows = new Map<string, ProjectRow>()
const keyByRepoId = new Map<string, string>()
): Map<string, string[]> {
const repoIdsByProjectKey = new Map<string, string[]>()
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<string, number>()
const worktreeCountByProjectKey = new Map<string, number>()
const worktreeCountByRepoId = new Map<string, number>()
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
}
@@ -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
)
})
})
@@ -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>): 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<typeof getVisibleWorkspaceHostIdSet>[0] & {
filterRepoIds: readonly string[]
}
/** Omits fields that select every available option because they do not narrow results. */
function sortedUnique(values: Iterable<string>): 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<string> | null = null
if (selectedProjectKeys) {
selectedRepoIds = new Set<string>()
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)
)
@@ -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<WorktreeJumpPaletteLocalState, 'rawFilter' | 'setRawFilter'>
Pick<WorktreeJumpPaletteLocalState, 'rawFilter'>
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(
@@ -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<WorktreeJumpPaletteFilter, 'filterModel'> &
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) {
+28 -12
View File
@@ -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<PaletteFilterFixture> {
return page.evaluate(
@@ -31,13 +35,14 @@ async function seedPaletteFilterFixture(page: Page): Promise<PaletteFilterFixtur
const remoteConnectionId = `e2e-palette-host-${token}`
const remoteRepoId = `e2e-palette-remote-repo-${token}`
const remoteWorktreeId = `e2e-palette-remote-worktree-${token}`
const remoteHostId = `ssh:${remoteConnectionId}` as const
const remoteRepo = {
...sourceRepo,
id: remoteRepoId,
path: `${sourceRepo.path}-e2e-palette-remote-${token}`,
displayName: remoteProject,
connectionId: remoteConnectionId,
executionHostId: `ssh:${remoteConnectionId}`
executionHostId: remoteHostId
}
const remoteWorktree = {
...sourceWorktree,
@@ -49,18 +54,11 @@ async function seedPaletteFilterFixture(page: Page): Promise<PaletteFilterFixtur
branch: 'refs/heads/e2e-palette-remote',
isMainWorktree: false,
isArchived: false,
hostId: `ssh:${remoteConnectionId}`
hostId: remoteHostId
}
const sshTargetLabels = new Map(state.sshTargetLabels)
sshTargetLabels.set(remoteConnectionId, remoteHost)
// Filter options use project.displayName when a Project entity exists;
// renaming only the repo leaves the option labeled with the path basename.
const projects = state.projects.map((project) =>
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<PaletteFilterFixtur
),
remoteRepo
],
projects,
sshTargetLabels,
worktreesByRepo: {
...state.worktreesByRepo,
@@ -79,7 +76,11 @@ async function seedPaletteFilterFixture(page: Page): Promise<PaletteFilterFixtur
}
})
return { localWorktreeId: sourceWorktree.id, remoteWorktreeId }
return {
localRepoId: sourceRepo.id,
localWorktreeId: sourceWorktree.id,
remoteWorktreeId
}
},
{
localProject: LOCAL_PROJECT,
@@ -198,6 +199,21 @@ test.describe('Worktree jump-palette filters', () => {
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)