From 3a9a8036d2cc970f2db9cd72adb06ea13fd0001d Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 19 Sep 2025 08:30:29 +0800 Subject: [PATCH] =?UTF-8?q?-=20feat(codex):=20Add=20=E2=80=9CApply=20to=20?= =?UTF-8?q?VS=20Code/Remove=20from=20VS=20Code=E2=80=9D=20button=20on=20cu?= =?UTF-8?q?rrent=20Codex=20provider=20card=20-=20feat(tauri):=20Add=20comm?= =?UTF-8?q?ands=20to=20read/write=20VS=20Code=20settings.json=20with=20cro?= =?UTF-8?q?ss-variant=20detection=20(Code/Insiders/VSCodium/OSS)=20-=20fix?= =?UTF-8?q?(vscode):=20Use=20top-level=20keys=20=E2=80=9Cchatgpt.apiBase?= =?UTF-8?q?=E2=80=9D=20and=20=E2=80=9Cchatgpt.config.preferred=5Fauth=5Fme?= =?UTF-8?q?thod=E2=80=9D=20-=20fix(vscode):=20Handle=20empty=20settings.js?= =?UTF-8?q?on=20(skip=20deletes,=20direct=20write)=20to=20avoid=20?= =?UTF-8?q?=E2=80=9CCan=20not=20delete=20in=20empty=20document=E2=80=9D=20?= =?UTF-8?q?-=20fix(windows):=20Make=20atomic=20writes=20robust=20by=20remo?= =?UTF-8?q?ving=20target=20before=20rename=20-=20ui(provider-list):=20Impr?= =?UTF-8?q?ove=20error=20surfacing=20when=20applying/removing=20-=20chore(?= =?UTF-8?q?types):=20Extend=20window.api=20typings=20and=20tauri-api=20wra?= =?UTF-8?q?ppers=20for=20VS=20Code=20commands=20-=20deps:=20Add=20jsonc-pa?= =?UTF-8?q?rser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 8 ++ src-tauri/src/commands.rs | 35 +++++++++ src-tauri/src/config.rs | 14 +++- src-tauri/src/lib.rs | 4 + src-tauri/src/vscode.rs | 61 ++++++++++++++++ src/App.tsx | 2 + src/components/ProviderList.tsx | 126 +++++++++++++++++++++++++++++++- src/lib/tauri-api.ts | 28 +++++++ src/utils/vscodeSettings.ts | 110 ++++++++++++++++++++++++++++ src/vite-env.d.ts | 4 + 11 files changed, 391 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/vscode.rs create mode 100644 src/utils/vscodeSettings.ts 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..452548a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -281,6 +281,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..32c5f4e 100644 --- a/src/components/ProviderList.tsx +++ b/src/components/ProviderList.tsx @@ -1,7 +1,9 @@ -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 } from "../utils/vscodeSettings"; // 不再在列表中显示分类徽章,避免造成困惑 interface ProviderListProps { @@ -10,6 +12,8 @@ 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 +22,8 @@ const ProviderList: React.FC = ({ onSwitch, onDelete, onEdit, + appType, + onNotify, }) => { // 提取API地址(兼容不同供应商配置:Claude env / Codex TOML) const getApiUrl = (provider: Provider): string => { @@ -46,6 +52,102 @@ const ProviderList: React.FC = ({ } }; + // 解析 Codex 配置中的 base_url(仅用于 VS Code 写入) + const getCodexBaseUrl = (provider: Provider): string | undefined => { + try { + const cfg = provider.settingsConfig; + const text = typeof cfg?.config === "string" ? cfg.config : ""; + if (!text) return undefined; + const m = text.match(/base_url\s*=\s*"([^"]+)"/); + return m && m[1] ? m[1] : undefined; + } catch { + return undefined; + } + }; + + // VS Code 按钮:仅在 Codex + 当前供应商显示;按钮文案根据是否“已应用”变化 + const [vscodeAppliedFor, setVscodeAppliedFor] = useState(null); + + // 当当前供应商或 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); + // 认为“已应用”的条件:存在任意一个我们管理的键 + const applied = detected.hasApiBase || detected.hasPreferredAuthMethod; + setVscodeAppliedFor(applied ? currentProviderId : null); + } catch { + setVscodeAppliedFor(null); + } + }; + check(); + }, [appType, currentProviderId]); + + 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"; + const baseUrl = isOfficial ? undefined : getCodexBaseUrl(provider); + const next = applyProviderToVSCode(raw, { baseUrl, isOfficial }); + + if (next === raw) { + // 幂等:没有变化也提示成功 + onNotify?.("已应用到 VS Code", "success", 1500); + setVscodeAppliedFor(provider.id); + return; + } + + await window.api.writeVSCodeSettings(next); + onNotify?.("已应用到 VS Code", "success", 1500); + setVscodeAppliedFor(provider.id); + } 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 移除", "success", 1500); + setVscodeAppliedFor(null); + return; + } + await window.api.writeVSCodeSettings(next); + onNotify?.("已从 VS Code 移除", "success", 1500); + setVscodeAppliedFor(null); + } 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) => { // 按添加时间排序 @@ -133,6 +235,28 @@ const ProviderList: React.FC = ({
+ {appType === "codex" && isCurrent && ( + + )}