mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user