diff --git a/package.json b/package.json index 9ca8611..b335bfe 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@tauri-apps/api": "^2.8.0", "@tauri-apps/plugin-process": "^2.0.0", "@tauri-apps/plugin-updater": "^2.0.0", + "jsonc-parser": "^3.2.1", "codemirror": "^6.0.2", "lucide-react": "^0.542.0", "react": "^18.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9b265b..9cb16cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: codemirror: specifier: ^6.0.2 version: 6.0.2 + jsonc-parser: + specifier: ^3.2.1 + version: 3.3.1 lucide-react: specifier: ^0.542.0 version: 0.542.0(react@18.3.1) @@ -755,6 +758,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + lightningcss-darwin-arm64@1.30.1: resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} @@ -1580,6 +1586,8 @@ snapshots: json5@2.2.3: {} + jsonc-parser@3.3.1: {} + lightningcss-darwin-arm64@1.30.1: optional: true diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8369f85..8bbf8c4 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -7,6 +7,8 @@ use tauri_plugin_opener::OpenerExt; use crate::app_config::AppType; use crate::codex_config; use crate::config::{get_claude_settings_path, ConfigStatus}; +use crate::vscode; +use crate::config; use crate::provider::Provider; use crate::store::AppState; @@ -633,3 +635,36 @@ pub async fn check_for_updates(handle: tauri::AppHandle) -> Result Ok(true) } + +/// VS Code: 获取用户 settings.json 状态 +#[tauri::command] +pub async fn get_vscode_settings_status() -> Result { + if let Some(p) = vscode::find_existing_settings() { + Ok(ConfigStatus { exists: true, path: p.to_string_lossy().to_string() }) + } else { + // 默认返回 macOS 稳定版路径(或其他平台首选项的第一个候选),但标记不存在 + let preferred = vscode::candidate_settings_paths().into_iter().next(); + Ok(ConfigStatus { exists: false, path: preferred.unwrap_or_default().to_string_lossy().to_string() }) + } +} + +/// VS Code: 读取 settings.json 文本(仅当文件存在) +#[tauri::command] +pub async fn read_vscode_settings() -> Result { + if let Some(p) = vscode::find_existing_settings() { + std::fs::read_to_string(&p).map_err(|e| format!("读取 VS Code 设置失败: {}", e)) + } else { + Err("未找到 VS Code 用户设置文件".to_string()) + } +} + +/// VS Code: 写入 settings.json 文本(仅当文件存在;不自动创建) +#[tauri::command] +pub async fn write_vscode_settings(content: String) -> Result { + if let Some(p) = vscode::find_existing_settings() { + config::write_text_file(&p, &content)?; + Ok(true) + } else { + Err("未找到 VS Code 用户设置文件".to_string()) + } +} diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 10b39c4..71d7b45 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -175,7 +175,19 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> { } } - fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?; + #[cfg(windows)] + { + // Windows 上 rename 目标存在会失败,先移除再重命名(尽量接近原子性) + if path.exists() { + let _ = fs::remove_file(path); + } + fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?; + } + + #[cfg(not(windows))] + { + fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?; + } Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2dd5dd6..abc6d48 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod app_config; mod codex_config; mod commands; mod config; +mod vscode; mod migration; mod provider; mod store; @@ -357,6 +358,9 @@ pub fn run() { commands::get_settings, commands::save_settings, commands::check_for_updates, + commands::get_vscode_settings_status, + commands::read_vscode_settings, + commands::write_vscode_settings, update_tray_menu, ]); diff --git a/src-tauri/src/vscode.rs b/src-tauri/src/vscode.rs new file mode 100644 index 0000000..be50924 --- /dev/null +++ b/src-tauri/src/vscode.rs @@ -0,0 +1,61 @@ +use std::path::{PathBuf}; + +/// 枚举可能的 VS Code 发行版配置目录名称 +fn vscode_product_dirs() -> Vec<&'static str> { + vec![ + "Code", // VS Code Stable + "Code - Insiders", // VS Code Insiders + "VSCodium", // VSCodium + "Code - OSS", // OSS 发行版 + ] +} + +/// 获取 VS Code 用户 settings.json 的候选路径列表(按优先级排序) +pub fn candidate_settings_paths() -> Vec { + let mut paths = Vec::new(); + + #[cfg(target_os = "macos")] + { + if let Some(home) = dirs::home_dir() { + for prod in vscode_product_dirs() { + paths.push( + home.join("Library").join("Application Support").join(prod).join("User").join("settings.json") + ); + } + } + } + + #[cfg(target_os = "windows")] + { + // Windows: %APPDATA%\Code\User\settings.json + if let Some(roaming) = dirs::config_dir() { + for prod in vscode_product_dirs() { + paths.push(roaming.join(prod).join("User").join("settings.json")); + } + } + } + + #[cfg(all(unix, not(target_os = "macos")))] + { + // Linux: ~/.config/Code/User/settings.json + if let Some(config) = dirs::config_dir() { + for prod in vscode_product_dirs() { + paths.push(config.join(prod).join("User").join("settings.json")); + } + } + } + + paths +} + +/// 返回第一个存在的 settings.json 路径 +pub fn find_existing_settings() -> Option { + for p in candidate_settings_paths() { + if let Ok(meta) = std::fs::metadata(&p) { + if meta.is_file() { + return Some(p); + } + } + } + None +} diff --git a/src/App.tsx b/src/App.tsx index d517384..3fd7737 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,9 +12,13 @@ import { Plus, Settings, Moon, Sun } from "lucide-react"; import { buttonStyles } from "./lib/styles"; import { useDarkMode } from "./hooks/useDarkMode"; import { extractErrorMessage } from "./utils/errorUtils"; +import { applyProviderToVSCode } from "./utils/vscodeSettings"; +import { getCodexBaseUrl } from "./utils/providerConfigUtils"; +import { useVSCodeAutoSync } from "./hooks/useVSCodeAutoSync"; function App() { const { isDarkMode, toggleDarkMode } = useDarkMode(); + const { isAutoSyncEnabled } = useVSCodeAutoSync(); const [activeApp, setActiveApp] = useState("claude"); const [providers, setProviders] = useState>({}); const [currentProviderId, setCurrentProviderId] = useState(""); @@ -76,7 +80,7 @@ function App() { }; }, []); - // 监听托盘切换事件 + // 监听托盘切换事件(包括菜单切换) useEffect(() => { let unlisten: (() => void) | null = null; @@ -91,6 +95,11 @@ function App() { if (data.appType === activeApp) { await loadProviders(); } + + // 若为 Codex 且开启自动同步,则静默同步到 VS Code(覆盖) + if (data.appType === "codex" && isAutoSyncEnabled) { + await syncCodexToVSCode(data.providerId, true); + } }); } catch (error) { console.error("设置供应商切换监听器失败:", error); @@ -105,7 +114,7 @@ function App() { unlisten(); } }; - }, [activeApp]); // 依赖activeApp,切换应用时重新设置监听器 + }, [activeApp, isAutoSyncEnabled]); const loadProviders = async () => { const loadedProviders = await window.api.getProviders(activeApp); @@ -174,6 +183,64 @@ function App() { }); }; + // 同步Codex供应商到VS Code设置(静默覆盖) + const syncCodexToVSCode = async (providerId: string, silent = false) => { + try { + const status = await window.api.getVSCodeSettingsStatus(); + if (!status.exists) { + if (!silent) { + showNotification( + "未找到 VS Code 用户设置文件 (settings.json)", + "error", + 3000, + ); + } + return; + } + + const raw = await window.api.readVSCodeSettings(); + const provider = providers[providerId]; + const isOfficial = provider?.category === "official"; + + // 非官方供应商需要解析 base_url(使用公共工具函数) + let baseUrl: string | undefined = undefined; + if (!isOfficial) { + const parsed = getCodexBaseUrl(provider); + if (!parsed) { + if (!silent) { + showNotification( + "当前配置缺少 base_url,无法写入 VS Code", + "error", + 4000, + ); + } + return; + } + baseUrl = parsed; + } + + const updatedSettings = applyProviderToVSCode(raw, { + baseUrl, + isOfficial, + }); + if (updatedSettings !== raw) { + await window.api.writeVSCodeSettings(updatedSettings); + if (!silent) { + showNotification("已同步到 VS Code", "success", 1500); + } + } + + // 触发providers重新加载,以更新VS Code按钮状态 + await loadProviders(); + } catch (error: any) { + console.error("同步到VS Code失败:", error); + if (!silent) { + const errorMessage = error?.message || "同步 VS Code 失败"; + showNotification(errorMessage, "error", 5000); + } + } + }; + const handleSwitchProvider = async (id: string) => { const success = await window.api.switchProvider(id, activeApp); if (success) { @@ -187,6 +254,11 @@ function App() { ); // 更新托盘菜单 await window.api.updateTrayMenu(); + + // Codex: 切换供应商后,只在自动同步启用时同步到 VS Code + if (activeApp === "codex" && isAutoSyncEnabled) { + await syncCodexToVSCode(id, true); // silent模式,不显示通知 + } } else { showNotification("切换失败,请检查配置", "error"); } @@ -281,6 +353,8 @@ function App() { onSwitch={handleSwitchProvider} onDelete={handleDeleteProvider} onEdit={setEditingProviderId} + appType={activeApp} + onNotify={showNotification} /> diff --git a/src/components/ProviderList.tsx b/src/components/ProviderList.tsx index f6c4a80..490764c 100644 --- a/src/components/ProviderList.tsx +++ b/src/components/ProviderList.tsx @@ -1,7 +1,15 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import { Provider } from "../types"; import { Play, Edit3, Trash2, CheckCircle2, Users } from "lucide-react"; import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles"; +import { AppType } from "../lib/tauri-api"; +import { + applyProviderToVSCode, + detectApplied, + normalizeBaseUrl, +} from "../utils/vscodeSettings"; +import { getCodexBaseUrl } from "../utils/providerConfigUtils"; +import { useVSCodeAutoSync } from "../hooks/useVSCodeAutoSync"; // 不再在列表中显示分类徽章,避免造成困惑 interface ProviderListProps { @@ -10,6 +18,12 @@ interface ProviderListProps { onSwitch: (id: string) => void; onDelete: (id: string) => void; onEdit: (id: string) => void; + appType?: AppType; + onNotify?: ( + message: string, + type: "success" | "error", + duration?: number, + ) => void; } const ProviderList: React.FC = ({ @@ -18,6 +32,8 @@ const ProviderList: React.FC = ({ onSwitch, onDelete, onEdit, + appType, + onNotify, }) => { // 提取API地址(兼容不同供应商配置:Claude env / Codex TOML) const getApiUrl = (provider: Provider): string => { @@ -29,8 +45,9 @@ const ProviderList: React.FC = ({ } // Codex: 从 TOML 配置中解析 base_url if (typeof cfg?.config === "string" && cfg.config.includes("base_url")) { - const match = cfg.config.match(/base_url\s*=\s*"([^"]+)"/); - if (match && match[1]) return match[1]; + // 支持单/双引号 + const match = cfg.config.match(/base_url\s*=\s*(['"])([^'\"]+)\1/); + if (match && match[2]) return match[2]; } return "未配置官网地址"; } catch { @@ -46,6 +63,128 @@ const ProviderList: React.FC = ({ } }; + // 解析 Codex 配置中的 base_url(已提取到公共工具) + + // VS Code 按钮:仅在 Codex + 当前供应商显示;按钮文案根据是否"已应用"变化 + const [vscodeAppliedFor, setVscodeAppliedFor] = useState(null); + const { enableAutoSync, disableAutoSync } = useVSCodeAutoSync(); + + // 当当前供应商或 appType 变化时,尝试读取 VS Code settings 并检测状态 + useEffect(() => { + const check = async () => { + if (appType !== "codex" || !currentProviderId) { + setVscodeAppliedFor(null); + return; + } + const status = await window.api.getVSCodeSettingsStatus(); + if (!status.exists) { + setVscodeAppliedFor(null); + return; + } + try { + const content = await window.api.readVSCodeSettings(); + const detected = detectApplied(content); + // 认为“已应用”的条件(非官方供应商):VS Code 中的 apiBase 与当前供应商的 base_url 完全一致 + const current = providers[currentProviderId]; + let applied = false; + if (current && current.category !== "official") { + const base = getCodexBaseUrl(current); + if (detected.apiBase && base) { + applied = + normalizeBaseUrl(detected.apiBase) === normalizeBaseUrl(base); + } + } + setVscodeAppliedFor(applied ? currentProviderId : null); + } catch { + setVscodeAppliedFor(null); + } + }; + check(); + }, [appType, currentProviderId, providers]); + + const handleApplyToVSCode = async (provider: Provider) => { + try { + const status = await window.api.getVSCodeSettingsStatus(); + if (!status.exists) { + onNotify?.( + "未找到 VS Code 用户设置文件 (settings.json)", + "error", + 3000, + ); + return; + } + + const raw = await window.api.readVSCodeSettings(); + + const isOfficial = provider.category === "official"; + // 非官方且缺少 base_url 时直接报错并返回,避免“空写入”假成功 + if (!isOfficial) { + const parsed = getCodexBaseUrl(provider); + if (!parsed) { + onNotify?.("当前配置缺少 base_url,无法写入 VS Code", "error", 4000); + return; + } + } + + const baseUrl = isOfficial ? undefined : getCodexBaseUrl(provider); + const next = applyProviderToVSCode(raw, { baseUrl, isOfficial }); + + if (next === raw) { + // 幂等:没有变化也提示成功 + onNotify?.("已应用到 VS Code,重启 Codex 插件以生效", "success", 3000); + setVscodeAppliedFor(provider.id); + // 用户手动应用时,启用自动同步 + enableAutoSync(); + return; + } + + await window.api.writeVSCodeSettings(next); + onNotify?.("已应用到 VS Code,重启 Codex 插件以生效", "success", 3000); + setVscodeAppliedFor(provider.id); + // 用户手动应用时,启用自动同步 + enableAutoSync(); + } catch (e: any) { + console.error(e); + const msg = e && e.message ? e.message : "应用到 VS Code 失败"; + onNotify?.(msg, "error", 5000); + } + }; + + const handleRemoveFromVSCode = async () => { + try { + const status = await window.api.getVSCodeSettingsStatus(); + if (!status.exists) { + onNotify?.( + "未找到 VS Code 用户设置文件 (settings.json)", + "error", + 3000, + ); + return; + } + const raw = await window.api.readVSCodeSettings(); + const next = applyProviderToVSCode(raw, { + baseUrl: undefined, + isOfficial: true, + }); + if (next === raw) { + onNotify?.("已从 VS Code 移除,重启 Codex 插件以生效", "success", 3000); + setVscodeAppliedFor(null); + // 用户手动移除时,禁用自动同步 + disableAutoSync(); + return; + } + await window.api.writeVSCodeSettings(next); + onNotify?.("已从 VS Code 移除,重启 Codex 插件以生效", "success", 3000); + setVscodeAppliedFor(null); + // 用户手动移除时,禁用自动同步 + disableAutoSync(); + } catch (e: any) { + console.error(e); + const msg = e && e.message ? e.message : "移除失败"; + onNotify?.(msg, "error", 5000); + } + }; + // 对供应商列表进行排序 const sortedProviders = Object.values(providers).sort((a, b) => { // 按添加时间排序 @@ -101,12 +240,15 @@ const ProviderList: React.FC = ({ {provider.name} {/* 分类徽章已移除 */} - {isCurrent && ( -
- - 当前使用 -
- )} +
+ + 当前使用 +
@@ -133,6 +275,32 @@ const ProviderList: React.FC = ({
+ {appType === "codex" && + provider.category !== "official" && ( + + )}
*/} + {/* VS Code 自动同步设置已移除 */} + {/* 配置文件位置 */}

diff --git a/src/hooks/useVSCodeAutoSync.ts b/src/hooks/useVSCodeAutoSync.ts new file mode 100644 index 0000000..c762eab --- /dev/null +++ b/src/hooks/useVSCodeAutoSync.ts @@ -0,0 +1,99 @@ +import { useState, useEffect, useCallback } from "react"; + +const VSCODE_AUTO_SYNC_KEY = "vscode-auto-sync-enabled"; +const VSCODE_AUTO_SYNC_EVENT = "vscode-auto-sync-changed"; + +export function useVSCodeAutoSync() { + // 默认开启自动同步;若本地存储存在记录,则以记录为准 + const [isAutoSyncEnabled, setIsAutoSyncEnabled] = useState(true); + + // 从 localStorage 读取初始状态 + useEffect(() => { + try { + const saved = localStorage.getItem(VSCODE_AUTO_SYNC_KEY); + if (saved !== null) { + setIsAutoSyncEnabled(saved === "true"); + } + } catch (error) { + console.error("读取自动同步状态失败:", error); + } + }, []); + + // 订阅同窗口的自定义事件,以及跨窗口的 storage 事件,实现全局同步 + useEffect(() => { + const onCustom = (e: Event) => { + try { + const detail = (e as CustomEvent).detail as + | { enabled?: boolean } + | undefined; + if (detail && typeof detail.enabled === "boolean") { + setIsAutoSyncEnabled(detail.enabled); + } else { + // 兜底:从 localStorage 读取 + const saved = localStorage.getItem(VSCODE_AUTO_SYNC_KEY); + if (saved !== null) setIsAutoSyncEnabled(saved === "true"); + } + } catch { + // 忽略 + } + }; + const onStorage = (e: StorageEvent) => { + if (e.key === VSCODE_AUTO_SYNC_KEY) { + setIsAutoSyncEnabled(e.newValue === "true"); + } + }; + window.addEventListener(VSCODE_AUTO_SYNC_EVENT, onCustom as EventListener); + window.addEventListener("storage", onStorage); + return () => { + window.removeEventListener( + VSCODE_AUTO_SYNC_EVENT, + onCustom as EventListener, + ); + window.removeEventListener("storage", onStorage); + }; + }, []); + + // 启用自动同步 + const enableAutoSync = useCallback(() => { + try { + localStorage.setItem(VSCODE_AUTO_SYNC_KEY, "true"); + setIsAutoSyncEnabled(true); + // 通知同窗口其他订阅者 + window.dispatchEvent( + new CustomEvent(VSCODE_AUTO_SYNC_EVENT, { detail: { enabled: true } }), + ); + } catch (error) { + console.error("保存自动同步状态失败:", error); + } + }, []); + + // 禁用自动同步 + const disableAutoSync = useCallback(() => { + try { + localStorage.setItem(VSCODE_AUTO_SYNC_KEY, "false"); + setIsAutoSyncEnabled(false); + // 通知同窗口其他订阅者 + window.dispatchEvent( + new CustomEvent(VSCODE_AUTO_SYNC_EVENT, { detail: { enabled: false } }), + ); + } catch (error) { + console.error("保存自动同步状态失败:", error); + } + }, []); + + // 切换自动同步状态 + const toggleAutoSync = useCallback(() => { + if (isAutoSyncEnabled) { + disableAutoSync(); + } else { + enableAutoSync(); + } + }, [isAutoSyncEnabled, enableAutoSync, disableAutoSync]); + + return { + isAutoSyncEnabled, + enableAutoSync, + disableAutoSync, + toggleAutoSync, + }; +} diff --git a/src/lib/styles.ts b/src/lib/styles.ts index 075fe78..76b53b5 100644 --- a/src/lib/styles.ts +++ b/src/lib/styles.ts @@ -38,7 +38,7 @@ export const cardStyles = { // 选中/激活态卡片 selected: - "bg-white rounded-lg border border-blue-500 ring-1 ring-blue-500/20 bg-blue-500/5 p-4 dark:bg-gray-900 dark:border-blue-400 dark:ring-blue-400/20 dark:bg-blue-400/10", + "bg-white rounded-lg border border-blue-500 shadow-sm bg-blue-50 p-4 dark:bg-gray-900 dark:border-blue-400 dark:bg-blue-400/10", } as const; // 输入控件样式 diff --git a/src/lib/tauri-api.ts b/src/lib/tauri-api.ts index b7989c3..e14d517 100644 --- a/src/lib/tauri-api.ts +++ b/src/lib/tauri-api.ts @@ -242,6 +242,38 @@ export const tauriAPI = { console.error("打开应用配置文件夹失败:", error); } }, + + // VS Code: 获取 settings.json 状态 + getVSCodeSettingsStatus: async (): Promise<{ + exists: boolean; + path: string; + error?: string; + }> => { + try { + return await invoke("get_vscode_settings_status"); + } catch (error) { + console.error("获取 VS Code 设置状态失败:", error); + return { exists: false, path: "", error: String(error) }; + } + }, + + // VS Code: 读取 settings.json 文本 + readVSCodeSettings: async (): Promise => { + try { + return await invoke("read_vscode_settings"); + } catch (error) { + throw new Error(`读取 VS Code 设置失败: ${String(error)}`); + } + }, + + // VS Code: 写回 settings.json 文本(不自动创建) + writeVSCodeSettings: async (content: string): Promise => { + try { + return await invoke("write_vscode_settings", { content }); + } catch (error) { + throw new Error(`写入 VS Code 设置失败: ${String(error)}`); + } + }, }; // 创建全局 API 对象,兼容现有代码 diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts index 930969f..7b2d3f1 100644 --- a/src/utils/providerConfigUtils.ts +++ b/src/utils/providerConfigUtils.ts @@ -287,3 +287,34 @@ export const hasTomlCommonConfigSnippet = ( normalizeWhitespace(snippetString), ); }; + +// ========== Codex base_url utils ========== + +// 从 Codex 的 TOML 配置文本中提取 base_url(支持单/双引号) +export const extractCodexBaseUrl = ( + configText: string | undefined | null, +): string | undefined => { + try { + const text = typeof configText === "string" ? configText : ""; + if (!text) return undefined; + const m = text.match(/base_url\s*=\s*(['"])([^'\"]+)\1/); + return m && m[2] ? m[2] : undefined; + } catch { + return undefined; + } +}; + +// 从 Provider 对象中提取 Codex base_url(当 settingsConfig.config 为 TOML 字符串时) +export const getCodexBaseUrl = ( + provider: { settingsConfig?: Record } | undefined | null, +): string | undefined => { + try { + const text = + typeof provider?.settingsConfig?.config === "string" + ? (provider as any).settingsConfig.config + : ""; + return extractCodexBaseUrl(text); + } catch { + return undefined; + } +}; diff --git a/src/utils/vscodeSettings.ts b/src/utils/vscodeSettings.ts new file mode 100644 index 0000000..09ed9b4 --- /dev/null +++ b/src/utils/vscodeSettings.ts @@ -0,0 +1,124 @@ +import { applyEdits, modify, parse } from "jsonc-parser"; + +const fmt = { insertSpaces: true, tabSize: 2, eol: "\n" } as const; + +export interface AppliedCheck { + hasApiBase: boolean; + apiBase?: string; + hasPreferredAuthMethod: boolean; +} + +export function normalizeBaseUrl(url: string): string { + return url.replace(/\/+$/, ""); +} + +const isDocEmpty = (s: string) => s.trim().length === 0; + +// 检查 settings.json(JSONC 文本)中是否已经应用了我们的键 +export function detectApplied(content: string): AppliedCheck { + try { + // 允许 JSONC 的宽松解析:jsonc-parser 的 parse 可以直接处理注释 + const data = parse(content) as any; + const apiBase = data?.["chatgpt.apiBase"]; + const method = data?.["chatgpt.config"]?.preferred_auth_method; + return { + hasApiBase: typeof apiBase === "string", + apiBase, + hasPreferredAuthMethod: typeof method === "string", + }; + } catch { + return { hasApiBase: false, hasPreferredAuthMethod: false }; + } +} + +// 生成“清理我们管理的键”后的文本(仅删除我们写入的两个键) +export function removeManagedKeys(content: string): string { + if (isDocEmpty(content)) return content; // 空文档无需删除 + let out = content; + // 删除 chatgpt.apiBase + try { + out = applyEdits( + out, + modify(out, ["chatgpt.apiBase"], undefined, { formattingOptions: fmt }), + ); + } catch { + // 忽略删除失败 + } + // 删除 chatgpt.config.preferred_auth_method(注意 chatgpt.config 是顶层带点的键) + try { + out = applyEdits( + out, + modify(out, ["chatgpt.config", "preferred_auth_method"], undefined, { + formattingOptions: fmt, + }), + ); + } catch { + // 忽略删除失败 + } + + // 兼容早期错误写入:若曾写成嵌套 chatgpt.config.preferred_auth_method,也一并清理 + try { + out = applyEdits( + out, + modify(out, ["chatgpt", "config", "preferred_auth_method"], undefined, { + formattingOptions: fmt, + }), + ); + } catch { + // 忽略删除失败 + } + + // 若 chatgpt.config 变为空对象,顺便移除(不影响其他 chatgpt* 键) + try { + const data = parse(out) as any; + const cfg = data?.["chatgpt.config"]; + if ( + cfg && + typeof cfg === "object" && + !Array.isArray(cfg) && + Object.keys(cfg).length === 0 + ) { + out = applyEdits( + out, + modify(out, ["chatgpt.config"], undefined, { formattingOptions: fmt }), + ); + } + } catch { + // 忽略解析失败,保持已删除的键 + } + + return out; +} + +// 生成“应用供应商到 VS Code”后的文本: +// - 先清理我们管理的键 +// - 再根据是否官方决定写入(官方:不写入;非官方:写入两个键) +export function applyProviderToVSCode( + content: string, + opts: { baseUrl?: string | null; isOfficial?: boolean }, +): string { + let out = removeManagedKeys(content); + if (!opts.isOfficial && opts.baseUrl) { + const apiBase = normalizeBaseUrl(opts.baseUrl); + if (isDocEmpty(out)) { + // 简化:空文档直接写入新对象 + const obj: any = { + "chatgpt.apiBase": apiBase, + "chatgpt.config": { preferred_auth_method: "apikey" }, + }; + out = JSON.stringify(obj, null, 2) + "\n"; + } else { + out = applyEdits( + out, + modify(out, ["chatgpt.apiBase"], apiBase, { formattingOptions: fmt }), + ); + out = applyEdits( + out, + modify(out, ["chatgpt.config", "preferred_auth_method"], "apikey", { + formattingOptions: fmt, + }), + ); + } + } + return out; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 2e05abd..2845baa 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -40,6 +40,10 @@ declare global { checkForUpdates: () => Promise; getAppConfigPath: () => Promise; openAppConfigFolder: () => Promise; + // VS Code settings.json 能力 + getVSCodeSettingsStatus: () => Promise; + readVSCodeSettings: () => Promise; + writeVSCodeSettings: (content: string) => Promise; }; platform: { isMac: boolean;