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:
kshitijk4poor
2026-09-15 16:01:27 +05:30
committed by kshitij
parent 8751b3edd4
commit 2821cb2d8c
5 changed files with 36 additions and 39 deletions

View File

@@ -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,

View File

@@ -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()
}))

View File

@@ -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]
)

View 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))
})
}

View File

@@ -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 ────────────────────────────────────────────────────────────