From 9eb991d087575765946670d807a2d60892686370 Mon Sep 17 00:00:00 2001 From: ZyphrZero <133507172+ZyphrZero@users.noreply.github.com> Date: Wed, 15 Oct 2025 22:21:06 +0800 Subject: [PATCH] feat(ui): add drag-and-drop sorting for provider list (#126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): add drag-and-drop sorting for provider list Implement drag-and-drop functionality to allow users to reorder providers with custom sort indices. Features: - Install @dnd-kit libraries for drag-and-drop support - Add sortIndex field to Provider type (frontend & backend) - Implement SortableProviderItem component with drag handle - Add update_providers_sort_order Tauri command - Sync tray menu order with provider list sorting - Add i18n support for drag-related UI text Technical details: - Use @dnd-kit/core and @dnd-kit/sortable for smooth drag interactions - Disable animations for immediate response after drop - Update tray menu immediately after reordering - Sort priority: sortIndex → createdAt → name 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix(ui): remove unused transition variable in ProviderList Remove unused 'transition' destructured variable from useSortable hook to fix TypeScript error TS6133. The transition property is hardcoded as 'none' in the style object to prevent conflicts with drag operations. --------- Co-authored-by: Claude --- package.json | 5 +- pnpm-lock.yaml | 63 ++++- src-tauri/src/commands.rs | 47 ++++ src-tauri/src/lib.rs | 48 +++- src-tauri/src/provider.rs | 4 + src/components/ProviderList.tsx | 421 ++++++++++++++++++++++---------- src/i18n/locales/en.json | 4 +- src/i18n/locales/zh.json | 4 +- src/lib/tauri-api.ts | 17 ++ src/types.ts | 1 + src/vite-env.d.ts | 8 + 11 files changed, 482 insertions(+), 140 deletions(-) diff --git a/package.json b/package.json index 8fdf7fb..3f3b086 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,9 @@ "@codemirror/state": "^6.5.2", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.38.2", - "smol-toml": "^1.4.2", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tailwindcss/vite": "^4.1.13", "@tauri-apps/api": "^2.8.0", "@tauri-apps/plugin-dialog": "^2.4.0", @@ -46,6 +48,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^16.0.0", + "smol-toml": "^1.4.2", "tailwindcss": "^4.1.13" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55138f8..f46cb7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,15 @@ importers: '@codemirror/view': specifier: ^6.38.2 version: 6.38.2 + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@18.3.1) '@tailwindcss/vite': specifier: ^4.1.13 version: 4.1.13(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1)) @@ -220,6 +229,28 @@ packages: '@codemirror/view@6.38.2': resolution: {integrity: sha512-bTWAJxL6EOFLPzTx+O5P5xAO3gTqpatQ2b/ARQ8itfU/v2LlpS3pH2fkL0A3E/Fx8Y2St2KES7ZEV0sHTsSW/A==} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -1011,6 +1042,9 @@ packages: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + typescript@5.9.2: resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} @@ -1259,6 +1293,31 @@ snapshots: style-mod: 4.1.2 w3c-keyname: 2.2.8 + '@dnd-kit/accessibility@3.1.1(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -1878,6 +1937,8 @@ snapshots: mkdirp: 3.0.1 yallist: 5.0.0 + tslib@2.8.1: {} + typescript@5.9.2: {} undici-types@6.21.0: {} @@ -1904,4 +1965,4 @@ snapshots: yallist@3.1.1: {} - yallist@5.0.0: {} \ No newline at end of file + yallist@5.0.0: {} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 9f1cbd6..734846d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1491,5 +1491,52 @@ pub async fn set_app_config_dir_override( path: Option, ) -> Result { crate::app_store::set_app_config_dir_to_store(&app, path.as_deref())?; + Ok(true) +} + +// ===================== +// Provider Sort Order Management +// ===================== + +#[derive(serde::Deserialize)] +pub struct ProviderSortUpdate { + pub id: String, + #[serde(rename = "sortIndex")] + pub sort_index: usize, +} + +/// Update sort order for multiple providers +#[tauri::command] +pub async fn update_providers_sort_order( + state: State<'_, AppState>, + app_type: Option, + app: Option, + appType: Option, + updates: Vec, +) -> Result { + let app_type = app_type + .or_else(|| app.as_deref().map(|s| s.into())) + .or_else(|| appType.as_deref().map(|s| s.into())) + .unwrap_or(AppType::Claude); + + let mut config = state + .config + .lock() + .map_err(|e| format!("获取锁失败: {}", e))?; + + let manager = config + .get_manager_mut(&app_type) + .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + + // Update sort_index for each provider + for update in updates { + if let Some(provider) = manager.providers.get_mut(&update.id) { + provider.sort_index = Some(update.sort_index); + } + } + + drop(config); + state.save()?; + Ok(true) } \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f08b5b7..1150b38 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -49,7 +49,28 @@ fn create_tray_menu( menu_builder = menu_builder.item(&claude_header); if !claude_manager.providers.is_empty() { - for (id, provider) in &claude_manager.providers { + // Sort providers by sortIndex, then by createdAt, then by name + let mut sorted_providers: Vec<_> = claude_manager.providers.iter().collect(); + sorted_providers.sort_by(|(_, a), (_, b)| { + // Priority 1: sortIndex + match (a.sort_index, b.sort_index) { + (Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b), + (Some(_), None) => return std::cmp::Ordering::Less, + (None, Some(_)) => return std::cmp::Ordering::Greater, + _ => {} + } + // Priority 2: createdAt + match (a.created_at, b.created_at) { + (Some(time_a), Some(time_b)) => return time_a.cmp(&time_b), + (Some(_), None) => return std::cmp::Ordering::Greater, + (None, Some(_)) => return std::cmp::Ordering::Less, + _ => {} + } + // Priority 3: name + a.name.cmp(&b.name) + }); + + for (id, provider) in sorted_providers { let is_current = claude_manager.current == *id; let item = CheckMenuItem::with_id( app, @@ -84,7 +105,28 @@ fn create_tray_menu( menu_builder = menu_builder.item(&codex_header); if !codex_manager.providers.is_empty() { - for (id, provider) in &codex_manager.providers { + // Sort providers by sortIndex, then by createdAt, then by name + let mut sorted_providers: Vec<_> = codex_manager.providers.iter().collect(); + sorted_providers.sort_by(|(_, a), (_, b)| { + // Priority 1: sortIndex + match (a.sort_index, b.sort_index) { + (Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b), + (Some(_), None) => return std::cmp::Ordering::Less, + (None, Some(_)) => return std::cmp::Ordering::Greater, + _ => {} + } + // Priority 2: createdAt + match (a.created_at, b.created_at) { + (Some(time_a), Some(time_b)) => return time_a.cmp(&time_b), + (Some(_), None) => return std::cmp::Ordering::Greater, + (None, Some(_)) => return std::cmp::Ordering::Less, + _ => {} + } + // Priority 3: name + a.name.cmp(&b.name) + }); + + for (id, provider) in sorted_providers { let is_current = codex_manager.current == *id; let item = CheckMenuItem::with_id( app, @@ -460,6 +502,8 @@ pub fn run() { // app_config_dir override via Store commands::get_app_config_dir_override, commands::set_app_config_dir_override, + // provider sort order management + commands::update_providers_sort_order, // theirs: config import/export and dialogs import_export::export_config_to_file, import_export::import_config_from_file, diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index bda9127..08e9cb2 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -19,6 +19,9 @@ pub struct Provider { #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "createdAt")] pub created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "sortIndex")] + pub sort_index: Option, /// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json) #[serde(skip_serializing_if = "Option::is_none")] pub meta: Option, @@ -39,6 +42,7 @@ impl Provider { website_url, category: None, created_at: None, + sort_index: None, meta: None, } } diff --git a/src/components/ProviderList.tsx b/src/components/ProviderList.tsx index 46b9c23..71ea236 100644 --- a/src/components/ProviderList.tsx +++ b/src/components/ProviderList.tsx @@ -2,10 +2,27 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { Provider, UsageScript } from "../types"; import { AppType } from "../lib/tauri-api"; -import { Play, Edit3, Trash2, CheckCircle2, Users, Check, BarChart3 } from "lucide-react"; -import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles"; +import { Play, Edit3, Trash2, CheckCircle2, Users, Check, BarChart3, GripVertical } from "lucide-react"; +import { buttonStyles, badgeStyles, cn } from "../lib/styles"; import UsageFooter from "./UsageFooter"; import UsageScriptModal from "./UsageScriptModal"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; // 不再在列表中显示分类徽章,避免造成困惑 interface ProviderListProps { @@ -23,6 +40,177 @@ interface ProviderListProps { onProvidersUpdated?: () => Promise; } +// Sortable Provider Item Component +interface SortableProviderItemProps { + provider: Provider; + isCurrent: boolean; + apiUrl: string; + onSwitch: (id: string) => void; + onEdit: (id: string) => void; + onDelete: (id: string) => void; + onOpenUsageModal: (id: string) => void; + onUrlClick: (url: string) => Promise; + appType: AppType; + t: any; +} + +const SortableProviderItem: React.FC = ({ + provider, + isCurrent, + apiUrl, + onSwitch, + onEdit, + onDelete, + onOpenUsageModal, + onUrlClick, + appType, + t, +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + isDragging, + } = useSortable({ + id: provider.id, + animateLayoutChanges: () => false, // Disable layout animations + }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition: 'none', // No transitions at all + opacity: isDragging ? 0.5 : 1, + zIndex: isDragging ? 1000 : undefined, + }; + + return ( +
+
+ {/* Drag Handle */} +
+ +
+ +
+
+

+ {provider.name} +

+
+ + {t("provider.currentlyUsing")} +
+
+ +
+ {provider.websiteUrl ? ( + + ) : ( + + {apiUrl} + + )} +
+
+ +
+ + + + + + + +
+
+ + +
+ ); +} + const ProviderList: React.FC = ({ providers, currentProviderId, @@ -36,6 +224,18 @@ const ProviderList: React.FC = ({ const { t, i18n } = useTranslation(); const [usageModalProviderId, setUsageModalProviderId] = useState(null); + // Drag and drop sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + // 提取API地址(兼容不同供应商配置:Claude env / Codex TOML) const getApiUrl = (provider: Provider): string => { try { @@ -69,7 +269,7 @@ const ProviderList: React.FC = ({ } }; - // 列表页不再提供 Claude 插件按钮,统一在“设置”中控制 + // 列表页不再提供 Claude 插件按钮,统一在"设置"中控制 // 处理用量配置保存 const handleSaveUsageScript = async (providerId: string, script: UsageScript) => { @@ -94,27 +294,59 @@ const ProviderList: React.FC = ({ } }; - // 对供应商列表进行排序 - const sortedProviders = Object.values(providers).sort((a, b) => { - // 按添加时间排序 - // 没有时间戳的视为最早添加的(排在最前面) - // 有时间戳的按时间升序排列 - const timeA = a.createdAt || 0; - const timeB = b.createdAt || 0; + // Sort providers + const sortedProviders = React.useMemo(() => { + return Object.values(providers).sort((a, b) => { + // Priority 1: sortIndex + if (a.sortIndex !== undefined && b.sortIndex !== undefined) { + return a.sortIndex - b.sortIndex; + } + if (a.sortIndex !== undefined) return -1; + if (b.sortIndex !== undefined) return 1; - // 如果都没有时间戳,按名称排序 - if (timeA === 0 && timeB === 0) { - const locale = i18n.language === "zh" ? "zh-CN" : "en-US"; - return a.name.localeCompare(b.name, locale); + // Priority 2: createdAt + const timeA = a.createdAt || 0; + const timeB = b.createdAt || 0; + if (timeA !== 0 && timeB !== 0) return timeA - timeB; + if (timeA === 0 && timeB === 0) { + // Priority 3: name + const locale = i18n.language === "zh" ? "zh-CN" : "en-US"; + return a.name.localeCompare(b.name, locale); + } + return timeA === 0 ? -1 : 1; + }); + }, [providers, i18n.language]); + + // Handle drag end - immediate refresh + const handleDragEnd = React.useCallback(async (event: DragEndEvent) => { + const { active, over } = event; + + if (!over || active.id === over.id) return; + + const oldIndex = sortedProviders.findIndex((p) => p.id === active.id); + const newIndex = sortedProviders.findIndex((p) => p.id === over.id); + + if (oldIndex === -1 || newIndex === -1) return; + + // Calculate new sort order + const reorderedProviders = arrayMove(sortedProviders, oldIndex, newIndex); + const updates = reorderedProviders.map((provider, index) => ({ + id: provider.id, + sortIndex: index, + })); + + try { + // Save to backend and refresh immediately + await window.api.updateProvidersSortOrder(updates, appType); + onProvidersUpdated?.(); + + // Update tray menu to reflect new order + await window.api.updateTrayMenu(); + } catch (error) { + console.error("Failed to update sort order:", error); + onNotify?.(t("provider.sortUpdateFailed") || "排序更新失败", "error"); } - - // 如果只有一个没有时间戳,没有时间戳的排在前面 - if (timeA === 0) return -1; - if (timeB === 0) return 1; - - // 都有时间戳,按时间升序 - return timeA - timeB; - }); + }, [sortedProviders, appType, onProvidersUpdated, onNotify, t]); return (
@@ -131,119 +363,40 @@ const ProviderList: React.FC = ({

) : ( -
- {sortedProviders.map((provider) => { - const isCurrent = provider.id === currentProviderId; - const apiUrl = getApiUrl(provider); + + p.id)} + strategy={verticalListSortingStrategy} + > +
+ {sortedProviders.map((provider) => { + const isCurrent = provider.id === currentProviderId; + const apiUrl = getApiUrl(provider); - return ( -
-
-
-
-

- {provider.name} -

- {/* 分类徽章已移除 */} -
- - {t("provider.currentlyUsing")} -
-
- -
- {provider.websiteUrl ? ( - - ) : ( - - {apiUrl} - - )} -
-
- -
- - - - - {/* 新增:用量配置按钮 */} - - - -
-
- - {/* 用量信息 Footer */} - -
- ); - })} -
+ return ( + + ); + })} +
+ + )} {/* 用量配置模态框 */} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f2263b4..bb7acec 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -63,7 +63,9 @@ "configError": "Configuration Error", "notConfigured": "Not configured for official website", "applyToClaudePlugin": "Apply to Claude plugin", - "removeFromClaudePlugin": "Remove from Claude plugin" + "removeFromClaudePlugin": "Remove from Claude plugin", + "dragToReorder": "Drag to reorder", + "sortUpdateFailed": "Failed to update sort order" }, "notifications": { "providerSaved": "Provider configuration saved", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 28a80d2..8ff2b19 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -63,7 +63,9 @@ "configError": "配置错误", "notConfigured": "未配置官网地址", "applyToClaudePlugin": "应用到 Claude 插件", - "removeFromClaudePlugin": "从 Claude 插件移除" + "removeFromClaudePlugin": "从 Claude 插件移除", + "dragToReorder": "拖拽以重新排序", + "sortUpdateFailed": "排序更新失败" }, "notifications": { "providerSaved": "供应商配置已保存", diff --git a/src/lib/tauri-api.ts b/src/lib/tauri-api.ts index 973b2c2..219c053 100644 --- a/src/lib/tauri-api.ts +++ b/src/lib/tauri-api.ts @@ -683,6 +683,23 @@ export const tauriAPI = { throw error; } }, + + // Update providers sort order + updateProvidersSortOrder: async ( + updates: Array<{ id: string; sortIndex: number }>, + app?: AppType, + ): Promise => { + try { + return await invoke("update_providers_sort_order", { + updates, + app_type: app, + app, + }); + } catch (error) { + console.error("更新供应商排序失败:", error); + throw error; + } + }, }; // 创建全局 API 对象,兼容现有代码 diff --git a/src/types.ts b/src/types.ts index ee0150d..ae4c20b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,7 @@ export interface Provider { // 新增:供应商分类(用于差异化提示/能力开关) category?: ProviderCategory; createdAt?: number; // 添加时间戳(毫秒) + sortIndex?: number; // 排序索引(用于自定义拖拽排序) // 可选:供应商元数据(仅存于 ~/.cc-switch/config.json,不写入 live 配置) meta?: ProviderMeta; } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 8587079..d202b01 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -140,6 +140,14 @@ declare global { providerId: string, url: string, ) => Promise; + // Provider sort order management + updateProvidersSortOrder: ( + updates: Array<{ id: string; sortIndex: number }>, + app?: AppType, + ) => Promise; + // app_config_dir override via Store + getAppConfigDirOverride: () => Promise; + setAppConfigDirOverride: (path: string | null) => Promise; }; platform: { isMac: boolean;