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() {
)}
-
- {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();
+ });
});