- feat(codex): Add “Apply to VS Code/Remove from VS Code” button on current Codex provider card

- feat(tauri): Add commands to read/write VS Code settings.json with cross-variant detection (Code/Insiders/VSCodium/OSS)
- fix(vscode): Use top-level keys “chatgpt.apiBase” and “chatgpt.config.preferred_auth_method”
- fix(vscode): Handle empty settings.json (skip deletes, direct write) to avoid “Can not delete in empty document”
- fix(windows): Make atomic writes robust by removing target before rename
- ui(provider-list): Improve error surfacing when applying/removing
- chore(types): Extend window.api typings and tauri-api wrappers for VS Code commands
- deps: Add jsonc-parser
This commit is contained in:
Jason
2025-09-19 08:30:29 +08:00
parent 04e81ebbe3
commit 3a9a8036d2
11 changed files with 391 additions and 2 deletions

View File

@@ -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",

8
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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<bool, String>
Ok(true)
}
/// VS Code: 获取用户 settings.json 状态
#[tauri::command]
pub async fn get_vscode_settings_status() -> Result<ConfigStatus, String> {
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<String, String> {
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<bool, String> {
if let Some(p) = vscode::find_existing_settings() {
config::write_text_file(&p, &content)?;
Ok(true)
} else {
Err("未找到 VS Code 用户设置文件".to_string())
}
}

View File

@@ -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(())
}

View File

@@ -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,
]);

61
src-tauri/src/vscode.rs Normal file
View File

@@ -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<PathBuf> {
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<PathBuf> {
for p in candidate_settings_paths() {
if let Ok(meta) = std::fs::metadata(&p) {
if meta.is_file() {
return Some(p);
}
}
}
None
}

View File

@@ -281,6 +281,8 @@ function App() {
onSwitch={handleSwitchProvider}
onDelete={handleDeleteProvider}
onEdit={setEditingProviderId}
appType={activeApp}
onNotify={showNotification}
/>
</div>
</div>

View File

@@ -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<ProviderListProps> = ({
@@ -18,6 +22,8 @@ const ProviderList: React.FC<ProviderListProps> = ({
onSwitch,
onDelete,
onEdit,
appType,
onNotify,
}) => {
// 提取API地址兼容不同供应商配置Claude env / Codex TOML
const getApiUrl = (provider: Provider): string => {
@@ -46,6 +52,102 @@ const ProviderList: React.FC<ProviderListProps> = ({
}
};
// 解析 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<string | null>(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<ProviderListProps> = ({
</div>
<div className="flex items-center gap-2 ml-4">
{appType === "codex" && isCurrent && (
<button
onClick={() =>
vscodeAppliedFor === provider.id
? handleRemoveFromVSCode()
: handleApplyToVSCode(provider)
}
className={cn(
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
vscodeAppliedFor === provider.id
? "bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
: "bg-emerald-500 text-white hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700",
)}
title={
vscodeAppliedFor === provider.id
? "从 VS Code 移除我们写入的配置"
: "将当前供应商应用到 VS Code"
}
>
{vscodeAppliedFor === provider.id ? "从 VS Code 移除" : "应用到 VS Code"}
</button>
)}
<button
onClick={() => onSwitch(provider.id)}
disabled={isCurrent}

View File

@@ -242,6 +242,34 @@ 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<string> => {
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<boolean> => {
try {
return await invoke("write_vscode_settings", { content });
} catch (error) {
throw new Error(`写入 VS Code 设置失败: ${String(error)}`);
}
},
};
// 创建全局 API 对象,兼容现有代码

110
src/utils/vscodeSettings.ts Normal file
View File

@@ -0,0 +1,110 @@
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.jsonJSONC 文本)中是否已经应用了我们的键
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;
}

4
src/vite-env.d.ts vendored
View File

@@ -40,6 +40,10 @@ declare global {
checkForUpdates: () => Promise<void>;
getAppConfigPath: () => Promise<string>;
openAppConfigFolder: () => Promise<void>;
// VS Code settings.json 能力
getVSCodeSettingsStatus: () => Promise<ConfigStatus>;
readVSCodeSettings: () => Promise<string>;
writeVSCodeSettings: (content: string) => Promise<boolean>;
};
platform: {
isMac: boolean;