diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8f536b16e..27110f225 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -223,44 +223,6 @@ pub fn run() { log::warn!("初始化 Updater 插件失败,已跳过:{e}"); } } - #[cfg(target_os = "macos")] - { - // 设置 macOS 标题栏背景色为主界面蓝色 - if let Some(window) = app.get_webview_window("main") { - use objc2::rc::Retained; - use objc2::runtime::AnyObject; - use objc2_app_kit::NSColor; - - match window.ns_window() { - Ok(ns_window_ptr) => { - if let Some(ns_window) = - unsafe { Retained::retain(ns_window_ptr as *mut AnyObject) } - { - // 使用与主界面 banner 相同的蓝色 #3498db - // #3498db = RGB(52, 152, 219) - let bg_color = unsafe { - NSColor::colorWithRed_green_blue_alpha( - 52.0 / 255.0, // R: 52 - 152.0 / 255.0, // G: 152 - 219.0 / 255.0, // B: 219 - 1.0, // Alpha: 1.0 - ) - }; - - unsafe { - use objc2::msg_send; - let _: () = - msg_send![&*ns_window, setBackgroundColor: &*bg_color]; - } - } else { - log::warn!("Failed to retain NSWindow reference"); - } - } - Err(e) => log::warn!("Failed to get NSWindow pointer: {e}"), - } - } - } - // 初始化日志 if cfg!(debug_assertions) { app.handle().plugin( diff --git a/src/App.tsx b/src/App.tsx index 5831580d1..5dd433767 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -47,6 +47,10 @@ import { Button } from "@/components/ui/button"; type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents"; +const DRAG_BAR_HEIGHT = 28; // px +const HEADER_HEIGHT = 64; // px +const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT; + function App() { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -112,7 +116,7 @@ function App() { if (event.appType === activeApp) { await refetch(); } - }, + } ); } catch (error) { console.error("[App] Failed to subscribe provider switch event", error); @@ -142,7 +146,7 @@ function App() { } catch (error) { console.error( "[App] Failed to check environment conflicts on startup:", - error, + error ); } }; @@ -158,7 +162,7 @@ function App() { if (migrated) { toast.success( t("migration.success", { defaultValue: "配置迁移成功" }), - { closeButton: true }, + { closeButton: true } ); } } catch (error) { @@ -179,10 +183,10 @@ function App() { // 合并新检测到的冲突 setEnvConflicts((prev) => { const existingKeys = new Set( - prev.map((c) => `${c.varName}:${c.sourcePath}`), + prev.map((c) => `${c.varName}:${c.sourcePath}`) ); const newConflicts = conflicts.filter( - (c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`), + (c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`) ); return [...prev, ...newConflicts]; }); @@ -194,7 +198,7 @@ function App() { } catch (error) { console.error( "[App] Failed to check environment conflicts on app switch:", - error, + error ); } }; @@ -255,7 +259,7 @@ function App() { (p) => p.sortIndex !== undefined && p.sortIndex >= newSortIndex! && - p.id !== provider.id, + p.id !== provider.id ) .map((p) => ({ id: p.id, @@ -271,7 +275,7 @@ function App() { toast.error( t("provider.sortUpdateFailed", { defaultValue: "排序更新失败", - }), + }) ); return; // 如果排序更新失败,不继续添加 } @@ -345,7 +349,7 @@ function App() { return (
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */} -
+
- {/* 全局拖拽区域(顶部 4px),避免上边框无法拖动 */} + {/* 全局拖拽区域(顶部 28px),避免上边框无法拖动 */}
{/* 环境变量警告横幅 */} {showEnvBanner && envConflicts.length > 0 && ( @@ -412,7 +416,7 @@ function App() { } catch (error) { console.error( "[App] Failed to re-check conflicts after deletion:", - error, + error ); } }} @@ -420,13 +424,18 @@ function App() { )}
-
@@ -442,7 +451,7 @@ function App() { onClick={() => setCurrentView("providers")} className="mr-2 rounded-lg" > - +

{currentView === "settings" && t("settings.title")} @@ -464,7 +473,7 @@ function App() { "text-xl font-semibold transition-colors", isProxyRunning && isCurrentAppTakeoverActive ? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300" - : "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300", + : "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300" )} > CC Switch @@ -476,7 +485,7 @@ function App() { title={t("common.settings")} className="hover:bg-black/5 dark:hover:bg-white/5" > - +

setCurrentView("settings")} /> @@ -495,7 +504,7 @@ function App() { className={addActionButtonClass} title={t("prompts.add")} > - + )} {currentView === "mcp" && ( @@ -505,7 +514,7 @@ function App() { className={addActionButtonClass} title={t("mcp.unifiedPanel.addServer")} > - + )} {currentView === "skills" && ( @@ -516,7 +525,7 @@ function App() { onClick={() => skillsPageRef.current?.refresh()} className="hover:bg-black/5 dark:hover:bg-white/5" > - + {t("skills.refresh")} @@ -536,7 +545,7 @@ function App() { -
+
{/* TODO: Agents 功能开发中,暂时隐藏入口 */} {/* {isClaudeApp && ( - - )} */} + + )} */}
@@ -589,7 +598,7 @@ function App() { size="icon" className={`ml-2 ${addActionButtonClass}`} > - + )} @@ -597,13 +606,12 @@ function App() {
-
- {renderContent()} +
+
+ {renderContent()} +
; @@ -53,9 +64,10 @@ export function ProviderList({ isProxyTakeover = false, activeProviderId, }: ProviderListProps) { + const { t } = useTranslation(); const { sortedProviders, sensors, handleDragEnd } = useDragSort( providers, - appId, + appId ); // 流式健康检查 @@ -108,13 +120,56 @@ export function ProviderList({ checkProvider(provider.id, provider.name); }; + const [searchTerm, setSearchTerm] = useState(""); + const [isSearchOpen, setIsSearchOpen] = useState(false); + const searchInputRef = useRef(null); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const key = event.key.toLowerCase(); + if ((event.metaKey || event.ctrlKey) && key === "f") { + event.preventDefault(); + setIsSearchOpen(true); + return; + } + + if (key === "escape") { + setIsSearchOpen(false); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + useEffect(() => { + if (isSearchOpen) { + const frame = requestAnimationFrame(() => { + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + }); + return () => cancelAnimationFrame(frame); + } + }, [isSearchOpen]); + + const filteredProviders = useMemo(() => { + const keyword = searchTerm.trim().toLowerCase(); + if (!keyword) return sortedProviders; + return sortedProviders.filter((provider) => { + const fields = [provider.name, provider.notes, provider.websiteUrl]; + return fields.some((field) => + field?.toString().toLowerCase().includes(keyword) + ); + }); + }, [searchTerm, sortedProviders]); + if (isLoading) { return (
{[0, 1, 2].map((index) => (
))}
@@ -125,18 +180,18 @@ export function ProviderList({ return ; } - return ( + const renderProviderList = () => ( provider.id)} + items={filteredProviders.map((provider) => provider.id)} strategy={verticalListSortingStrategy} >
- {sortedProviders.map((provider) => ( + {filteredProviders.map((provider) => ( ); + + return ( +
+ + {isSearchOpen && ( + +
+
+ + setSearchTerm(event.target.value)} + placeholder={t("provider.searchPlaceholder", { + defaultValue: "Search name, notes, or URL...", + })} + aria-label={t("provider.searchAriaLabel", { + defaultValue: "Search providers", + })} + className="pr-16 pl-9" + /> + {searchTerm && ( + + )} + +
+
+ + {t("provider.searchScopeHint", { + defaultValue: "Matches provider name, notes, and URL.", + })} + + + {t("provider.searchCloseHint", { + defaultValue: "Press Esc to close", + })} + +
+
+
+ )} +
+ + {filteredProviders.length === 0 ? ( +
+ {t("provider.noSearchResults", { + defaultValue: "No providers match your search.", + })} +
+ ) : ( + renderProviderList() + )} +
+ ); } interface SortableProviderCardProps { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 34ca1b526..051d4affc 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -87,6 +87,12 @@ "removeFromClaudePlugin": "Remove from Claude plugin", "dragToReorder": "Drag to reorder", "dragHandle": "Drag to reorder", + "searchPlaceholder": "Search name, notes, or URL...", + "searchAriaLabel": "Search providers", + "searchScopeHint": "Matches provider name, notes, and URL.", + "searchCloseHint": "Press Esc to close", + "searchCloseAriaLabel": "Close provider search", + "noSearchResults": "No providers match your search.", "duplicate": "Duplicate", "sortUpdateFailed": "Failed to update sort order", "configureUsage": "Configure usage query", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9ca5e909a..6554839b7 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -87,6 +87,12 @@ "removeFromClaudePlugin": "Claude プラグインから解除", "dragToReorder": "ドラッグで並べ替え", "dragHandle": "ドラッグで並べ替え", + "searchPlaceholder": "名前・メモ・URLで検索...", + "searchAriaLabel": "プロバイダーを検索", + "searchScopeHint": "名前・メモ・URL を対象に検索します。", + "searchCloseHint": "Esc で閉じる", + "searchCloseAriaLabel": "検索を閉じる", + "noSearchResults": "一致するプロバイダーがありません。", "duplicate": "複製", "sortUpdateFailed": "並び順の更新に失敗しました", "configureUsage": "利用状況を設定", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 5976a0cf5..2b7d7cf5e 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -87,6 +87,12 @@ "removeFromClaudePlugin": "从 Claude 插件移除", "dragToReorder": "拖拽以重新排序", "dragHandle": "拖拽排序", + "searchPlaceholder": "按名称/备注/网址搜索供应商...", + "searchAriaLabel": "搜索供应商", + "searchScopeHint": "根据名称、备注和官网链接匹配结果。", + "searchCloseHint": "按 Esc 关闭", + "searchCloseAriaLabel": "关闭供应商搜索", + "noSearchResults": "没有符合搜索条件的供应商。", "duplicate": "复制", "sortUpdateFailed": "排序更新失败", "configureUsage": "配置用量查询", diff --git a/tests/components/ProviderList.test.tsx b/tests/components/ProviderList.test.tsx index c0aef3ff2..5c8f6b3d7 100644 --- a/tests/components/ProviderList.test.tsx +++ b/tests/components/ProviderList.test.tsx @@ -251,4 +251,47 @@ describe("ProviderList Component", () => { "claude", ); }); + + it("filters providers with the search input", () => { + const providerAlpha = createProvider({ id: "alpha", name: "Alpha Labs" }); + const providerBeta = createProvider({ id: "beta", name: "Beta Works" }); + + useDragSortMock.mockReturnValue({ + sortedProviders: [providerAlpha, providerBeta], + sensors: [], + handleDragEnd: vi.fn(), + }); + + render( + , + ); + + fireEvent.keyDown(window, { key: "f", metaKey: true }); + const searchInput = screen.getByPlaceholderText( + "Search name, notes, or URL...", + ); + // Initially both providers are rendered + expect(screen.getByTestId("provider-card-alpha")).toBeInTheDocument(); + expect(screen.getByTestId("provider-card-beta")).toBeInTheDocument(); + + fireEvent.change(searchInput, { target: { value: "beta" } }); + expect(screen.queryByTestId("provider-card-alpha")).not.toBeInTheDocument(); + expect(screen.getByTestId("provider-card-beta")).toBeInTheDocument(); + + fireEvent.change(searchInput, { target: { value: "gamma" } }); + expect(screen.queryByTestId("provider-card-alpha")).not.toBeInTheDocument(); + expect(screen.queryByTestId("provider-card-beta")).not.toBeInTheDocument(); + expect( + screen.getByText("No providers match your search."), + ).toBeInTheDocument(); + }); });