refactor(desktop): one profile-order sort for the active strip and the at-rest groups
sortByProfileOrder moves to a pure lib module with a key selector so buildRestGroups sorts its named squares directly, replacing the collator sort that the component then re-sorted with a different comparator. The fleet rail test no longer needs importOriginal (and three store mocks) to reach the helper.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { DesktopAgentRoster, DesktopConnectionKind, DesktopRegistryConnection } from '@/global'
|
||||
import { sortConnectionsForDisplay } from '@/lib/connection-display'
|
||||
import { sortByProfileOrder } from '@/lib/profile-order'
|
||||
|
||||
// Pure grouping for the fleet profile rail: which gateways sit "at rest"
|
||||
// beside the active one, and which agents each of them carries. Kept free of
|
||||
@@ -24,7 +25,7 @@ export interface FleetGroup {
|
||||
/** The gateway's default profile — every Hermes home has one, so a group
|
||||
* always carries it even before the roster has been enumerated. */
|
||||
defaultAgent: FleetAgent
|
||||
/** Named (non-default) profiles, alphabetical for a stable strip. */
|
||||
/** Named (non-default) profiles in the user's rail order, like the active strip. */
|
||||
named: FleetAgent[]
|
||||
}
|
||||
|
||||
@@ -34,8 +35,6 @@ export function fleetRouteKey(connectionId: string, profile: string): string {
|
||||
return `${connectionId}::${profile}`
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
|
||||
|
||||
/**
|
||||
* Groups for every registered gateway EXCEPT the active one, in the same order
|
||||
* the connection switcher lists them (This device first, then by label), so the
|
||||
@@ -51,10 +50,12 @@ const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'bas
|
||||
export function buildRestGroups({
|
||||
activeConnectionId,
|
||||
connections,
|
||||
order = [],
|
||||
roster
|
||||
}: {
|
||||
activeConnectionId: null | string
|
||||
connections: readonly DesktopRegistryConnection[]
|
||||
order?: readonly string[]
|
||||
roster: DesktopAgentRoster | null
|
||||
}): FleetGroup[] {
|
||||
const groups: FleetGroup[] = []
|
||||
@@ -82,10 +83,11 @@ export function buildRestGroups({
|
||||
|
||||
const defaultRow = rows.find(row => row.profile === DEFAULT_PROFILE)
|
||||
|
||||
const named = rows
|
||||
.filter(row => row.profile !== DEFAULT_PROFILE)
|
||||
.map(row => toAgent(row.profile, row.handle))
|
||||
.sort((left, right) => collator.compare(left.profile, right.profile))
|
||||
const named = sortByProfileOrder(
|
||||
rows.filter(row => row.profile !== DEFAULT_PROFILE).map(row => toAgent(row.profile, row.handle)),
|
||||
order,
|
||||
agent => agent.profile
|
||||
)
|
||||
|
||||
groups.push({
|
||||
connectionId: connection.id,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { atom } from 'nanostores'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { DesktopAgentRoster, DesktopConnectionsRegistry } from '@/global'
|
||||
import type * as ProfileStore from '@/store/profile'
|
||||
|
||||
import { ProfileRail } from './profile-switcher'
|
||||
|
||||
@@ -67,11 +66,9 @@ vi.mock('@/i18n', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/store/gateway', () => ({ $gateway: atom(null) }))
|
||||
vi.mock('@/lib/query-client', () => ({ invalidateProfileScopedQueries: vi.fn() }))
|
||||
vi.mock('@/store/starmap', () => ({ resetStarmapGraph: vi.fn() }))
|
||||
const { sortByProfileOrder } = await import('@/lib/profile-order')
|
||||
|
||||
vi.mock('@/store/profile', async importOriginal => ({
|
||||
vi.mock('@/store/profile', () => ({
|
||||
$activeGatewayProfile: atom('default'),
|
||||
$profileColors: atom({}),
|
||||
$profileCreateRequest: atom(0),
|
||||
@@ -87,7 +84,8 @@ vi.mock('@/store/profile', async importOriginal => ({
|
||||
setProfileColor: vi.fn(),
|
||||
setProfileOrder: vi.fn(),
|
||||
setShowAllProfiles: vi.fn(),
|
||||
sortByProfileOrder: (await importOriginal<typeof ProfileStore>()).sortByProfileOrder
|
||||
sortByProfileOrder: (profiles: Array<{ name: string }>, order: string[]) =>
|
||||
sortByProfileOrder(profiles, order, profile => profile.name)
|
||||
}))
|
||||
|
||||
vi.mock('@/store/connections', () => ({
|
||||
@@ -111,7 +109,6 @@ vi.mock('./use-profile-rail-refresh-on-active', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
setApiRequestProfile: vi.fn(),
|
||||
getProfileSoul: vi.fn().mockResolvedValue({ content: '' }),
|
||||
updateProfileSoul: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -177,19 +177,9 @@ export function ProfileRail() {
|
||||
|
||||
const connections = registry?.connections
|
||||
|
||||
// At-rest named squares follow the same user order as the active strip, so
|
||||
// a gateway's squares do not rearrange when it becomes inactive.
|
||||
const restGroups = useMemo(
|
||||
() =>
|
||||
multipleConnections
|
||||
? buildRestGroups({ activeConnectionId, connections: connections ?? [], roster }).map(group => ({
|
||||
...group,
|
||||
named: sortByProfileOrder(
|
||||
group.named.map(agent => ({ name: agent.profile, agent })),
|
||||
order
|
||||
).map(({ agent }) => agent)
|
||||
}))
|
||||
: [],
|
||||
multipleConnections ? buildRestGroups({ activeConnectionId, connections: connections ?? [], order, roster }) : [],
|
||||
[activeConnectionId, connections, multipleConnections, order, roster]
|
||||
)
|
||||
|
||||
|
||||
17
apps/desktop/src/lib/profile-order.ts
Normal file
17
apps/desktop/src/lib/profile-order.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// User-defined rail order for named profile squares. Names absent from the
|
||||
// order alphabetise at the tail. Pure so both the active strip and the at-rest
|
||||
// fleet groups sort identically without pulling stores into fleet-rail.
|
||||
export function sortByProfileOrder<T>(items: readonly T[], order: readonly string[], name: (item: T) => string): T[] {
|
||||
const rank = new Map(order.map((entry, index) => [entry, index]))
|
||||
|
||||
return [...items].sort((a, b) => {
|
||||
const ra = rank.get(name(a))
|
||||
const rb = rank.get(name(b))
|
||||
|
||||
if (ra != null && rb != null) {
|
||||
return ra - rb
|
||||
}
|
||||
|
||||
return ra != null ? -1 : rb != null ? 1 : name(a).localeCompare(name(b))
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { atom, batch, computed } from 'nanostores'
|
||||
|
||||
import type { HermesConnection } from '@/global'
|
||||
import { getProfiles, hermesApi, setApiRequestProfile, STARTUP_REQUEST_TIMEOUT_MS } from '@/hermes'
|
||||
import { sortByProfileOrder as sortProfilesByOrder } from '@/lib/profile-order'
|
||||
import { invalidateProfileScopedQueries } from '@/lib/query-client'
|
||||
import {
|
||||
arraysEqual,
|
||||
@@ -65,8 +66,9 @@ export const $profiles = atom<ProfileInfo[]>(NO_PROFILES)
|
||||
// outgoing source's profiles nor blank a source we already know.
|
||||
export const $profilesByConnection = atom<ReadonlyMap<string, ProfileInfo[]>>(new Map())
|
||||
|
||||
// Registry descriptors carry their connection id; legacy primaries are keyed
|
||||
// by endpoint. Null is a reconnect blip (see setConnection), not a source.
|
||||
// Registry descriptors carry their connection id (a slug, so it never contains
|
||||
// ':'); legacy primaries are keyed by endpoint. Null is a reconnect blip (see
|
||||
// setConnection), not a source.
|
||||
function profileListSource(connection: HermesConnection | null): null | string {
|
||||
if (!connection) {
|
||||
return null
|
||||
@@ -208,18 +210,7 @@ export function setProfileOrder(names: string[]): void {
|
||||
|
||||
// Sort items by the stored order; unordered names alphabetise at the tail.
|
||||
export function sortByProfileOrder<T extends { name: string }>(items: T[], order: string[]): T[] {
|
||||
const rank = new Map(order.map((name, index) => [name, index]))
|
||||
|
||||
return [...items].sort((a, b) => {
|
||||
const ra = rank.get(a.name)
|
||||
const rb = rank.get(b.name)
|
||||
|
||||
if (ra != null && rb != null) {
|
||||
return ra - rb
|
||||
}
|
||||
|
||||
return ra != null ? -1 : rb != null ? 1 : a.name.localeCompare(b.name)
|
||||
})
|
||||
return sortProfilesByOrder(items, order, item => item.name)
|
||||
}
|
||||
|
||||
// ── Rail colors ────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user