feat(desktop): drag-sort profiles in the rail
Make the named-profile squares reorderable via dnd-kit (horizontal sort, 4px activation so a tap still selects). Order persists in localStorage ($profileOrder); unordered/new profiles alphabetize at the tail.
This commit is contained in:
@ -1,3 +1,20 @@
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
@ -9,13 +26,16 @@ import { profileColor, profileColorSoft } from '@/lib/profile-color'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
$profileOrder,
|
||||
$profiles,
|
||||
$profileScope,
|
||||
ALL_PROFILES,
|
||||
normalizeProfileKey,
|
||||
refreshActiveProfile,
|
||||
selectProfile,
|
||||
setShowAllProfiles
|
||||
setProfileOrder,
|
||||
setShowAllProfiles,
|
||||
sortByProfileOrder
|
||||
} from '@/store/profile'
|
||||
|
||||
import { CreateProfileDialog } from '../../profiles/create-profile-dialog'
|
||||
@ -29,6 +49,7 @@ export function ProfileRail() {
|
||||
const profiles = useStore($profiles)
|
||||
const scope = useStore($profileScope)
|
||||
const gatewayProfile = useStore($activeGatewayProfile)
|
||||
const order = useStore($profileOrder)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
@ -38,7 +59,27 @@ export function ProfileRail() {
|
||||
const defaultProfile = profiles.find(profile => profile.is_default)
|
||||
const onDefault = !isAll && activeKey === 'default'
|
||||
|
||||
const named = profiles.filter(profile => !profile.is_default).sort((a, b) => a.name.localeCompare(b.name))
|
||||
const named = sortByProfileOrder(profiles.filter(profile => !profile.is_default), order)
|
||||
|
||||
// distance constraint: a small drag reorders, a tap still selects the profile.
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
)
|
||||
|
||||
const handleDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
if (!over || active.id === over.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const ids = named.map(profile => profile.name)
|
||||
const from = ids.indexOf(String(active.id))
|
||||
const to = ids.indexOf(String(over.id))
|
||||
|
||||
if (from >= 0 && to >= 0) {
|
||||
setProfileOrder(arrayMove(ids, from, to))
|
||||
}
|
||||
}
|
||||
|
||||
// Re-pull the running profile + list on mount so a profile created elsewhere
|
||||
// shows up; cheap and best-effort.
|
||||
@ -64,15 +105,19 @@ export function ProfileRail() {
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{named.map(profile => (
|
||||
<ProfileSquare
|
||||
active={!isAll && normalizeProfileKey(profile.name) === activeKey}
|
||||
color={profileColor(profile.name)}
|
||||
key={profile.name}
|
||||
label={profile.name}
|
||||
onSelect={() => selectProfile(profile.name)}
|
||||
/>
|
||||
))}
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd} sensors={sensors}>
|
||||
<SortableContext items={named.map(profile => profile.name)} strategy={horizontalListSortingStrategy}>
|
||||
{named.map(profile => (
|
||||
<ProfileSquare
|
||||
active={!isAll && normalizeProfileKey(profile.name) === activeKey}
|
||||
color={profileColor(profile.name)}
|
||||
key={profile.name}
|
||||
label={profile.name}
|
||||
onSelect={() => selectProfile(profile.name)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<Tip label="New profile">
|
||||
<button
|
||||
@ -131,26 +176,34 @@ interface ProfileSquareProps {
|
||||
|
||||
// A profile *is* its colored square — no icon-button chrome. Soft profile-tint
|
||||
// fill + the initial in the full color; the active one pops to full opacity with
|
||||
// a color ring. These pack tightly so the rail reads as a strip of profiles.
|
||||
// a color ring. These pack tightly so the rail reads as a strip of profiles,
|
||||
// and drag-sort to reorder (a tap below the drag threshold still selects).
|
||||
function ProfileSquare({ active, color, label, onSelect }: ProfileSquareProps) {
|
||||
const hue = color ?? 'var(--ui-text-quaternary)'
|
||||
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id: label })
|
||||
|
||||
return (
|
||||
<Tip label={label}>
|
||||
<button
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'grid size-5 shrink-0 place-items-center rounded-[3px] text-[0.5625rem] font-semibold uppercase leading-none transition-opacity hover:opacity-100',
|
||||
active ? 'opacity-100' : 'opacity-55'
|
||||
'grid size-5 shrink-0 cursor-grab touch-none select-none place-items-center rounded-[3px] text-[0.5625rem] font-semibold uppercase leading-none transition-opacity hover:opacity-100',
|
||||
active ? 'opacity-100' : 'opacity-55',
|
||||
isDragging && 'cursor-grabbing opacity-90'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
backgroundColor: profileColorSoft(hue, active ? 30 : 22),
|
||||
boxShadow: active ? `inset 0 0 0 1.5px ${hue}` : undefined,
|
||||
color: color ?? undefined
|
||||
color: color ?? undefined,
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition
|
||||
}}
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{label.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
|
||||
</button>
|
||||
|
||||
@ -3,7 +3,7 @@ import { atom, computed } from 'nanostores'
|
||||
import { getProfiles, setApiRequestProfile } from '@/hermes'
|
||||
import { resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
|
||||
import { queryClient } from '@/lib/query-client'
|
||||
import { persistBoolean, storedBoolean } from '@/lib/storage'
|
||||
import { arraysEqual, persistBoolean, persistStringArray, storedBoolean, storedStringArray } from '@/lib/storage'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { setConnection } from '@/store/session'
|
||||
import type { ProfileInfo } from '@/types/hermes'
|
||||
@ -30,6 +30,38 @@ export function setActiveProfile(name: string): void {
|
||||
$activeProfile.set(name || 'default')
|
||||
}
|
||||
|
||||
// ── Rail order ─────────────────────────────────────────────────────────────
|
||||
// User-defined order for the named (non-default) profile squares in the rail.
|
||||
// Names absent from the list fall back to alphabetical, appended at the tail —
|
||||
// so a freshly created profile lands at the end until the user drags it.
|
||||
const PROFILE_ORDER_STORAGE_KEY = 'hermes.desktop.profileOrder'
|
||||
|
||||
export const $profileOrder = atom<string[]>(storedStringArray(PROFILE_ORDER_STORAGE_KEY))
|
||||
|
||||
$profileOrder.subscribe(value => persistStringArray(PROFILE_ORDER_STORAGE_KEY, [...value]))
|
||||
|
||||
export function setProfileOrder(names: string[]): void {
|
||||
if (!arraysEqual($profileOrder.get(), names)) {
|
||||
$profileOrder.set(names)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
|
||||
interface ActiveProfileResponse {
|
||||
active: string
|
||||
current: string
|
||||
|
||||
Reference in New Issue
Block a user