From 483dad692bcfb9ce3aa628058a9d03a31598b790 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Wed, 10 Jun 2026 18:55:46 +0800 Subject: [PATCH] feat(uiState): implement provider UI state management with persistence --- .../providers/ProvidersWorkbenchPage.tsx | 88 ++++++++++--- .../components/ProviderResourcePanel.tsx | 7 +- .../components/ProviderResourceToolbar.tsx | 4 +- src/features/providers/types.ts | 6 + src/features/providers/uiState.ts | 116 ++++++++++++++++++ 5 files changed, 197 insertions(+), 24 deletions(-) create mode 100644 src/features/providers/uiState.ts diff --git a/src/features/providers/ProvidersWorkbenchPage.tsx b/src/features/providers/ProvidersWorkbenchPage.tsx index a256da2e..9a84f690 100644 --- a/src/features/providers/ProvidersWorkbenchPage.tsx +++ b/src/features/providers/ProvidersWorkbenchPage.tsx @@ -15,13 +15,16 @@ import { ProviderHeaderCard } from './components/ProviderHeaderCard'; import { ProviderCategoryList } from './components/ProviderCategoryList'; import { ProviderResourcePanel } from './components/ProviderResourcePanel'; import type { ProviderPanelControls } from './components/ProviderResourcePanel'; -import type { - ProviderSortBy, - SortDir, -} from './components/ProviderResourceToolbar'; import { ProviderSheet, type ProviderSheetHandle } from './sheets/ProviderSheet'; import { useProviderWorkbench } from './useProviderWorkbench'; -import type { ProviderBrand, ProviderResource } from './types'; +import { + getProviderFilterState, + readProvidersWorkbenchUiState, + writeProvidersWorkbenchUiState, + type ProviderFilterState, + type ProvidersWorkbenchUiState, +} from './uiState'; +import type { ProviderBrand, ProviderResource, ProviderSortBy, SortDir } from './types'; import styles from './ProvidersWorkbenchPage.module.scss'; type SheetMode = 'detail' | 'create' | 'edit'; @@ -129,11 +132,9 @@ export function ProvidersWorkbenchPage() { const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true; const workbench = useProviderWorkbench(); - const [activeBrand, setActiveBrand] = useState('gemini'); - const [filter, setFilter] = useState(''); - const [providerSortBy, setProviderSortBy] = useState('name'); - const [providerSortDir, setProviderSortDir] = useState('asc'); - const [selectedModels, setSelectedModels] = useState>(() => new Set()); + const [uiState, setUiState] = useState( + readProvidersWorkbenchUiState + ); const [sheetState, setSheetState] = useState({ open: false, brand: 'gemini', @@ -158,7 +159,52 @@ export function ProvidersWorkbenchPage() { const disableMutations = connectionStatus !== 'connected' || workbench.mutating; + const persistUiState = useCallback( + (updater: (prev: ProvidersWorkbenchUiState) => ProvidersWorkbenchUiState) => { + setUiState((prev) => { + const next = updater(prev); + writeProvidersWorkbenchUiState(next); + return next; + }); + }, + [] + ); + + const setActiveBrand = useCallback( + (brand: ProviderBrand) => { + persistUiState((prev) => + prev.activeBrand === brand ? prev : { ...prev, activeBrand: brand } + ); + }, + [persistUiState] + ); + + const updateActiveFilterState = useCallback( + (patch: Partial) => { + persistUiState((prev) => { + const brand = prev.activeBrand; + const current = getProviderFilterState(prev, brand); + return { + ...prev, + filtersByBrand: { + ...prev.filtersByBrand, + [brand]: { + ...current, + ...patch, + }, + }, + }; + }); + }, + [persistUiState] + ); + const groups = useMemo(() => workbench.snapshot?.groups ?? [], [workbench.snapshot]); + const activeBrand = uiState.activeBrand; + const activeFilterState = getProviderFilterState(uiState, activeBrand); + const filter = activeFilterState.filter; + const providerSortBy = activeFilterState.sortBy; + const providerSortDir = activeFilterState.sortDir; const activeGroup = groups.find((g) => g.id === activeBrand) ?? groups[0] ?? null; @@ -177,6 +223,14 @@ export function ProvidersWorkbenchPage() { return Array.from(seen).sort(); }, [activeGroup]); + const selectedModels = useMemo(() => { + if (availableModels.length === 0) return new Set(); + const availableModelSet = new Set(availableModels); + return new Set( + activeFilterState.selectedModels.filter((name) => availableModelSet.has(name)) + ); + }, [activeFilterState.selectedModels, availableModels]); + const visibleResources = useMemo(() => { let arr = filteredResources; if (selectedModels.size > 0) { @@ -219,11 +273,14 @@ export function ProvidersWorkbenchPage() { return { sortBy: providerSortBy, sortDir: providerSortDir, - onSortBy: setProviderSortBy, - onSortDir: setProviderSortDir, + onSortBy: (value: ProviderSortBy) => updateActiveFilterState({ sortBy: value }), + onSortDir: (value: SortDir) => updateActiveFilterState({ sortDir: value }), availableModels, selectedModels, - onSelectedModelsChange: setSelectedModels, + onSelectedModelsChange: (next) => + updateActiveFilterState({ + selectedModels: Array.from(next).sort((a, b) => a.localeCompare(b)), + }), }; }, [ activeGroup, @@ -231,6 +288,7 @@ export function ProvidersWorkbenchPage() { providerSortBy, providerSortDir, selectedModels, + updateActiveFilterState, ]); const totalResources = useMemo( @@ -423,8 +481,6 @@ export function ProvidersWorkbenchPage() { void proceed.then((ok) => { if (!ok) return; setActiveBrand(brand); - setFilter(''); - setSelectedModels(new Set()); if (isSwitching) { closeSheet(); } @@ -434,7 +490,7 @@ export function ProvidersWorkbenchPage() { updateActiveFilterState({ filter: value })} filteredResources={visibleResources} selectedId={sheetState.open ? sheetState.resource?.id ?? null : null} disableMutations={disableMutations} diff --git a/src/features/providers/components/ProviderResourcePanel.tsx b/src/features/providers/components/ProviderResourcePanel.tsx index 49fafa2c..13542488 100644 --- a/src/features/providers/components/ProviderResourcePanel.tsx +++ b/src/features/providers/components/ProviderResourcePanel.tsx @@ -9,11 +9,8 @@ import { IconPlus, IconSearch } from '@/components/ui/icons'; import type { ProviderRecentUsageMap } from '@/components/providers/utils'; import type { ProviderBrand, ProviderGroup, ProviderResource } from '../types'; import { ProviderResourceTable } from './ProviderResourceTable'; -import { - ProviderResourceToolbar, - type ProviderSortBy, - type SortDir, -} from './ProviderResourceToolbar'; +import { ProviderResourceToolbar } from './ProviderResourceToolbar'; +import type { ProviderSortBy, SortDir } from '../types'; import styles from './ProviderResourcePanel.module.scss'; const LOGOS: Record = { diff --git a/src/features/providers/components/ProviderResourceToolbar.tsx b/src/features/providers/components/ProviderResourceToolbar.tsx index b4acb351..ec71c841 100644 --- a/src/features/providers/components/ProviderResourceToolbar.tsx +++ b/src/features/providers/components/ProviderResourceToolbar.tsx @@ -7,11 +7,9 @@ import { } from '@/components/ui/icons'; import { Select } from '@/components/ui/Select'; import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox'; +import type { ProviderSortBy, SortDir } from '../types'; import styles from './ProviderResourceToolbar.module.scss'; -export type ProviderSortBy = 'name' | 'priority' | 'recent-success'; -export type SortDir = 'asc' | 'desc'; - interface ProviderResourceToolbarProps { sortBy: ProviderSortBy; sortDir: SortDir; diff --git a/src/features/providers/types.ts b/src/features/providers/types.ts index 7b7329a1..b269041e 100644 --- a/src/features/providers/types.ts +++ b/src/features/providers/types.ts @@ -10,6 +10,12 @@ export type ProviderBrand = | 'openaiCompatibility' | 'ampcode'; +export const PROVIDER_SORT_BY_VALUES = ['name', 'priority', 'recent-success'] as const; +export type ProviderSortBy = (typeof PROVIDER_SORT_BY_VALUES)[number]; + +export const SORT_DIR_VALUES = ['asc', 'desc'] as const; +export type SortDir = (typeof SORT_DIR_VALUES)[number]; + export type ProviderResourceSelector = | { brand: 'gemini'; apiKey: string; baseUrl?: string; index: number } | { brand: 'codex'; apiKey: string; baseUrl?: string; index: number } diff --git a/src/features/providers/uiState.ts b/src/features/providers/uiState.ts new file mode 100644 index 00000000..d6559321 --- /dev/null +++ b/src/features/providers/uiState.ts @@ -0,0 +1,116 @@ +import { PROVIDER_BRAND_ORDER } from './descriptors'; +import { + PROVIDER_SORT_BY_VALUES, + SORT_DIR_VALUES, + type ProviderBrand, + type ProviderSortBy, + type SortDir, +} from './types'; + +const PROVIDERS_UI_STATE_KEY = 'providersPage.uiState'; +const DEFAULT_ACTIVE_BRAND: ProviderBrand = 'gemini'; +const DEFAULT_PROVIDER_FILTER_STATE: ProviderFilterState = { + filter: '', + sortBy: 'name', + sortDir: 'asc', + selectedModels: [], +}; + +const PROVIDER_BRAND_SET = new Set(PROVIDER_BRAND_ORDER); +const PROVIDER_SORT_BY_SET = new Set(PROVIDER_SORT_BY_VALUES); +const SORT_DIR_SET = new Set(SORT_DIR_VALUES); + +export interface ProviderFilterState { + filter: string; + sortBy: ProviderSortBy; + sortDir: SortDir; + selectedModels: string[]; +} + +export interface ProvidersWorkbenchUiState { + activeBrand: ProviderBrand; + filtersByBrand: Partial>; +} + +const isRecord = (value: unknown): value is Record => + Boolean(value && typeof value === 'object' && !Array.isArray(value)); + +const isProviderBrand = (value: unknown): value is ProviderBrand => + typeof value === 'string' && PROVIDER_BRAND_SET.has(value as ProviderBrand); + +const isProviderSortBy = (value: unknown): value is ProviderSortBy => + typeof value === 'string' && PROVIDER_SORT_BY_SET.has(value as ProviderSortBy); + +const isSortDir = (value: unknown): value is SortDir => + typeof value === 'string' && SORT_DIR_SET.has(value as SortDir); + +const normalizeSelectedModels = (value: unknown): string[] => { + if (!Array.isArray(value)) return []; + const seen = new Set(); + value.forEach((item) => { + if (typeof item !== 'string') return; + const name = item.trim(); + if (!name) return; + seen.add(name); + }); + return Array.from(seen); +}; + +const normalizeProviderFilterState = (value: unknown): ProviderFilterState => { + if (!isRecord(value)) return { ...DEFAULT_PROVIDER_FILTER_STATE }; + return { + filter: typeof value.filter === 'string' ? value.filter : '', + sortBy: isProviderSortBy(value.sortBy) ? value.sortBy : 'name', + sortDir: isSortDir(value.sortDir) ? value.sortDir : 'asc', + selectedModels: normalizeSelectedModels(value.selectedModels), + }; +}; + +const createDefaultProvidersWorkbenchUiState = (): ProvidersWorkbenchUiState => ({ + activeBrand: DEFAULT_ACTIVE_BRAND, + filtersByBrand: {}, +}); + +export const getProviderFilterState = ( + state: ProvidersWorkbenchUiState, + brand: ProviderBrand +): ProviderFilterState => state.filtersByBrand[brand] ?? DEFAULT_PROVIDER_FILTER_STATE; + +export const readProvidersWorkbenchUiState = (): ProvidersWorkbenchUiState => { + if (typeof window === 'undefined') return createDefaultProvidersWorkbenchUiState(); + + try { + const raw = window.localStorage.getItem(PROVIDERS_UI_STATE_KEY); + if (!raw) return createDefaultProvidersWorkbenchUiState(); + + const parsed = JSON.parse(raw); + if (!isRecord(parsed)) return createDefaultProvidersWorkbenchUiState(); + + const source = isRecord(parsed.filtersByBrand) ? parsed.filtersByBrand : {}; + const filtersByBrand: ProvidersWorkbenchUiState['filtersByBrand'] = {}; + PROVIDER_BRAND_ORDER.forEach((brand) => { + const filterState = source[brand]; + if (filterState !== undefined) { + filtersByBrand[brand] = normalizeProviderFilterState(filterState); + } + }); + + return { + activeBrand: isProviderBrand(parsed.activeBrand) + ? parsed.activeBrand + : DEFAULT_ACTIVE_BRAND, + filtersByBrand, + }; + } catch { + return createDefaultProvidersWorkbenchUiState(); + } +}; + +export const writeProvidersWorkbenchUiState = (state: ProvidersWorkbenchUiState) => { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(PROVIDERS_UI_STATE_KEY, JSON.stringify(state)); + } catch { + // ignore storage failures + } +};