From c4a7f575c5dda260d4dc38386374c338ff214329 Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Sun, 28 Jun 2026 19:47:38 +0300 Subject: [PATCH] feat(accounts): pin default account on top + drag-to-reorder switcher The account switcher now always renders the default (starred) account first and lets you drag the remaining accounts into any order. Default stays pinned; only non-default rows are draggable (shown via a grip handle on hover). Wraps each row in a draggable container and persists the new order through the existing reorderAccounts store action. Ordering logic extracted to pure helpers (sortDefaultFirst, reorderNonDefaultIds) in account-utils with unit tests. --- components/layout/account-switcher.tsx | 62 +++++++++++++++++++++++--- lib/__tests__/account-ordering.test.ts | 45 +++++++++++++++++++ lib/account-utils.ts | 38 ++++++++++++++++ 3 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 lib/__tests__/account-ordering.test.ts diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx index 24df5725..4ddecd61 100644 --- a/components/layout/account-switcher.tsx +++ b/components/layout/account-switcher.tsx @@ -1,12 +1,12 @@ "use client"; -import { useState, useRef, useEffect, useCallback } from "react"; +import { useState, useRef, useEffect, useCallback, useMemo } from "react"; import { createPortal } from "react-dom"; -import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle } from "lucide-react"; +import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle, GripVertical } from "lucide-react"; import { useTranslations } from "next-intl"; import { useAccountStore, type AccountEntry } from "@/stores/account-store"; import { useAuthStore } from "@/stores/auth-store"; -import { getMaxAccounts } from "@/lib/account-utils"; +import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds } from "@/lib/account-utils"; import { cn } from "@/lib/utils"; import { useRouter } from "@/i18n/navigation"; import { Avatar } from "@/components/ui/avatar"; @@ -40,6 +40,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher const accounts = useAccountStore((s) => s.accounts); const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount); + const reorderAccounts = useAccountStore((s) => s.reorderAccounts); // Read activeAccountId from authStore so the selector matches the actually-loaded // session (primaryIdentity, JMAP client). accountStore.activeAccountId is a separate // persisted copy that can drift out of sync across hydration / partial persist writes. @@ -113,6 +114,32 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher setDefaultAccount(accountId); }; + // Display order: default account pinned to the top, the rest reorderable. + const displayAccounts = useMemo(() => sortDefaultFirst(accounts), [accounts]); + + // Drag-to-rearrange (non-default accounts only; the default stays pinned). + const [dragId, setDragId] = useState(null); + const [dragOverId, setDragOverId] = useState(null); + const resetDrag = () => { setDragId(null); setDragOverId(null); }; + + const handleDragStart = (e: React.DragEvent, id: string) => { + setDragId(id); + e.dataTransfer.effectAllowed = "move"; + }; + const handleDragOver = (e: React.DragEvent, overId: string) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + if (overId !== dragOverId) setDragOverId(overId); + }; + const handleDrop = (e: React.DragEvent, overId: string) => { + e.preventDefault(); + if (dragId) { + const next = reorderNonDefaultIds(accounts, dragId, overId); + if (next) reorderAccounts(next); + } + resetDrag(); + }; + // Show the account's own identity, not the preferred sending identity - // primaryIdentity can be an alias (e.g. info@korazo.net) that differs from // the actually logged-in account (info@linusrath.de). @@ -167,15 +194,29 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher > {/* Account List */}
- {accounts.map((account) => { + {displayAccounts.map((account) => { const isActive = account.id === activeAccountId; + const isDraggable = !account.isDefault && accounts.length > 2; return ( -
+ {isDraggable && ( + + + + )} + ); })} diff --git a/lib/__tests__/account-ordering.test.ts b/lib/__tests__/account-ordering.test.ts new file mode 100644 index 00000000..4c102584 --- /dev/null +++ b/lib/__tests__/account-ordering.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { sortDefaultFirst, reorderNonDefaultIds, type OrderableAccount } from '../account-utils'; + +const acct = (id: string, isDefault = false): OrderableAccount => ({ id, isDefault }); + +describe('sortDefaultFirst', () => { + it('pins the default account to the front, preserving the rest order', () => { + const accounts = [acct('a'), acct('b', true), acct('c')]; + expect(sortDefaultFirst(accounts).map((a) => a.id)).toEqual(['b', 'a', 'c']); + }); + + it('is a no-op shape when the default is already first', () => { + const accounts = [acct('b', true), acct('a'), acct('c')]; + expect(sortDefaultFirst(accounts).map((a) => a.id)).toEqual(['b', 'a', 'c']); + }); + + it('does not mutate the input array', () => { + const accounts = [acct('a'), acct('b', true)]; + const snapshot = accounts.map((a) => a.id); + sortDefaultFirst(accounts); + expect(accounts.map((a) => a.id)).toEqual(snapshot); + }); +}); + +describe('reorderNonDefaultIds', () => { + // default 'd' stays index 0; non-defaults are a, b, c + const accounts = [acct('d', true), acct('a'), acct('b'), acct('c')]; + + it('moves a non-default onto a later position, keeping default pinned', () => { + expect(reorderNonDefaultIds(accounts, 'a', 'c')).toEqual(['d', 'b', 'c', 'a']); + }); + + it('moves a non-default earlier', () => { + expect(reorderNonDefaultIds(accounts, 'c', 'a')).toEqual(['d', 'c', 'a', 'b']); + }); + + it('returns null for a no-op (same id)', () => { + expect(reorderNonDefaultIds(accounts, 'a', 'a')).toBeNull(); + }); + + it('returns null when the default is dragged or targeted', () => { + expect(reorderNonDefaultIds(accounts, 'd', 'a')).toBeNull(); + expect(reorderNonDefaultIds(accounts, 'a', 'd')).toBeNull(); + }); +}); diff --git a/lib/account-utils.ts b/lib/account-utils.ts index ea63020f..41204e37 100644 --- a/lib/account-utils.ts +++ b/lib/account-utils.ts @@ -102,3 +102,41 @@ export function isHttp2Available(): boolean { export function getMaxAccounts(): number { return isHttp2Available() ? MAX_ACCOUNT_SLOTS : MAX_ACCOUNTS_HTTP1; } + +/** Minimal shape needed to order accounts (structural — avoids importing AccountEntry). */ +export interface OrderableAccount { + id: string; + isDefault: boolean; +} + +/** + * Display order for the account switcher: the default account first, then the + * remaining accounts in their stored order. Pure — does not mutate the input. + */ +export function sortDefaultFirst(accounts: T[]): T[] { + const defaults = accounts.filter((a) => a.isDefault); + const rest = accounts.filter((a) => !a.isDefault); + return [...defaults, ...rest]; +} + +/** + * Compute the new full account-id order after dragging `dragId` onto `overId`. + * Default account(s) stay pinned to the front; only non-default accounts are + * reordered (`dragId` is inserted at `overId`'s position among them). + * Returns null when the move is a no-op or invalid (e.g. a default is involved). + */ +export function reorderNonDefaultIds( + accounts: OrderableAccount[], + dragId: string, + overId: string, +): string[] | null { + if (dragId === overId) return null; + const defaults = accounts.filter((a) => a.isDefault).map((a) => a.id); + const nonDefault = accounts.filter((a) => !a.isDefault).map((a) => a.id); + const from = nonDefault.indexOf(dragId); + const to = nonDefault.indexOf(overId); + if (from < 0 || to < 0) return null; + const [moved] = nonDefault.splice(from, 1); + nonDefault.splice(to, 0, moved); + return [...defaults, ...nonDefault]; +}