mirror of
https://github.com/yynxxxxx/Codex-X.git
synced 2026-10-18 05:59:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7610f9b12 | ||
|
|
e4822237fb | ||
|
|
5d18b2555e | ||
|
|
2ace729801 |
@@ -223,7 +223,7 @@ jobs:
|
||||
|
||||
- name: Test Windows filesystem compatibility
|
||||
if: matrix.platform == 'windows-latest'
|
||||
run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked
|
||||
run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked -- --test-threads=1
|
||||
|
||||
- name: Import Apple signing certificate and notarization key
|
||||
if: matrix.macos
|
||||
|
||||
@@ -4,6 +4,26 @@ All notable changes to Codex-X will be documented here.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.3.15] - 2026-08-20
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复 Windows 重启 Codex 时仅隐藏到托盘、后台未退出,以及再次重启报错的问题。
|
||||
|
||||
## [v0.3.14] - 2026-08-20
|
||||
|
||||
### 更新
|
||||
|
||||
- MCP 和 Skills 支持添加自定义备注。
|
||||
- 供应商支持复制,同一 API 平台可保存多个模型或相同配置,复制项会添加到列表末尾。
|
||||
- 供应商列表不再显示重复的模型信息。
|
||||
- 设置页新增“重启 Codex”按钮,支持 macOS 和 Windows。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复相同供应商配置被覆盖,以及复制当前供应商后启用状态丢失的问题。
|
||||
- 修复子代理会话被误判为“待同步”的问题。
|
||||
|
||||
## [v0.3.13] - 2026-08-18
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codex-x",
|
||||
"version": "0.3.13",
|
||||
"version": "0.3.15",
|
||||
"private": true,
|
||||
"description": "Codex Switch & Instruct desktop manager",
|
||||
"type": "module",
|
||||
|
||||
Generated
+1
-1
@@ -490,7 +490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codex-x"
|
||||
version = "0.3.13"
|
||||
version = "0.3.15"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codex-x"
|
||||
version = "0.3.13"
|
||||
version = "0.3.15"
|
||||
description = "Codex Switch & Instruct desktop manager"
|
||||
authors = ["yynxxxxx"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
const APP_DB_SCHEMA_VERSION: i64 = 1;
|
||||
const APP_DB_SCHEMA_VERSION: i64 = 3;
|
||||
|
||||
struct DatabaseInitializer {
|
||||
migration_lock: Mutex<()>,
|
||||
@@ -193,6 +193,19 @@ fn initialize_schema(conn: &Connection) -> Result<()> {
|
||||
content_hash TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS skills_mcp_notes (
|
||||
codex_dir TEXT NOT NULL,
|
||||
item_kind TEXT NOT NULL CHECK(item_kind IN ('skill', 'mcp')),
|
||||
item_id TEXT NOT NULL,
|
||||
note TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(codex_dir, item_kind, item_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS active_provider_selections (
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
provider_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
@@ -319,7 +332,10 @@ mod tests {
|
||||
let path = test_db_path("replace-database");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let conn = initializer.open_at(&path).expect("initialize database");
|
||||
assert_eq!(schema_version(&conn).expect("read schema version"), 1);
|
||||
assert_eq!(
|
||||
schema_version(&conn).expect("read schema version"),
|
||||
APP_DB_SCHEMA_VERSION
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
fs::remove_file(&path).expect("replace initialized database");
|
||||
@@ -330,11 +346,77 @@ mod tests {
|
||||
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
|
||||
.expect("query replacement database schema");
|
||||
assert_eq!(provider_count, 0);
|
||||
assert_eq!(schema_version(&conn).expect("read replacement version"), 1);
|
||||
assert_eq!(
|
||||
schema_version(&conn).expect("read replacement version"),
|
||||
APP_DB_SCHEMA_VERSION
|
||||
);
|
||||
drop(conn);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_one_database_migrates_new_metadata_tables_without_data_loss() {
|
||||
let path = test_db_path("skills-mcp-notes-migration");
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).expect("create legacy database directory");
|
||||
}
|
||||
let legacy = Connection::open(&path).expect("create legacy database");
|
||||
legacy
|
||||
.execute_batch(
|
||||
"CREATE TABLE managed_skills (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
directory TEXT NOT NULL,
|
||||
source_path TEXT,
|
||||
content_hash TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO managed_skills
|
||||
(id, name, directory, enabled, updated_at)
|
||||
VALUES ('legacy-skill', 'Legacy skill', 'legacy-skill', 1, '2026-01-01T00:00:00Z');
|
||||
PRAGMA user_version = 1;",
|
||||
)
|
||||
.expect("seed version one database");
|
||||
drop(legacy);
|
||||
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let migrated = initializer.open_at(&path).expect("migrate database");
|
||||
assert_eq!(
|
||||
schema_version(&migrated).expect("read migrated schema version"),
|
||||
APP_DB_SCHEMA_VERSION
|
||||
);
|
||||
let skill_count: i64 = migrated
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM managed_skills WHERE id = 'legacy-skill'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("count preserved skill");
|
||||
assert_eq!(skill_count, 1);
|
||||
migrated
|
||||
.execute(
|
||||
"INSERT INTO skills_mcp_notes
|
||||
(codex_dir, item_kind, item_id, note, updated_at)
|
||||
VALUES
|
||||
('/tmp/legacy-codex', 'skill', 'legacy-skill', 'my note', '2026-01-02T00:00:00Z')",
|
||||
[],
|
||||
)
|
||||
.expect("write note after migration");
|
||||
migrated
|
||||
.execute(
|
||||
"INSERT INTO active_provider_selections
|
||||
(codex_dir, provider_id, updated_at)
|
||||
VALUES
|
||||
('/tmp/legacy-codex', 'legacy-provider', '2026-01-02T00:00:00Z')",
|
||||
[],
|
||||
)
|
||||
.expect("write active provider selection after migration");
|
||||
drop(migrated);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_first_opens_initialize_once() {
|
||||
let path = test_db_path("concurrent-init");
|
||||
|
||||
@@ -82,14 +82,16 @@ use providers::{
|
||||
upsert_provider_on_connection, CcSwitchCodexRow, ProviderUpsertKind, ProviderUpsertMode,
|
||||
};
|
||||
use providers::{
|
||||
build_provider_toml_draft_inner, delete_saved_provider_inner, fetch_provider_models_inner,
|
||||
get_official_config_draft_inner, import_ccswitch_codex_providers_inner,
|
||||
list_saved_providers_inner, read_ccswitch_official_auth_inner, reset_official_provider_inner,
|
||||
restore_official_provider_inner, save_active_provider_inner, save_official_config_inner,
|
||||
save_provider_inner, save_provider_toml_config_inner, switch_official_provider_inner,
|
||||
switch_provider_inner, test_provider_connection_inner, ImportResult, OfficialAuthCandidate,
|
||||
OfficialConfigDraft, OfficialConfigInput, ProviderConnectionResult, ProviderInput,
|
||||
ProviderModelsResult, ProviderTomlInput, SavedProvider,
|
||||
build_provider_toml_draft_inner, clear_active_provider_on_connection,
|
||||
delete_saved_provider_inner, fetch_provider_models_inner, get_official_config_draft_inner,
|
||||
import_ccswitch_codex_providers_inner, list_saved_providers_inner, open_store,
|
||||
read_ccswitch_official_auth_inner, remember_active_provider_on_connection,
|
||||
reset_official_provider_inner, restore_official_provider_inner, save_active_provider_inner,
|
||||
save_official_config_inner, save_provider_inner, save_provider_toml_config_inner,
|
||||
switch_official_provider_inner, switch_provider_inner, test_provider_connection_inner,
|
||||
ImportResult, OfficialAuthCandidate, OfficialConfigDraft, OfficialConfigInput,
|
||||
ProviderConnectionResult, ProviderInput, ProviderModelsResult, ProviderTomlInput,
|
||||
SavedProvider,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use sessions::{
|
||||
@@ -105,8 +107,9 @@ use sessions::{
|
||||
};
|
||||
use skills_mcp::{
|
||||
build_skills_mcp_state_inner, check_skill_updates_inner, import_existing_skills_mcp_inner,
|
||||
install_skill_zip_inner, preview_existing_skills_mcp_inner, toggle_codex_mcp_inner,
|
||||
toggle_codex_skill_inner, SkillsMcpActionResult, SkillsMcpImportPreview, SkillsMcpState,
|
||||
install_skill_zip_inner, preview_existing_skills_mcp_inner, save_skills_mcp_note_inner,
|
||||
toggle_codex_mcp_inner, toggle_codex_skill_inner, SkillsMcpActionResult,
|
||||
SkillsMcpImportPreview, SkillsMcpState,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use skills_mcp::{
|
||||
@@ -154,6 +157,13 @@ struct AboutInfo {
|
||||
native_updater_supported: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CodexDesktopRestartResult {
|
||||
app_name: String,
|
||||
was_running: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DiagnosticItem {
|
||||
@@ -473,6 +483,20 @@ async fn toggle_codex_mcp(
|
||||
.map_err(|e| CodexxError::Config(format!("切换 MCP 失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_skills_mcp_note(
|
||||
config_dir: Option<String>,
|
||||
item_kind: String,
|
||||
id: String,
|
||||
note: String,
|
||||
) -> Result<SkillsMcpState> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
save_skills_mcp_note_inner(config_dir, item_kind, id, note)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存 Skills/MCP 备注失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn install_skill_zip(
|
||||
config_dir: Option<String>,
|
||||
@@ -578,6 +602,18 @@ async fn get_about_info(config_dir: Option<String>) -> Result<AboutInfo> {
|
||||
.map_err(|e| CodexxError::Config(format!("读取关于信息失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn restart_codex_desktop() -> std::result::Result<CodexDesktopRestartResult, String> {
|
||||
let (app_name, was_running) =
|
||||
tauri::async_runtime::spawn_blocking(platform::restart_codex_desktop)
|
||||
.await
|
||||
.map_err(|error| format!("重启 Codex 桌面客户端失败: {error}"))??;
|
||||
Ok(CodexDesktopRestartResult {
|
||||
app_name,
|
||||
was_running,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_saved_prompts() -> Result<Vec<SavedPrompt>> {
|
||||
tauri::async_runtime::spawn_blocking(list_saved_prompts_inner)
|
||||
@@ -852,6 +888,54 @@ async fn list_saved_providers() -> Result<Vec<SavedProvider>> {
|
||||
.map_err(|e| CodexxError::Config(format!("读取供应商列表失败: {e}")))?
|
||||
}
|
||||
|
||||
enum ActiveProviderSelectionUpdate {
|
||||
Set(String),
|
||||
ClearIfOfficial,
|
||||
}
|
||||
|
||||
fn finish_provider_selection(
|
||||
mut result: ActionResult,
|
||||
update: ActiveProviderSelectionUpdate,
|
||||
) -> ActionResult {
|
||||
if matches!(update, ActiveProviderSelectionUpdate::ClearIfOfficial)
|
||||
&& !result.state.is_official_provider
|
||||
{
|
||||
return result;
|
||||
}
|
||||
match &update {
|
||||
ActiveProviderSelectionUpdate::Set(provider_id) => {
|
||||
result.state.active_saved_provider_id = Some(provider_id.clone());
|
||||
}
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial => {
|
||||
result.state.active_saved_provider_id = None;
|
||||
}
|
||||
}
|
||||
|
||||
let codex_dir = PathBuf::from(&result.state.codex_dir);
|
||||
let refreshed_state = (|| -> Result<CodexState> {
|
||||
let conn = open_store()?;
|
||||
match update {
|
||||
ActiveProviderSelectionUpdate::Set(provider_id) => {
|
||||
remember_active_provider_on_connection(&conn, &codex_dir, &provider_id)?;
|
||||
}
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial => {
|
||||
clear_active_provider_on_connection(&conn, &codex_dir)?;
|
||||
}
|
||||
}
|
||||
drop(conn);
|
||||
build_state_after_migration(codex_dir)
|
||||
})();
|
||||
match refreshed_state {
|
||||
Ok(state) => result.state = state,
|
||||
Err(error) => {
|
||||
result
|
||||
.message
|
||||
.push_str(&format!(";当前供应商状态记录失败:{error}"));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn build_provider_toml_draft(
|
||||
provider: SavedProvider,
|
||||
@@ -880,9 +964,16 @@ async fn save_active_provider(
|
||||
provider: SavedProvider,
|
||||
config_dir: Option<String>,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || save_active_provider_inner(provider, config_dir))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存活动供应商失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let provider_id = provider.id.clone();
|
||||
let result = save_active_provider_inner(provider, config_dir)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::Set(provider_id),
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存活动供应商失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -906,9 +997,15 @@ fn get_codex_state_inner(config_dir: Option<String>) -> Result<CodexState> {
|
||||
|
||||
#[tauri::command]
|
||||
async fn switch_official_provider(config_dir: Option<String>) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || switch_official_provider_inner(config_dir))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("切换官方配置失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = switch_official_provider_inner(config_dir)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("切换官方配置失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -922,15 +1019,26 @@ async fn get_official_config_draft(
|
||||
|
||||
#[tauri::command]
|
||||
async fn restore_official_provider(config_dir: Option<String>) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || restore_official_provider_inner(config_dir))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("还原官方配置失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = restore_official_provider_inner(config_dir)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("还原官方配置失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn reset_official_provider(input: OfficialConfigInput) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
reset_official_provider_inner(input.config_dir, input.model, input.config_text)
|
||||
let result =
|
||||
reset_official_provider_inner(input.config_dir, input.model, input.config_text)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("新建官方配置失败: {e}")))?
|
||||
@@ -939,12 +1047,16 @@ async fn reset_official_provider(input: OfficialConfigInput) -> Result<ActionRes
|
||||
#[tauri::command]
|
||||
async fn save_official_config(input: OfficialConfigInput) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
save_official_config_inner(
|
||||
let result = save_official_config_inner(
|
||||
input.config_dir,
|
||||
input.model,
|
||||
input.auth_json,
|
||||
input.config_text,
|
||||
)
|
||||
)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存官方配置失败: {e}")))?
|
||||
@@ -1124,10 +1236,21 @@ async fn disable_external_instruction(config_dir: Option<String>) -> Result<Acti
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_provider_toml_config(input: ProviderTomlInput) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || save_provider_toml_config_inner(input))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存供应商 TOML 失败: {e}")))?
|
||||
async fn save_provider_toml_config(
|
||||
input: ProviderTomlInput,
|
||||
provider_id: Option<String>,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = save_provider_toml_config_inner(input)?;
|
||||
Ok(match provider_id {
|
||||
Some(provider_id) => {
|
||||
finish_provider_selection(result, ActiveProviderSelectionUpdate::Set(provider_id))
|
||||
}
|
||||
None => result,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存供应商 TOML 失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1152,9 +1275,18 @@ async fn fetch_provider_models(
|
||||
|
||||
#[tauri::command]
|
||||
async fn switch_provider(input: ProviderInput) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || switch_provider_inner(input))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("切换供应商失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let provider_id = input.provider_id.clone();
|
||||
let result = switch_provider_inner(input)?;
|
||||
Ok(match provider_id {
|
||||
Some(provider_id) => {
|
||||
finish_provider_selection(result, ActiveProviderSelectionUpdate::Set(provider_id))
|
||||
}
|
||||
None => result,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("切换供应商失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1341,12 +1473,14 @@ pub fn run() {
|
||||
.on_window_event(desktop_lifecycle::handle_window_event)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_about_info,
|
||||
restart_codex_desktop,
|
||||
check_app_update,
|
||||
get_skills_mcp_state,
|
||||
preview_existing_skills_mcp,
|
||||
import_existing_skills_mcp,
|
||||
toggle_codex_skill,
|
||||
toggle_codex_mcp,
|
||||
save_skills_mcp_note,
|
||||
install_skill_zip,
|
||||
check_skill_updates,
|
||||
get_startup_diagnostics,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
#[cfg(test)]
|
||||
use chrono::Local;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) fn home_dir() -> Result<PathBuf> {
|
||||
dirs::home_dir().ok_or(CodexxError::NoHomeDir)
|
||||
@@ -33,3 +33,13 @@ pub(crate) fn app_home() -> Result<PathBuf> {
|
||||
Ok(home_dir()?.join(".codexx"))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalized_path_scope(path: &Path) -> String {
|
||||
let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
let normalized = resolved.to_string_lossy().replace('\\', "/");
|
||||
if cfg!(target_os = "windows") {
|
||||
normalized.to_ascii_lowercase()
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,46 @@ const WINDOWS_CODEX_PACKAGE_IDENTITIES: &[&str] =
|
||||
&["OpenAI.Codex", "OpenAI.CodexBeta", "OpenAI.ChatGPT-Desktop"];
|
||||
#[cfg(target_os = "windows")]
|
||||
const WINDOWS_CODEX_EXECUTABLES: &[&str] = &["ChatGPT.exe", "Codex.exe", "codex.exe"];
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
const MACOS_CODEX_APP_NAMES: &[&str] = &[
|
||||
"Codex.app",
|
||||
"OpenAI Codex.app",
|
||||
"OpenAI.Codex.app",
|
||||
"ChatGPT Codex.app",
|
||||
"ChatGPT.app",
|
||||
];
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
const MACOS_CODEX_BUNDLE_IDS: &[&str] = &["com.openai.codex", "com.openai.codex.beta"];
|
||||
|
||||
const CODEX_VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const PROGRAM_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const PROGRAM_POLL_INTERVAL: Duration = Duration::from_millis(10);
|
||||
const CHILD_TERMINATION_GRACE: Duration = Duration::from_millis(250);
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
const CODEX_DESKTOP_COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
const CODEX_DESKTOP_QUIT_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
#[cfg(target_os = "macos")]
|
||||
const CODEX_DESKTOP_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const WINDOWS_RESTART_RESULT_PREFIX: &str = "CODEX_X_DESKTOP_RESTART";
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct MacosAppMetadata {
|
||||
bundle_id: String,
|
||||
executable: String,
|
||||
display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[derive(Debug, Clone)]
|
||||
struct MacosCodexApp {
|
||||
bundle_id: String,
|
||||
bundle_path: PathBuf,
|
||||
executable_path: PathBuf,
|
||||
app_name: String,
|
||||
}
|
||||
|
||||
static CODEX_VERSION: OnceLock<String> = OnceLock::new();
|
||||
|
||||
@@ -172,8 +207,13 @@ fn receive_output(
|
||||
}
|
||||
}
|
||||
|
||||
fn run_program(program: &Path, args: &[&str], deadline: Option<Instant>) -> Option<Output> {
|
||||
let timeout = remaining_timeout(deadline, PROGRAM_TIMEOUT)?;
|
||||
fn run_program_with_timeout(
|
||||
program: &Path,
|
||||
args: &[&str],
|
||||
deadline: Option<Instant>,
|
||||
maximum: Duration,
|
||||
) -> Option<Output> {
|
||||
let timeout = remaining_timeout(deadline, maximum)?;
|
||||
let command_deadline = Instant::now().checked_add(timeout)?;
|
||||
let mut command = program_command(program, args);
|
||||
command
|
||||
@@ -222,6 +262,10 @@ fn run_program(program: &Path, args: &[&str], deadline: Option<Instant>) -> Opti
|
||||
})
|
||||
}
|
||||
|
||||
fn run_program(program: &Path, args: &[&str], deadline: Option<Instant>) -> Option<Output> {
|
||||
run_program_with_timeout(program, args, deadline, PROGRAM_TIMEOUT)
|
||||
}
|
||||
|
||||
fn command_version(program: &Path, deadline: Option<Instant>) -> Option<String> {
|
||||
run_program(program, &["--version"], deadline)
|
||||
.and_then(version_from_output)
|
||||
@@ -550,47 +594,90 @@ fn windows_where_candidates(_deadline: Option<Instant>) -> Vec<PathBuf> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn macos_codex_app_paths(home: &Path) -> Vec<PathBuf> {
|
||||
[PathBuf::from("/Applications"), home.join("Applications")]
|
||||
.into_iter()
|
||||
.flat_map(|root| {
|
||||
MACOS_CODEX_APP_NAMES
|
||||
.iter()
|
||||
.map(move |name| root.join(name))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn macos_app_metadata_from_plist(plist: &str) -> Option<MacosAppMetadata> {
|
||||
Some(MacosAppMetadata {
|
||||
bundle_id: plist_string_value(plist, "CFBundleIdentifier")?,
|
||||
executable: plist_string_value(plist, "CFBundleExecutable")?,
|
||||
display_name: plist_string_value(plist, "CFBundleDisplayName")
|
||||
.or_else(|| plist_string_value(plist, "CFBundleName")),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn is_supported_macos_codex_bundle_id(bundle_id: &str) -> bool {
|
||||
MACOS_CODEX_BUNDLE_IDS
|
||||
.iter()
|
||||
.any(|supported| bundle_id.eq_ignore_ascii_case(supported))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn parse_macos_main_process_ids(output: &str, executable_path: &Path) -> Vec<u32> {
|
||||
let executable = executable_path.to_string_lossy();
|
||||
output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let line = line.trim_start();
|
||||
let pid_end = line.find(char::is_whitespace)?;
|
||||
let pid = line[..pid_end].parse::<u32>().ok()?;
|
||||
let command = line[pid_end..].trim_start();
|
||||
let matches = command == executable
|
||||
|| command
|
||||
.strip_prefix(executable.as_ref())
|
||||
.is_some_and(|rest| rest.chars().next().is_some_and(char::is_whitespace));
|
||||
matches.then_some(pid)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_app_version(deadline: Option<Instant>) -> Option<String> {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
for root in [PathBuf::from("/Applications"), home.join("Applications")] {
|
||||
for name in [
|
||||
"Codex.app",
|
||||
"OpenAI Codex.app",
|
||||
"OpenAI.Codex.app",
|
||||
"ChatGPT Codex.app",
|
||||
"ChatGPT.app",
|
||||
] {
|
||||
if deadline_expired(deadline) {
|
||||
return None;
|
||||
}
|
||||
let app = root.join(name);
|
||||
if !app.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let app_name = if name == "ChatGPT.app" {
|
||||
"ChatGPT app"
|
||||
} else {
|
||||
"Codex app"
|
||||
};
|
||||
if let Some(version) = macos_info_plist_version(&app).or_else(|| {
|
||||
let app = app.to_str()?;
|
||||
let output = run_program(
|
||||
Path::new("mdls"),
|
||||
&["-name", "kMDItemVersion", "-raw", app],
|
||||
deadline,
|
||||
)?;
|
||||
output
|
||||
.status
|
||||
.success()
|
||||
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}) {
|
||||
if !version.is_empty() && version != "(null)" {
|
||||
return Some(format!("{app_name} {version}"));
|
||||
}
|
||||
}
|
||||
return Some(format!("{app_name} installed"));
|
||||
for app in macos_codex_app_paths(&home) {
|
||||
if deadline_expired(deadline) {
|
||||
return None;
|
||||
}
|
||||
if !app.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let app_name = if app
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name == "ChatGPT.app")
|
||||
{
|
||||
"ChatGPT app"
|
||||
} else {
|
||||
"Codex app"
|
||||
};
|
||||
if let Some(version) = macos_info_plist_version(&app).or_else(|| {
|
||||
let app = app.to_str()?;
|
||||
let output = run_program(
|
||||
Path::new("mdls"),
|
||||
&["-name", "kMDItemVersion", "-raw", app],
|
||||
deadline,
|
||||
)?;
|
||||
output
|
||||
.status
|
||||
.success()
|
||||
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}) {
|
||||
if !version.is_empty() && version != "(null)" {
|
||||
return Some(format!("{app_name} {version}"));
|
||||
}
|
||||
}
|
||||
return Some(format!("{app_name} installed"));
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -611,11 +698,365 @@ fn plist_string_value(plist: &str, key: &str) -> Option<String> {
|
||||
(!value.is_empty()).then(|| value.to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_plist_raw_value(app: &Path, key: &str, deadline: Option<Instant>) -> Option<String> {
|
||||
let plist_path = app.join("Contents/Info.plist");
|
||||
if let Ok(plist) = fs::read_to_string(&plist_path) {
|
||||
if let Some(value) = plist_string_value(&plist, key) {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
|
||||
let plist_path = plist_path.to_str()?;
|
||||
let output = run_program(
|
||||
Path::new("/usr/bin/plutil"),
|
||||
&["-extract", key, "raw", "-o", "-", plist_path],
|
||||
deadline,
|
||||
)?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_codex_apps(deadline: Option<Instant>) -> Result<Vec<MacosCodexApp>, String> {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
let mut apps = Vec::new();
|
||||
for app in macos_codex_app_paths(&home) {
|
||||
if deadline_expired(deadline) {
|
||||
return Err("检测 Codex/ChatGPT 客户端超时".to_string());
|
||||
}
|
||||
if !app.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let plist = fs::read_to_string(app.join("Contents/Info.plist")).ok();
|
||||
let metadata = plist.as_deref().and_then(macos_app_metadata_from_plist);
|
||||
let bundle_id = metadata
|
||||
.as_ref()
|
||||
.map(|metadata| metadata.bundle_id.clone())
|
||||
.or_else(|| macos_plist_raw_value(&app, "CFBundleIdentifier", deadline));
|
||||
let executable = metadata
|
||||
.as_ref()
|
||||
.map(|metadata| metadata.executable.clone())
|
||||
.or_else(|| macos_plist_raw_value(&app, "CFBundleExecutable", deadline));
|
||||
let Some(bundle_id) = bundle_id.filter(|value| is_supported_macos_codex_bundle_id(value))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(executable) = executable.filter(|value| {
|
||||
!value.is_empty()
|
||||
&& !value.contains('/')
|
||||
&& !value.contains('\\')
|
||||
&& !value.contains('\0')
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let executable_path = app.join("Contents/MacOS").join(executable);
|
||||
if !executable_path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let executable_path = executable_path.canonicalize().unwrap_or(executable_path);
|
||||
let display_name = metadata
|
||||
.and_then(|metadata| metadata.display_name)
|
||||
.or_else(|| macos_plist_raw_value(&app, "CFBundleDisplayName", deadline))
|
||||
.or_else(|| macos_plist_raw_value(&app, "CFBundleName", deadline));
|
||||
let app_name = if display_name
|
||||
.as_deref()
|
||||
.is_some_and(|name| name.to_ascii_lowercase().contains("chatgpt"))
|
||||
|| app
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.to_ascii_lowercase().contains("chatgpt"))
|
||||
{
|
||||
"ChatGPT"
|
||||
} else {
|
||||
"Codex"
|
||||
};
|
||||
let bundle_path = app.canonicalize().unwrap_or(app);
|
||||
apps.push(MacosCodexApp {
|
||||
bundle_id,
|
||||
bundle_path,
|
||||
executable_path,
|
||||
app_name: app_name.to_string(),
|
||||
});
|
||||
}
|
||||
if apps.is_empty() {
|
||||
Err("未找到可重启的 Codex/ChatGPT 桌面客户端".to_string())
|
||||
} else {
|
||||
Ok(apps)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_main_process_ids(
|
||||
executable_path: &Path,
|
||||
deadline: Option<Instant>,
|
||||
) -> Result<Vec<u32>, String> {
|
||||
let output = run_program_with_timeout(
|
||||
Path::new("/bin/ps"),
|
||||
&["-axo", "pid=,command="],
|
||||
deadline,
|
||||
CODEX_DESKTOP_COMMAND_TIMEOUT,
|
||||
)
|
||||
.ok_or_else(|| "读取 Codex/ChatGPT 客户端进程失败或超时".to_string())?;
|
||||
if !output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(if message.is_empty() {
|
||||
"读取 Codex/ChatGPT 客户端进程失败".to_string()
|
||||
} else {
|
||||
format!("读取 Codex/ChatGPT 客户端进程失败: {message}")
|
||||
});
|
||||
}
|
||||
Ok(parse_macos_main_process_ids(
|
||||
&String::from_utf8_lossy(&output.stdout),
|
||||
executable_path,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_macos_codex_app(app: &MacosCodexApp) -> Result<(), String> {
|
||||
let deadline = Instant::now()
|
||||
.checked_add(CODEX_DESKTOP_COMMAND_TIMEOUT)
|
||||
.ok_or_else(|| "创建 Codex/ChatGPT 启动超时时间失败".to_string())?;
|
||||
let bundle_path = app
|
||||
.bundle_path
|
||||
.to_str()
|
||||
.ok_or_else(|| "Codex/ChatGPT 客户端路径不是有效文本".to_string())?;
|
||||
let output = run_program_with_timeout(
|
||||
Path::new("/usr/bin/open"),
|
||||
&["-a", bundle_path],
|
||||
Some(deadline),
|
||||
CODEX_DESKTOP_COMMAND_TIMEOUT,
|
||||
)
|
||||
.ok_or_else(|| "启动 Codex/ChatGPT 客户端失败或超时".to_string())?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if message.is_empty() {
|
||||
"启动 Codex/ChatGPT 客户端失败".to_string()
|
||||
} else {
|
||||
format!("启动 Codex/ChatGPT 客户端失败: {message}")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn restart_codex_desktop() -> Result<(String, bool), String> {
|
||||
let detection_deadline = Instant::now()
|
||||
.checked_add(CODEX_DESKTOP_COMMAND_TIMEOUT)
|
||||
.ok_or_else(|| "创建 Codex/ChatGPT 检测超时时间失败".to_string())?;
|
||||
let apps = macos_codex_apps(Some(detection_deadline))?;
|
||||
let mut first_installed = None;
|
||||
let mut running_app = None;
|
||||
for app in apps {
|
||||
let is_running =
|
||||
!macos_main_process_ids(&app.executable_path, Some(detection_deadline))?.is_empty();
|
||||
if is_running {
|
||||
running_app = Some(app);
|
||||
break;
|
||||
}
|
||||
if first_installed.is_none() {
|
||||
first_installed = Some(app);
|
||||
}
|
||||
}
|
||||
let was_running = running_app.is_some();
|
||||
let app = running_app
|
||||
.or(first_installed)
|
||||
.ok_or_else(|| "未找到可重启的 Codex/ChatGPT 桌面客户端".to_string())?;
|
||||
if !was_running {
|
||||
launch_macos_codex_app(&app)?;
|
||||
return Ok((app.app_name, false));
|
||||
}
|
||||
|
||||
let quit_script = format!("tell application id \"{}\" to quit", app.bundle_id);
|
||||
let command_deadline = Instant::now()
|
||||
.checked_add(CODEX_DESKTOP_COMMAND_TIMEOUT)
|
||||
.ok_or_else(|| "创建 Codex/ChatGPT 退出命令超时时间失败".to_string())?;
|
||||
let output = run_program_with_timeout(
|
||||
Path::new("/usr/bin/osascript"),
|
||||
&["-e", quit_script.as_str()],
|
||||
Some(command_deadline),
|
||||
CODEX_DESKTOP_COMMAND_TIMEOUT,
|
||||
)
|
||||
.ok_or_else(|| "请求 Codex/ChatGPT 客户端正常退出失败或超时".to_string())?;
|
||||
if !output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(if message.is_empty() {
|
||||
"请求 Codex/ChatGPT 客户端正常退出失败".to_string()
|
||||
} else {
|
||||
format!("请求 Codex/ChatGPT 客户端正常退出失败: {message}")
|
||||
});
|
||||
}
|
||||
|
||||
let quit_deadline = Instant::now()
|
||||
.checked_add(CODEX_DESKTOP_QUIT_TIMEOUT)
|
||||
.ok_or_else(|| "创建 Codex/ChatGPT 退出等待时间失败".to_string())?;
|
||||
loop {
|
||||
if macos_main_process_ids(&app.executable_path, Some(quit_deadline))?.is_empty() {
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= quit_deadline {
|
||||
return Err("Codex/ChatGPT 客户端未在限定时间内正常退出,已取消重新启动".to_string());
|
||||
}
|
||||
thread::sleep(CODEX_DESKTOP_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
launch_macos_codex_app(&app)?;
|
||||
Ok((app.app_name, true))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn macos_app_version(_deadline: Option<Instant>) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn parse_windows_restart_result(output: &str) -> Option<(String, bool)> {
|
||||
output.lines().rev().find_map(|line| {
|
||||
let payload = line
|
||||
.trim()
|
||||
.strip_prefix(WINDOWS_RESTART_RESULT_PREFIX)?
|
||||
.strip_prefix('\t')?;
|
||||
let mut fields = payload.split('\t');
|
||||
let app_name = fields.next()?.trim();
|
||||
let was_running = match fields.next()?.trim() {
|
||||
"0" => false,
|
||||
"1" => true,
|
||||
_ => return None,
|
||||
};
|
||||
if fields.next().is_some() || !matches!(app_name, "Codex" | "ChatGPT") {
|
||||
return None;
|
||||
}
|
||||
Some((app_name.to_string(), was_running))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn windows_restart_script() -> String {
|
||||
let identities = WINDOWS_CODEX_PACKAGE_IDENTITIES
|
||||
.iter()
|
||||
.map(|identity| format!("'{}'", identity.replace('\'', "''")))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
r#"
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$utf8 = [System.Text.UTF8Encoding]::new($false)
|
||||
[Console]::OutputEncoding = $utf8
|
||||
$OutputEncoding = $utf8
|
||||
$identities = @(__IDENTITIES__)
|
||||
$packages = @(Get-AppxPackage |
|
||||
Where-Object { $identities -contains $_.Name } |
|
||||
Sort-Object -Property Version -Descending |
|
||||
Select-Object -Unique)
|
||||
if ($packages.Count -eq 0) { throw 'Supported Codex/ChatGPT Appx package was not found.' }
|
||||
|
||||
function Get-CodexDesktopProcesses([string]$root) {
|
||||
$appRoot = $root + 'app\'
|
||||
$resourcesRoot = $appRoot + 'resources\'
|
||||
@(Get-Process -ErrorAction SilentlyContinue | Where-Object {
|
||||
try {
|
||||
$processPath = [IO.Path]::GetFullPath($_.MainModule.FileName)
|
||||
$executableName = [IO.Path]::GetFileName($processPath)
|
||||
($executableName -ieq 'Codex.exe' -or $executableName -ieq 'ChatGPT.exe') -and
|
||||
$processPath.StartsWith($appRoot, [StringComparison]::OrdinalIgnoreCase) -and
|
||||
-not $processPath.StartsWith($resourcesRoot, [StringComparison]::OrdinalIgnoreCase)
|
||||
} catch {
|
||||
$false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
$package = $null
|
||||
$installRoot = $null
|
||||
$processes = @()
|
||||
foreach ($candidate in $packages) {
|
||||
$candidateRoot = [IO.Path]::GetFullPath([string]$candidate.InstallLocation).TrimEnd('\') + '\'
|
||||
$candidateProcesses = @(Get-CodexDesktopProcesses $candidateRoot)
|
||||
if ($candidateProcesses.Count -gt 0) {
|
||||
$package = $candidate
|
||||
$installRoot = $candidateRoot
|
||||
$processes = $candidateProcesses
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($null -eq $package) {
|
||||
$package = $packages[0]
|
||||
$installRoot = [IO.Path]::GetFullPath([string]$package.InstallLocation).TrimEnd('\') + '\'
|
||||
$processes = @(Get-CodexDesktopProcesses $installRoot)
|
||||
}
|
||||
|
||||
$manifest = Get-AppxPackageManifest -Package $package.PackageFullName
|
||||
$application = @($manifest.Package.Applications.Application) | Select-Object -First 1
|
||||
$applicationId = [string]$application.Id
|
||||
$packageFamily = [string]$package.PackageFamilyName
|
||||
if ([string]::IsNullOrWhiteSpace($applicationId) -or [string]::IsNullOrWhiteSpace($packageFamily)) {
|
||||
throw 'Codex/ChatGPT Appx package does not expose a launchable application id.'
|
||||
}
|
||||
|
||||
$wasRunning = $processes.Count -gt 0
|
||||
if ($wasRunning) {
|
||||
foreach ($process in $processes) {
|
||||
Stop-Process -InputObject $process -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$quitDeadline = [DateTime]::UtcNow.AddSeconds(8)
|
||||
do {
|
||||
$remaining = @(Get-CodexDesktopProcesses $installRoot)
|
||||
if ($remaining.Count -eq 0) { break }
|
||||
Start-Sleep -Milliseconds 100
|
||||
} while ([DateTime]::UtcNow -lt $quitDeadline)
|
||||
$remaining = @(Get-CodexDesktopProcesses $installRoot)
|
||||
if ($remaining.Count -gt 0) {
|
||||
throw 'Codex/ChatGPT background processes could not be stopped before the timeout; relaunch was cancelled.'
|
||||
}
|
||||
}
|
||||
|
||||
$aumid = $packageFamily + '!' + $applicationId
|
||||
Start-Process -FilePath 'explorer.exe' -ArgumentList ('shell:AppsFolder\' + $aumid) | Out-Null
|
||||
$appName = if ($package.Name -eq 'OpenAI.ChatGPT-Desktop') { 'ChatGPT' } else { 'Codex' }
|
||||
$runningFlag = if ($wasRunning) { '1' } else { '0' }
|
||||
[Console]::Out.WriteLine('__RESULT_PREFIX__' + "`t" + $appName + "`t" + $runningFlag)
|
||||
"#
|
||||
.replace("__IDENTITIES__", &identities)
|
||||
.replace("__RESULT_PREFIX__", WINDOWS_RESTART_RESULT_PREFIX)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn restart_codex_desktop() -> Result<(String, bool), String> {
|
||||
let script = windows_restart_script();
|
||||
let timeout = CODEX_DESKTOP_QUIT_TIMEOUT + CODEX_DESKTOP_COMMAND_TIMEOUT;
|
||||
let deadline = Instant::now()
|
||||
.checked_add(timeout)
|
||||
.ok_or_else(|| "创建 Codex/ChatGPT 重启超时时间失败".to_string())?;
|
||||
let output = run_program_with_timeout(
|
||||
Path::new("powershell.exe"),
|
||||
&[
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
script.as_str(),
|
||||
],
|
||||
Some(deadline),
|
||||
timeout,
|
||||
)
|
||||
.ok_or_else(|| "重启 Codex/ChatGPT 客户端失败或超时".to_string())?;
|
||||
if !output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(if message.is_empty() {
|
||||
"重启 Codex/ChatGPT 客户端失败".to_string()
|
||||
} else {
|
||||
format!("重启 Codex/ChatGPT 客户端失败: {message}")
|
||||
});
|
||||
}
|
||||
parse_windows_restart_result(&String::from_utf8_lossy(&output.stdout))
|
||||
.ok_or_else(|| "无法确认 Codex/ChatGPT 客户端的重启结果".to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_app_version(deadline: Option<Instant>) -> Option<String> {
|
||||
let mut roots = Vec::new();
|
||||
@@ -680,6 +1121,11 @@ fn windows_app_version(_deadline: Option<Instant>) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
|
||||
pub fn restart_codex_desktop() -> Result<(String, bool), String> {
|
||||
Err("当前平台暂不支持重启 Codex/ChatGPT 桌面客户端".to_string())
|
||||
}
|
||||
|
||||
fn path_codex_candidates(deadline: Option<Instant>) -> Vec<PathBuf> {
|
||||
let mut candidates = ["codex", "codex.exe", "codex.cmd"]
|
||||
.into_iter()
|
||||
@@ -805,8 +1251,10 @@ pub fn detect_codex_version() -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
cached_codex_version, latest_windows_package_version, plist_string_value, run_program,
|
||||
version_line, visit_named_files,
|
||||
cached_codex_version, is_supported_macos_codex_bundle_id, latest_windows_package_version,
|
||||
macos_app_metadata_from_plist, macos_codex_app_paths, parse_macos_main_process_ids,
|
||||
parse_windows_restart_result, plist_string_value, run_program, version_line,
|
||||
visit_named_files, windows_restart_script,
|
||||
};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -943,6 +1391,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_restart_result_parser_ignores_noise_and_reads_state() {
|
||||
let output = "PowerShell startup text\nCODEX_X_DESKTOP_RESTART\tChatGPT\t1\n";
|
||||
assert_eq!(
|
||||
parse_windows_restart_result(output),
|
||||
Some(("ChatGPT".to_string(), true))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_windows_restart_result("CODEX_X_DESKTOP_RESTART\tCodex\t0\n"),
|
||||
Some(("Codex".to_string(), false))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_windows_restart_result("CODEX_X_DESKTOP_RESTART\tOther\t1\n"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_restart_script_only_stops_processes_in_the_selected_package_directory() {
|
||||
let script = windows_restart_script();
|
||||
assert!(script.contains("$processPath.StartsWith($appRoot"));
|
||||
assert!(script.contains("$executableName -ieq 'Codex.exe'"));
|
||||
assert!(script.contains("$executableName -ieq 'ChatGPT.exe'"));
|
||||
assert!(script.contains("-not $processPath.StartsWith($resourcesRoot"));
|
||||
assert!(script.contains("Stop-Process -InputObject $process -Force"));
|
||||
assert!(script.contains("[Console]::OutputEncoding = $utf8"));
|
||||
assert!(script.contains("shell:AppsFolder\\"));
|
||||
assert!(!script.contains("CloseMainWindow"));
|
||||
assert!(!script.contains("Get-Process -Name"));
|
||||
assert!(!script.contains("Stop-Process -Name"));
|
||||
assert!(!script.contains("taskkill"));
|
||||
assert!(!script.contains("ApplicationFrameHost"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plist_parser_reads_codex_bundle_version() {
|
||||
let plist = r#"<plist><dict>
|
||||
@@ -954,4 +1436,53 @@ mod tests {
|
||||
Some("1.2026.204")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_app_metadata_parser_reads_exact_desktop_identity() {
|
||||
let plist = r#"<plist><dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>ChatGPT</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>ChatGPT</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.openai.codex</string>
|
||||
</dict></plist>"#;
|
||||
let metadata = macos_app_metadata_from_plist(plist).expect("parse app metadata");
|
||||
assert_eq!(metadata.bundle_id, "com.openai.codex");
|
||||
assert_eq!(metadata.executable, "ChatGPT");
|
||||
assert_eq!(metadata.display_name.as_deref(), Some("ChatGPT"));
|
||||
assert!(is_supported_macos_codex_bundle_id(&metadata.bundle_id));
|
||||
assert!(!is_supported_macos_codex_bundle_id("com.example.codex"));
|
||||
assert!(macos_app_metadata_from_plist(
|
||||
"<plist><dict><key>CFBundleIdentifier</key><string>com.openai.codex</string></dict></plist>"
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_app_candidates_reuse_supported_names_in_stable_order() {
|
||||
let paths = macos_codex_app_paths(Path::new("/Users/tester"));
|
||||
assert_eq!(
|
||||
paths.first(),
|
||||
Some(&Path::new("/Applications/Codex.app").to_path_buf())
|
||||
);
|
||||
assert_eq!(
|
||||
paths.last(),
|
||||
Some(&Path::new("/Users/tester/Applications/ChatGPT.app").to_path_buf())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_process_parser_only_matches_the_exact_main_executable() {
|
||||
let executable = Path::new("/Applications/ChatGPT.app/Contents/MacOS/ChatGPT");
|
||||
let output = " 101 /Applications/ChatGPT.app/Contents/MacOS/ChatGPT\n\
|
||||
102 /Applications/ChatGPT.app/Contents/MacOS/ChatGPT --started-from-launch-services\n\
|
||||
103 /Applications/ChatGPT.app/Contents/Frameworks/Codex (Renderer).app/Contents/MacOS/Codex (Renderer)\n\
|
||||
104 /Applications/ChatGPT.app/Contents/MacOS/ChatGPT-helper\n\
|
||||
105 /Applications/Other.app/Contents/MacOS/ChatGPT\n";
|
||||
assert_eq!(
|
||||
parse_macos_main_process_ids(output, executable),
|
||||
vec![101, 102]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,7 +395,6 @@ pub(crate) fn import_ccswitch_codex_providers_inner(path: Option<String>) -> Res
|
||||
match result.kind {
|
||||
ProviderUpsertKind::Added => added += 1,
|
||||
ProviderUpsertKind::Updated => updated += 1,
|
||||
ProviderUpsertKind::Merged => merged += 1,
|
||||
}
|
||||
imported += 1;
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ use super::official_auth::{
|
||||
use super::{
|
||||
custom_provider_id, delete_provider_inner, experimental_bearer_token_from_doc,
|
||||
is_placeholder_provider, list_saved_providers_inner, list_saved_providers_on_connection,
|
||||
matching_saved_provider_ids_for_live, normalize_saved_provider, open_store,
|
||||
provider_template_from_document, reserved_codex_provider_id, rollback_provider_store_inner,
|
||||
save_provider_with_rollback_inner, strip_provider_bearer_tokens,
|
||||
matching_saved_provider_ids_for_live, normalize_saved_provider,
|
||||
normalize_saved_provider_for_save, open_store, provider_template_from_document,
|
||||
reconcile_active_provider_on_connection, reserved_codex_provider_id,
|
||||
rollback_provider_store_inner, save_provider_with_rollback_inner, strip_provider_bearer_tokens,
|
||||
unique_saved_provider_id_for_live, ProviderStoreRollback, SavedProvider,
|
||||
};
|
||||
use crate::backups::create_backup;
|
||||
@@ -1224,42 +1225,33 @@ fn save_active_provider_with_apply<F>(
|
||||
where
|
||||
F: FnOnce(&SavedProvider, &Path, Option<Vec<u8>>) -> Result<ActionResult>,
|
||||
{
|
||||
let provider = normalize_saved_provider(provider)?;
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
ensure_directory(&codex_dir)?;
|
||||
let _live_lock = acquire_live_config_lock(&codex_dir)?;
|
||||
migrate_legacy_prompt_config_locked(&codex_dir)?;
|
||||
let active_config = read_file_snapshot(&config_path(&codex_dir))?;
|
||||
let conn = open_store()?;
|
||||
let provider = normalize_saved_provider_for_save(&conn, provider)?;
|
||||
let saved_before = list_saved_providers_on_connection(&conn)?;
|
||||
let live = detected_live_custom_provider(&codex_dir)?.ok_or_else(|| {
|
||||
CodexxError::Config("当前不是可编辑的第三方供应商,未修改保存记录".to_string())
|
||||
})?;
|
||||
let matches = matching_saved_provider_ids_for_live(&live, &saved_before);
|
||||
match matches.as_slice() {
|
||||
[] => {
|
||||
if saved_before
|
||||
.iter()
|
||||
.any(|candidate| candidate.id == provider.id)
|
||||
{
|
||||
return Err(CodexxError::Config(format!(
|
||||
"供应商 ID {} 已被另一条配置使用,请更换名称后再保存",
|
||||
provider.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
[active_id] if active_id == &provider.id => {}
|
||||
[active_id] => {
|
||||
if matches.is_empty() {
|
||||
if saved_before
|
||||
.iter()
|
||||
.any(|candidate| candidate.id == provider.id)
|
||||
{
|
||||
return Err(CodexxError::Config(format!(
|
||||
"当前启用的是 {active_id},不能把 {} 作为活动配置保存",
|
||||
"供应商 ID {} 已被另一条配置使用,请更换名称后再保存",
|
||||
provider.id
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(CodexxError::Config(
|
||||
"当前 live 供应商匹配到多条保存记录,请先清理重复配置".to_string(),
|
||||
));
|
||||
}
|
||||
} else if !matches.iter().any(|active_id| active_id == &provider.id) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"当前 live 配置不匹配供应商 {},不能作为活动配置保存",
|
||||
provider.id
|
||||
)));
|
||||
}
|
||||
|
||||
let (saved, rollback) = save_provider_with_rollback_inner(provider)?;
|
||||
@@ -1319,15 +1311,18 @@ pub(crate) fn delete_saved_provider_inner(id: &str, config_dir: Option<String>)
|
||||
return Err(CodexxError::Config("供应商 ID 不能为空".to_string()));
|
||||
}
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
let providers = list_saved_providers_inner()?;
|
||||
let conn = open_store()?;
|
||||
let providers = list_saved_providers_on_connection(&conn)?;
|
||||
if let Some(live) = detected_live_custom_provider(&codex_dir)? {
|
||||
let active_ids = matching_saved_provider_ids_for_live(&live, &providers);
|
||||
if live.id == id || active_ids.iter().any(|active_id| active_id == id) {
|
||||
let active_id = reconcile_active_provider_on_connection(&conn, &codex_dir, &active_ids)?;
|
||||
if live.id == id || active_id.as_deref() == Some(id) {
|
||||
return Err(CodexxError::Config(
|
||||
"不能直接删除当前启用的供应商,请先切换到官方配置或其他供应商".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
drop(conn);
|
||||
delete_provider_inner(id)
|
||||
}
|
||||
|
||||
@@ -1335,7 +1330,7 @@ pub(crate) fn delete_saved_provider_inner(id: &str, config_dir: Option<String>)
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::file_io::{write_json, write_text};
|
||||
use crate::providers::delete_provider_inner;
|
||||
use crate::providers::{delete_provider_inner, remember_active_provider_on_connection};
|
||||
use crate::providers::{list_saved_providers_inner, save_provider_inner};
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
@@ -1980,6 +1975,142 @@ command = "docs-server"
|
||||
fs::remove_dir_all(codex_dir).expect("remove active provider test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_provider_save_uses_explicit_id_when_live_profile_has_duplicates() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let tag = 50_001;
|
||||
let first_id = format!("active-duplicate-first-{tag}");
|
||||
let target_id = format!("active-duplicate-target-{tag}");
|
||||
let codex_dir = active_provider_test_dir("duplicate-explicit-edit", tag);
|
||||
let first =
|
||||
active_provider_fixture(tag, &first_id, "Shared Before", "model-before", "sk-before");
|
||||
let target = active_provider_fixture(
|
||||
tag,
|
||||
&target_id,
|
||||
"Shared Before",
|
||||
"model-before",
|
||||
"sk-before",
|
||||
);
|
||||
save_provider_inner(first.clone()).expect("save first duplicate provider");
|
||||
save_provider_inner(target).expect("save target duplicate provider");
|
||||
write_active_provider_files(&codex_dir, &first);
|
||||
|
||||
let updated = active_provider_fixture(
|
||||
tag,
|
||||
&target_id,
|
||||
"Explicit Target",
|
||||
"model-after",
|
||||
"sk-after",
|
||||
);
|
||||
let result = save_active_provider_inner(updated, Some(codex_dir.display().to_string()))
|
||||
.expect("edit the explicitly selected active duplicate");
|
||||
|
||||
let untouched = saved_provider(&first_id);
|
||||
assert_eq!(untouched.provider_name, "Shared Before");
|
||||
assert_eq!(untouched.model, "model-before");
|
||||
assert_eq!(untouched.api_key.as_deref(), Some("sk-before"));
|
||||
let saved_target = saved_provider(&target_id);
|
||||
assert_eq!(saved_target.provider_name, "Explicit Target");
|
||||
assert_eq!(saved_target.model, "model-after");
|
||||
assert_eq!(saved_target.api_key.as_deref(), Some("sk-after"));
|
||||
let state = serde_json::to_value(&result.state).expect("serialize updated state");
|
||||
assert_eq!(
|
||||
state["activeSavedProviderId"].as_str(),
|
||||
Some(target_id.as_str())
|
||||
);
|
||||
|
||||
delete_provider_inner(&first_id).expect("delete first duplicate provider");
|
||||
delete_provider_inner(&target_id).expect("delete target duplicate provider");
|
||||
fs::remove_dir_all(codex_dir).expect("remove duplicate edit test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_current_id_protects_only_the_active_duplicate_from_deletion() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let tag = 50_002;
|
||||
let first_id = format!("active-delete-duplicate-first-{tag}");
|
||||
let remaining_id = format!("active-delete-duplicate-remaining-{tag}");
|
||||
let codex_dir = active_provider_test_dir("duplicate-delete-guard", tag);
|
||||
let first = active_provider_fixture(
|
||||
tag,
|
||||
&first_id,
|
||||
"Shared Provider",
|
||||
"shared-model",
|
||||
"sk-shared",
|
||||
);
|
||||
let remaining = active_provider_fixture(
|
||||
tag,
|
||||
&remaining_id,
|
||||
"Shared Provider",
|
||||
"shared-model",
|
||||
"sk-shared",
|
||||
);
|
||||
save_provider_inner(first.clone()).expect("save first duplicate provider");
|
||||
save_provider_inner(remaining).expect("save remaining duplicate provider");
|
||||
write_active_provider_files(&codex_dir, &first);
|
||||
let conn = open_store().expect("open provider store");
|
||||
remember_active_provider_on_connection(&conn, &codex_dir, &first_id)
|
||||
.expect("remember the explicit current provider");
|
||||
drop(conn);
|
||||
|
||||
delete_saved_provider_inner(&remaining_id, Some(codex_dir.display().to_string()))
|
||||
.expect("delete the inactive duplicate");
|
||||
assert!(list_saved_providers_inner()
|
||||
.expect("list providers after duplicate deletion")
|
||||
.iter()
|
||||
.all(|provider| provider.id != remaining_id));
|
||||
let delete_error =
|
||||
delete_saved_provider_inner(&first_id, Some(codex_dir.display().to_string()))
|
||||
.expect_err("the explicitly selected provider must be protected");
|
||||
assert!(delete_error.to_string().contains("不能直接删除当前启用"));
|
||||
assert_eq!(saved_provider(&first_id).provider_name, "Shared Provider");
|
||||
|
||||
delete_provider_inner(&first_id).expect("delete active duplicate during cleanup");
|
||||
fs::remove_dir_all(codex_dir).expect("remove duplicate delete test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_provider_save_preserves_a_legacy_custom_record_id() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let tag = 50_003;
|
||||
let temporary_id = format!("legacy-custom-seed-{tag}");
|
||||
let codex_dir = active_provider_test_dir("legacy-custom-edit", tag);
|
||||
let temporary = active_provider_fixture(
|
||||
tag,
|
||||
&temporary_id,
|
||||
"Legacy Before",
|
||||
"model-before",
|
||||
"sk-before",
|
||||
);
|
||||
save_provider_inner(temporary.clone()).expect("seed temporary legacy provider");
|
||||
let conn = open_store().expect("open provider store");
|
||||
conn.execute(
|
||||
"UPDATE providers SET id = 'custom' WHERE id = ?1",
|
||||
[&temporary_id],
|
||||
)
|
||||
.expect("restore historical custom ID");
|
||||
drop(conn);
|
||||
|
||||
let mut live = temporary;
|
||||
live.id = "custom".to_string();
|
||||
write_active_provider_files(&codex_dir, &live);
|
||||
let updated =
|
||||
active_provider_fixture(tag, "custom", "Legacy After", "model-after", "sk-after");
|
||||
let result = save_active_provider_inner(updated, Some(codex_dir.display().to_string()))
|
||||
.expect("edit active provider with a legacy custom ID");
|
||||
|
||||
assert_eq!(saved_provider("custom").provider_name, "Legacy After");
|
||||
assert!(list_saved_providers_inner()
|
||||
.expect("list providers after legacy edit")
|
||||
.iter()
|
||||
.all(|provider| provider.id != "custom-custom"));
|
||||
let state = serde_json::to_value(&result.state).expect("serialize updated state");
|
||||
assert_eq!(state["activeSavedProviderId"].as_str(), Some("custom"));
|
||||
|
||||
delete_provider_inner("custom").expect("delete legacy custom provider");
|
||||
fs::remove_dir_all(codex_dir).expect("remove legacy custom test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_provider_save_rolls_back_record_when_apply_fails_before_writing() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
|
||||
@@ -2,6 +2,7 @@ mod ccswitch;
|
||||
mod connection;
|
||||
mod live;
|
||||
mod official_auth;
|
||||
mod selection;
|
||||
mod store;
|
||||
|
||||
use crate::error::Result;
|
||||
@@ -40,6 +41,10 @@ pub(crate) use official_auth::{
|
||||
document_is_official, get_official_config_draft_inner, official_auth_available,
|
||||
OfficialConfigDraft,
|
||||
};
|
||||
pub(crate) use selection::{
|
||||
clear_active_provider_on_connection, clear_provider_selections_on_connection,
|
||||
reconcile_active_provider_on_connection, remember_active_provider_on_connection,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use store::{
|
||||
canonical_provider_base_url, provider_by_id_on_connection, provider_identity,
|
||||
@@ -50,10 +55,11 @@ pub(crate) use store::{
|
||||
delete_provider_inner, experimental_bearer_token_from_doc, is_placeholder_provider,
|
||||
list_saved_providers_inner, list_saved_providers_on_connection,
|
||||
matching_saved_provider_ids_for_live, normalize_saved_provider,
|
||||
provider_template_from_document, reserved_codex_provider_id, rollback_provider_store_inner,
|
||||
save_provider_inner, save_provider_with_rollback_inner, strip_provider_bearer_tokens,
|
||||
unique_saved_provider_id_for_live, upsert_ccswitch_provider_on_connection,
|
||||
ProviderStoreRollback, ProviderUpsertKind, SavedProvider,
|
||||
normalize_saved_provider_for_save, provider_template_from_document, reserved_codex_provider_id,
|
||||
rollback_provider_store_inner, save_provider_inner, save_provider_with_rollback_inner,
|
||||
strip_provider_bearer_tokens, unique_saved_provider_id_for_live,
|
||||
upsert_ccswitch_provider_on_connection, ProviderStoreRollback, ProviderUpsertKind,
|
||||
SavedProvider,
|
||||
};
|
||||
|
||||
pub(crate) fn open_store() -> Result<Connection> {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::now_rfc3339;
|
||||
use crate::paths::normalized_path_scope;
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
fn selected_provider_id_on_connection(
|
||||
conn: &Connection,
|
||||
codex_dir: &Path,
|
||||
) -> Result<Option<String>> {
|
||||
conn.query_row(
|
||||
"SELECT provider_id
|
||||
FROM active_provider_selections
|
||||
WHERE codex_dir = ?1",
|
||||
[normalized_path_scope(codex_dir)],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn remember_active_provider_on_connection(
|
||||
conn: &Connection,
|
||||
codex_dir: &Path,
|
||||
provider_id: &str,
|
||||
) -> Result<()> {
|
||||
let provider_id = provider_id.trim();
|
||||
if provider_id.is_empty() {
|
||||
return Err(CodexxError::Config("当前供应商 ID 不能为空".to_string()));
|
||||
}
|
||||
let exists = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM providers WHERE id = ?1)",
|
||||
[provider_id],
|
||||
|row| row.get::<_, bool>(0),
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
if !exists {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"无法记录当前供应商,未找到 ID {provider_id}"
|
||||
)));
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO active_provider_selections (codex_dir, provider_id, updated_at)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(codex_dir) DO UPDATE SET
|
||||
provider_id = excluded.provider_id,
|
||||
updated_at = excluded.updated_at",
|
||||
params![normalized_path_scope(codex_dir), provider_id, now_rfc3339()],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn clear_active_provider_on_connection(
|
||||
conn: &Connection,
|
||||
codex_dir: &Path,
|
||||
) -> Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM active_provider_selections WHERE codex_dir = ?1",
|
||||
[normalized_path_scope(codex_dir)],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn clear_provider_selections_on_connection(
|
||||
conn: &Connection,
|
||||
provider_id: &str,
|
||||
) -> Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM active_provider_selections WHERE provider_id = ?1",
|
||||
[provider_id],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile_active_provider_on_connection(
|
||||
conn: &Connection,
|
||||
codex_dir: &Path,
|
||||
candidate_ids: &[String],
|
||||
) -> Result<Option<String>> {
|
||||
let candidates = candidate_ids
|
||||
.iter()
|
||||
.map(|id| id.trim())
|
||||
.filter(|id| !id.is_empty())
|
||||
.collect::<HashSet<_>>();
|
||||
let selected = selected_provider_id_on_connection(conn, codex_dir)?;
|
||||
if let Some(selected) = selected.as_deref() {
|
||||
if candidates.contains(selected) {
|
||||
return Ok(Some(selected.to_string()));
|
||||
}
|
||||
clear_active_provider_on_connection(conn, codex_dir)?;
|
||||
}
|
||||
|
||||
if candidates.len() != 1 {
|
||||
return Ok(None);
|
||||
}
|
||||
let provider_id = candidates
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("one candidate must be present");
|
||||
remember_active_provider_on_connection(conn, codex_dir, provider_id)?;
|
||||
Ok(Some(provider_id.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn test_connection() -> Connection {
|
||||
let conn = Connection::open_in_memory().expect("open selection test database");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE providers (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE active_provider_selections (
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
provider_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO providers (id) VALUES ('original'), ('copy'), ('other');",
|
||||
)
|
||||
.expect("create selection test schema");
|
||||
conn
|
||||
}
|
||||
|
||||
fn scope(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!("codex-x-selection-{name}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_duplicate_does_not_replace_the_remembered_current_provider() {
|
||||
let conn = test_connection();
|
||||
let codex_dir = scope("duplicate");
|
||||
remember_active_provider_on_connection(&conn, &codex_dir, "original")
|
||||
.expect("remember original provider");
|
||||
|
||||
let active = reconcile_active_provider_on_connection(
|
||||
&conn,
|
||||
&codex_dir,
|
||||
&["original".to_string(), "copy".to_string()],
|
||||
)
|
||||
.expect("reconcile duplicate profiles");
|
||||
|
||||
assert_eq!(active.as_deref(), Some("original"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selections_are_independent_for_each_codex_directory() {
|
||||
let conn = test_connection();
|
||||
let first = scope("first");
|
||||
let second = scope("second");
|
||||
remember_active_provider_on_connection(&conn, &first, "original")
|
||||
.expect("remember first selection");
|
||||
remember_active_provider_on_connection(&conn, &second, "copy")
|
||||
.expect("remember second selection");
|
||||
let candidates = ["original".to_string(), "copy".to_string()];
|
||||
|
||||
assert_eq!(
|
||||
reconcile_active_provider_on_connection(&conn, &first, &candidates)
|
||||
.expect("reconcile first selection")
|
||||
.as_deref(),
|
||||
Some("original")
|
||||
);
|
||||
assert_eq!(
|
||||
reconcile_active_provider_on_connection(&conn, &second, &candidates)
|
||||
.expect("reconcile second selection")
|
||||
.as_deref(),
|
||||
Some("copy")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_selection_falls_back_only_when_the_live_match_is_unique() {
|
||||
let conn = test_connection();
|
||||
let codex_dir = scope("stale");
|
||||
remember_active_provider_on_connection(&conn, &codex_dir, "original")
|
||||
.expect("remember stale selection");
|
||||
|
||||
assert_eq!(
|
||||
reconcile_active_provider_on_connection(&conn, &codex_dir, &["other".to_string()])
|
||||
.expect("reconcile unique replacement")
|
||||
.as_deref(),
|
||||
Some("other")
|
||||
);
|
||||
assert_eq!(
|
||||
selected_provider_id_on_connection(&conn, &codex_dir)
|
||||
.expect("read repaired selection")
|
||||
.as_deref(),
|
||||
Some("other")
|
||||
);
|
||||
|
||||
assert!(
|
||||
reconcile_active_provider_on_connection(&conn, &codex_dir, &[])
|
||||
.expect("clear unmatched selection")
|
||||
.is_none()
|
||||
);
|
||||
assert!(selected_provider_id_on_connection(&conn, &codex_dir)
|
||||
.expect("read cleared selection")
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
use super::open_store as open_db;
|
||||
use super::{clear_provider_selections_on_connection, open_store as open_db};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::{now_rfc3339, sanitize_id};
|
||||
use rusqlite::{params, Connection, TransactionBehavior};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashSet;
|
||||
use toml_edit::{value, DocumentMut};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -43,6 +43,13 @@ pub(crate) enum ProviderIdentity {
|
||||
Unauthenticated { base_url: String, name: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct ProviderProfileIdentity {
|
||||
// Matching helper only; manually saved rows are always addressed by ID.
|
||||
provider: ProviderIdentity,
|
||||
model: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ProviderUpsertMode {
|
||||
Manual,
|
||||
@@ -53,7 +60,6 @@ pub(crate) enum ProviderUpsertMode {
|
||||
pub(crate) enum ProviderUpsertKind {
|
||||
Added,
|
||||
Updated,
|
||||
Merged,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -161,6 +167,13 @@ pub(crate) fn provider_identity(provider: &SavedProvider) -> Option<ProviderIden
|
||||
(!name.is_empty()).then_some(ProviderIdentity::Unauthenticated { base_url, name })
|
||||
}
|
||||
|
||||
fn provider_profile_identity(provider: &SavedProvider) -> Option<ProviderProfileIdentity> {
|
||||
provider_identity(provider).map(|identity| ProviderProfileIdentity {
|
||||
provider: identity,
|
||||
model: provider.model.trim().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn provider_template_from_document(
|
||||
doc: &DocumentMut,
|
||||
provider_id: &str,
|
||||
@@ -211,8 +224,12 @@ fn same_provider_endpoint_and_name(left: &SavedProvider, right: &SavedProvider)
|
||||
== normalized_provider_name(&right.provider_name)
|
||||
}
|
||||
|
||||
fn same_provider_profile_fallback(left: &SavedProvider, right: &SavedProvider) -> bool {
|
||||
same_provider_endpoint_and_name(left, right) && left.model.trim() == right.model.trim()
|
||||
}
|
||||
|
||||
fn compatible_provider_match(left: &SavedProvider, right: &SavedProvider) -> bool {
|
||||
if !same_provider_endpoint_and_name(left, right) {
|
||||
if !same_provider_profile_fallback(left, right) {
|
||||
return false;
|
||||
}
|
||||
match (
|
||||
@@ -224,30 +241,16 @@ fn compatible_provider_match(left: &SavedProvider, right: &SavedProvider) -> boo
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_compatible_provider<'a>(
|
||||
stored: &'a [StoredProvider],
|
||||
incoming: &SavedProvider,
|
||||
) -> Option<&'a StoredProvider> {
|
||||
let mut matches = stored
|
||||
.iter()
|
||||
.filter(|candidate| compatible_provider_match(&candidate.provider, incoming));
|
||||
let candidate = matches.next()?;
|
||||
matches.next().is_none().then_some(candidate)
|
||||
}
|
||||
|
||||
pub(crate) fn matching_saved_provider_ids_for_live(
|
||||
live: &SavedProvider,
|
||||
providers: &[SavedProvider],
|
||||
) -> Vec<String> {
|
||||
let stable = providers
|
||||
.iter()
|
||||
.filter(|candidate| !is_historical_custom_provider_id(&candidate.id))
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(identity) = provider_identity(live) {
|
||||
let exact = stable
|
||||
let candidates = providers.iter().collect::<Vec<_>>();
|
||||
if let Some(identity) = provider_profile_identity(live) {
|
||||
let exact = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|candidate| provider_identity(candidate).as_ref() == Some(&identity))
|
||||
.filter(|candidate| provider_profile_identity(candidate).as_ref() == Some(&identity))
|
||||
.collect::<Vec<_>>();
|
||||
if !exact.is_empty() {
|
||||
return exact
|
||||
@@ -257,7 +260,7 @@ pub(crate) fn matching_saved_provider_ids_for_live(
|
||||
}
|
||||
}
|
||||
|
||||
let compatible = stable
|
||||
let compatible = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|candidate| compatible_provider_match(live, candidate))
|
||||
@@ -272,9 +275,9 @@ pub(crate) fn matching_saved_provider_ids_for_live(
|
||||
if !is_historical_custom_provider_id(&live.id) {
|
||||
return Vec::new();
|
||||
}
|
||||
stable
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter(|candidate| same_provider_endpoint_and_name(live, candidate))
|
||||
.filter(|candidate| same_provider_profile_fallback(live, candidate))
|
||||
.map(|candidate| candidate.id.clone())
|
||||
.collect()
|
||||
}
|
||||
@@ -306,7 +309,7 @@ fn stored_providers_on_connection(conn: &Connection) -> Result<Vec<StoredProvide
|
||||
"SELECT id, provider_name, base_url, model, api_key, toml_config, wire_api,
|
||||
requires_openai_auth, created_at, updated_at, source, source_id
|
||||
FROM providers
|
||||
ORDER BY created_at ASC, updated_at ASC, id ASC",
|
||||
ORDER BY created_at ASC, rowid ASC",
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let rows = stmt
|
||||
@@ -478,34 +481,6 @@ fn source_matches(row: &StoredProvider, origin: (&str, &str)) -> bool {
|
||||
source_matches && row.source_id.as_deref() == Some(origin.1)
|
||||
}
|
||||
|
||||
fn source_can_merge(row: &StoredProvider, origin: Option<(&str, &str)>) -> bool {
|
||||
match origin {
|
||||
None => true,
|
||||
Some(origin) => row.source == MANUAL_PROVIDER_SOURCE || source_matches(row, origin),
|
||||
}
|
||||
}
|
||||
|
||||
fn conflicting_provider_ids(
|
||||
stored: &[StoredProvider],
|
||||
provider: &SavedProvider,
|
||||
origin: Option<(&str, &str)>,
|
||||
) -> Vec<String> {
|
||||
let identity = provider_identity(provider);
|
||||
stored
|
||||
.iter()
|
||||
.filter(|candidate| candidate.provider.id != provider.id)
|
||||
.filter(|candidate| source_can_merge(candidate, origin))
|
||||
.filter(|candidate| {
|
||||
identity.as_ref().is_some_and(|identity| {
|
||||
provider_identity(&candidate.provider).as_ref() == Some(identity)
|
||||
}) || (is_historical_custom_provider_id(&candidate.provider.id)
|
||||
&& !is_historical_custom_provider_id(&provider.id)
|
||||
&& same_provider_endpoint_and_name(&candidate.provider, provider))
|
||||
})
|
||||
.map(|candidate| candidate.provider.id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn upsert_provider_in_savepoint(
|
||||
conn: &Connection,
|
||||
mut provider: SavedProvider,
|
||||
@@ -513,70 +488,27 @@ fn upsert_provider_in_savepoint(
|
||||
origin: Option<(&str, &str)>,
|
||||
) -> Result<ProviderUpsertResult> {
|
||||
let requested_id = provider.id.clone();
|
||||
let identity = provider_identity(&provider);
|
||||
let stored = stored_providers_on_connection(conn)?;
|
||||
let source_match = origin.and_then(|origin| {
|
||||
stored
|
||||
.iter()
|
||||
.find(|candidate| source_matches(candidate, origin))
|
||||
});
|
||||
let merge_candidates = stored
|
||||
.iter()
|
||||
.filter(|candidate| source_can_merge(candidate, origin))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let identity_match = identity.as_ref().and_then(|identity| {
|
||||
merge_candidates
|
||||
.iter()
|
||||
.find(|candidate| provider_identity(&candidate.provider).as_ref() == Some(identity))
|
||||
});
|
||||
let manual_candidates = stored
|
||||
.iter()
|
||||
.filter(|candidate| candidate.source == MANUAL_PROVIDER_SOURCE)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let manual_identity_match = identity.as_ref().and_then(|identity| {
|
||||
manual_candidates
|
||||
.iter()
|
||||
.find(|candidate| provider_identity(&candidate.provider).as_ref() == Some(identity))
|
||||
});
|
||||
let manual_compatible_match = unique_compatible_provider(&manual_candidates, &provider);
|
||||
let exact_id_match = stored
|
||||
.iter()
|
||||
.find(|candidate| candidate.provider.id == requested_id);
|
||||
let compatible_match = unique_compatible_provider(&merge_candidates, &provider);
|
||||
let import_rehome_target = source_match.and_then(|source| {
|
||||
manual_identity_match
|
||||
.or(manual_compatible_match)
|
||||
.filter(|candidate| candidate.provider.id != source.provider.id)
|
||||
});
|
||||
|
||||
let target = match mode {
|
||||
ProviderUpsertMode::Manual => exact_id_match.or(identity_match).or(compatible_match),
|
||||
ProviderUpsertMode::Imported => import_rehome_target
|
||||
.or(source_match)
|
||||
.or(identity_match)
|
||||
.or(compatible_match),
|
||||
ProviderUpsertMode::Manual => exact_id_match,
|
||||
ProviderUpsertMode::Imported => source_match,
|
||||
};
|
||||
let preserves_local_id = mode == ProviderUpsertMode::Imported
|
||||
&& target.is_some_and(|candidate| {
|
||||
candidate.source == MANUAL_PROVIDER_SOURCE
|
||||
|| candidate.source == CCSWITCH_LOCAL_PROVIDER_SOURCE
|
||||
});
|
||||
let kind = if let Some(target) = target {
|
||||
let existing = &target.provider;
|
||||
let same_id = existing.id == requested_id;
|
||||
provider.id = existing.id.clone();
|
||||
if mode == ProviderUpsertMode::Imported {
|
||||
provider = merge_authoritative_import(provider, existing);
|
||||
}
|
||||
if import_rehome_target.is_some() {
|
||||
ProviderUpsertKind::Merged
|
||||
} else if source_match.is_some() || same_id {
|
||||
ProviderUpsertKind::Updated
|
||||
} else {
|
||||
ProviderUpsertKind::Merged
|
||||
}
|
||||
ProviderUpsertKind::Updated
|
||||
} else {
|
||||
if exact_id_match.is_some() {
|
||||
provider.id = unique_provider_id_on_connection(conn, &provider.id)?;
|
||||
@@ -584,23 +516,7 @@ fn upsert_provider_in_savepoint(
|
||||
ProviderUpsertKind::Added
|
||||
};
|
||||
|
||||
if let (Some(source), Some(target)) = (source_match, import_rehome_target) {
|
||||
if source.provider.id != target.provider.id {
|
||||
conn.execute("DELETE FROM providers WHERE id = ?1", [&source.provider.id])
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
let write_origin = match origin {
|
||||
Some((CCSWITCH_PROVIDER_SOURCE, source_id)) if preserves_local_id => {
|
||||
Some((CCSWITCH_LOCAL_PROVIDER_SOURCE, source_id))
|
||||
}
|
||||
_ => origin,
|
||||
};
|
||||
write_provider_with_origin(conn, &provider, write_origin)?;
|
||||
for duplicate_id in conflicting_provider_ids(&stored, &provider, origin) {
|
||||
conn.execute("DELETE FROM providers WHERE id = ?1", [&duplicate_id])
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
}
|
||||
write_provider_with_origin(conn, &provider, origin)?;
|
||||
let provider = provider_by_id_on_connection(conn, &provider.id)?
|
||||
.ok_or_else(|| CodexxError::Database("provider saved but not found".to_string()))?;
|
||||
Ok(ProviderUpsertResult { provider, kind })
|
||||
@@ -671,164 +587,11 @@ fn is_historical_custom_provider_id(id: &str) -> bool {
|
||||
}
|
||||
|
||||
pub(crate) fn consolidate_legacy_provider_duplicates_on_connection(
|
||||
conn: &Connection,
|
||||
_conn: &Connection,
|
||||
) -> Result<usize> {
|
||||
const SAVEPOINT: &str = "codex_x_provider_cleanup";
|
||||
conn.execute_batch(&format!("SAVEPOINT {SAVEPOINT}"))
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let result = (|| -> Result<usize> {
|
||||
let rows = stored_providers_on_connection(conn)?;
|
||||
let mut groups: HashMap<ProviderIdentity, Vec<StoredProvider>> = HashMap::new();
|
||||
for row in rows {
|
||||
if let Some(identity @ ProviderIdentity::Credential(_)) =
|
||||
provider_identity(&row.provider)
|
||||
{
|
||||
groups.entry(identity).or_default().push(row);
|
||||
}
|
||||
}
|
||||
let duplicate_groups = groups
|
||||
.into_values()
|
||||
.filter(|group| group.len() > 1)
|
||||
.collect::<Vec<_>>();
|
||||
let mut merged = 0usize;
|
||||
for mut group in duplicate_groups {
|
||||
let preserves_local_profile = group.iter().any(|row| {
|
||||
row.source == MANUAL_PROVIDER_SOURCE || row.source == CCSWITCH_LOCAL_PROVIDER_SOURCE
|
||||
});
|
||||
let mut origins = group
|
||||
.iter()
|
||||
.filter_map(|row| {
|
||||
row.source_id.as_ref().map(|source_id| {
|
||||
let source = if row.source == CCSWITCH_LOCAL_PROVIDER_SOURCE {
|
||||
CCSWITCH_PROVIDER_SOURCE.to_string()
|
||||
} else {
|
||||
row.source.clone()
|
||||
};
|
||||
(source, source_id.clone())
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
origins.sort();
|
||||
origins.dedup();
|
||||
if origins.len() > 1 {
|
||||
continue;
|
||||
}
|
||||
group.sort_by(|left, right| {
|
||||
let left_is_local = left.source == MANUAL_PROVIDER_SOURCE
|
||||
|| left.source == CCSWITCH_LOCAL_PROVIDER_SOURCE;
|
||||
let right_is_local = right.source == MANUAL_PROVIDER_SOURCE
|
||||
|| right.source == CCSWITCH_LOCAL_PROVIDER_SOURCE;
|
||||
right_is_local
|
||||
.cmp(&left_is_local)
|
||||
.then_with(|| {
|
||||
is_historical_custom_provider_id(&left.provider.id)
|
||||
.cmp(&is_historical_custom_provider_id(&right.provider.id))
|
||||
})
|
||||
.then_with(|| right.updated_at.cmp(&left.updated_at))
|
||||
.then_with(|| right.created_at.cmp(&left.created_at))
|
||||
.then_with(|| left.provider.id.cmp(&right.provider.id))
|
||||
});
|
||||
let mut survivor = group[0].clone();
|
||||
for duplicate in group.iter().skip(1) {
|
||||
if survivor.provider.provider_name.trim().is_empty()
|
||||
&& !duplicate.provider.provider_name.trim().is_empty()
|
||||
{
|
||||
survivor.provider.provider_name = duplicate.provider.provider_name.clone();
|
||||
}
|
||||
if survivor.provider.model.trim().is_empty()
|
||||
&& !duplicate.provider.model.trim().is_empty()
|
||||
{
|
||||
survivor.provider.model = duplicate.provider.model.clone();
|
||||
}
|
||||
if survivor
|
||||
.provider
|
||||
.toml_config
|
||||
.as_deref()
|
||||
.is_none_or(|value| value.trim().is_empty())
|
||||
&& duplicate
|
||||
.provider
|
||||
.toml_config
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
{
|
||||
survivor.provider.toml_config = duplicate.provider.toml_config.clone();
|
||||
}
|
||||
if survivor.provider.api_key.is_none() && duplicate.provider.api_key.is_some() {
|
||||
survivor.provider.api_key = duplicate.provider.api_key.clone();
|
||||
}
|
||||
}
|
||||
survivor.provider.base_url = canonical_provider_base_url(&survivor.provider.base_url);
|
||||
survivor.provider.api_key = survivor
|
||||
.provider
|
||||
.api_key
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
for duplicate in group.iter().skip(1) {
|
||||
conn.execute(
|
||||
"DELETE FROM providers WHERE id = ?1",
|
||||
[&duplicate.provider.id],
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
merged += 1;
|
||||
}
|
||||
let origin = origins.first().map(|(source, source_id)| {
|
||||
let source = if preserves_local_profile && source == CCSWITCH_PROVIDER_SOURCE {
|
||||
CCSWITCH_LOCAL_PROVIDER_SOURCE
|
||||
} else {
|
||||
source.as_str()
|
||||
};
|
||||
(source, source_id.as_str())
|
||||
});
|
||||
write_provider_with_origin(conn, &survivor.provider, origin)?;
|
||||
}
|
||||
|
||||
loop {
|
||||
let rows = stored_providers_on_connection(conn)?;
|
||||
let ghost_id = rows.iter().find_map(|ghost| {
|
||||
if !is_historical_custom_provider_id(&ghost.provider.id) {
|
||||
return None;
|
||||
}
|
||||
if ghost.source != MANUAL_PROVIDER_SOURCE || ghost.source_id.is_some() {
|
||||
return None;
|
||||
}
|
||||
let mut candidates = rows.iter().filter(|candidate| {
|
||||
!is_historical_custom_provider_id(&candidate.provider.id)
|
||||
&& same_provider_endpoint_and_name(&ghost.provider, &candidate.provider)
|
||||
});
|
||||
candidates.next()?;
|
||||
candidates
|
||||
.next()
|
||||
.is_none()
|
||||
.then(|| ghost.provider.id.clone())
|
||||
});
|
||||
let Some(ghost_id) = ghost_id else {
|
||||
break;
|
||||
};
|
||||
conn.execute("DELETE FROM providers WHERE id = ?1", [&ghost_id])
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
merged += 1;
|
||||
}
|
||||
Ok(merged)
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(merged) => {
|
||||
conn.execute_batch(&format!("RELEASE SAVEPOINT {SAVEPOINT}"))
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
Ok(merged)
|
||||
}
|
||||
Err(error) => {
|
||||
let rollback = conn.execute_batch(&format!(
|
||||
"ROLLBACK TO SAVEPOINT {SAVEPOINT}; RELEASE SAVEPOINT {SAVEPOINT}"
|
||||
));
|
||||
match rollback {
|
||||
Ok(()) => Err(error),
|
||||
Err(rollback_error) => Err(CodexxError::Database(format!(
|
||||
"{error}; provider cleanup rollback failed: {rollback_error}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Distinct IDs are independent records, even when every configuration
|
||||
// field matches. Keep the legacy hook for import-result compatibility.
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn apply_provider_toml_authority(provider: &mut SavedProvider) -> Result<()> {
|
||||
@@ -1003,18 +766,32 @@ pub(crate) fn normalize_saved_provider(provider: SavedProvider) -> Result<SavedP
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
pub(crate) fn save_manual_provider_on_connection(
|
||||
pub(crate) fn normalize_saved_provider_for_save(
|
||||
conn: &Connection,
|
||||
provider: SavedProvider,
|
||||
) -> Result<SavedProvider> {
|
||||
let requested_id = provider.id.trim().to_string();
|
||||
let provider = normalize_saved_provider(provider)?;
|
||||
if requested_id != provider.id && provider_by_id_on_connection(conn, &provider.id)?.is_some() {
|
||||
let mut normalized = normalize_saved_provider(provider)?;
|
||||
if provider_by_id_on_connection(conn, &requested_id)?.is_some() {
|
||||
// Existing IDs are record identities. Preserve legacy reserved IDs such
|
||||
// as `custom` instead of treating an edit as a new normalized record.
|
||||
normalized.id = requested_id;
|
||||
} else if requested_id != normalized.id
|
||||
&& provider_by_id_on_connection(conn, &normalized.id)?.is_some()
|
||||
{
|
||||
return Err(CodexxError::Config(format!(
|
||||
"供应商 ID {} 规范化后与现有供应商冲突,请更换名称或 ID",
|
||||
requested_id
|
||||
)));
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
pub(crate) fn save_manual_provider_on_connection(
|
||||
conn: &Connection,
|
||||
provider: SavedProvider,
|
||||
) -> Result<SavedProvider> {
|
||||
let provider = normalize_saved_provider_for_save(conn, provider)?;
|
||||
Ok(upsert_provider_on_connection(conn, provider, ProviderUpsertMode::Manual)?.provider)
|
||||
}
|
||||
|
||||
@@ -1121,9 +898,17 @@ pub(crate) fn rollback_provider_store_inner(rollback: ProviderStoreRollback) ->
|
||||
}
|
||||
|
||||
pub(crate) fn delete_provider_inner(id: &str) -> Result<()> {
|
||||
let conn = open_db()?;
|
||||
conn.execute("DELETE FROM providers WHERE id = ?1", params![id])
|
||||
let mut conn = open_db()?;
|
||||
let transaction = conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
clear_provider_selections_on_connection(&transaction, id)?;
|
||||
transaction
|
||||
.execute("DELETE FROM providers WHERE id = ?1", params![id])
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
transaction
|
||||
.commit()
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1204,7 +989,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_upsert_deduplicates_safe_matches_and_keeps_distinct_credentials() {
|
||||
fn provider_upsert_uses_record_ids_and_stable_import_sources() {
|
||||
let stable = test_connection();
|
||||
let mut existing = provider("cc-stable-id", "Old name", Some("sk-old"));
|
||||
existing.toml_config = Some("model = \"locally-preserved\"".to_string());
|
||||
@@ -1263,16 +1048,16 @@ mod tests {
|
||||
ProviderUpsertMode::Manual,
|
||||
)
|
||||
.unwrap();
|
||||
let merged = upsert_ccswitch_provider_on_connection(
|
||||
let added = upsert_ccswitch_provider_on_connection(
|
||||
&compatible,
|
||||
provider("cc-import", " same api ", Some("sk-imported")),
|
||||
"compatible-row",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(merged.kind, ProviderUpsertKind::Merged);
|
||||
assert_eq!(merged.provider.id, "local");
|
||||
assert_eq!(merged.provider.api_key.as_deref(), Some("sk-imported"));
|
||||
assert_eq!(provider_count(&compatible), 1);
|
||||
assert_eq!(added.kind, ProviderUpsertKind::Added);
|
||||
assert_eq!(added.provider.id, "cc-import");
|
||||
assert_eq!(added.provider.api_key.as_deref(), Some("sk-imported"));
|
||||
assert_eq!(provider_count(&compatible), 2);
|
||||
|
||||
let distinct = test_connection();
|
||||
upsert_provider_on_connection(
|
||||
@@ -1292,7 +1077,166 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_import_collision_keeps_manual_id_and_applies_source_values() {
|
||||
fn provider_upsert_keeps_manual_profiles_with_distinct_ids() {
|
||||
let conn = test_connection();
|
||||
let mut gpt = provider("gpt-profile", "Same API", Some("sk-shared"));
|
||||
gpt.model = "gpt-5.6".to_string();
|
||||
let added = upsert_provider_on_connection(&conn, gpt.clone(), ProviderUpsertMode::Manual)
|
||||
.expect("save GPT profile");
|
||||
assert_eq!(added.kind, ProviderUpsertKind::Added);
|
||||
|
||||
let exact_copy = SavedProvider {
|
||||
id: "gpt-copy".to_string(),
|
||||
..gpt.clone()
|
||||
};
|
||||
let added = upsert_provider_on_connection(&conn, exact_copy, ProviderUpsertMode::Manual)
|
||||
.expect("save an identical profile under a different ID");
|
||||
assert_eq!(added.kind, ProviderUpsertKind::Added);
|
||||
assert_eq!(added.provider.id, "gpt-copy");
|
||||
|
||||
let renamed_copy = SavedProvider {
|
||||
id: "gpt-renamed".to_string(),
|
||||
provider_name: "Renamed API".to_string(),
|
||||
..gpt.clone()
|
||||
};
|
||||
let added = upsert_provider_on_connection(&conn, renamed_copy, ProviderUpsertMode::Manual)
|
||||
.expect("save a renamed profile under a different ID");
|
||||
assert_eq!(added.kind, ProviderUpsertKind::Added);
|
||||
|
||||
let edited = SavedProvider {
|
||||
provider_name: "Edited GPT".to_string(),
|
||||
..gpt
|
||||
};
|
||||
let updated = upsert_provider_on_connection(&conn, edited, ProviderUpsertMode::Manual)
|
||||
.expect("update the profile with the same ID");
|
||||
assert_eq!(updated.kind, ProviderUpsertKind::Updated);
|
||||
assert_eq!(updated.provider.id, "gpt-profile");
|
||||
assert_eq!(updated.provider.provider_name, "Edited GPT");
|
||||
|
||||
let mut deepseek = provider("deepseek-profile", "Renamed API", Some("sk-shared"));
|
||||
deepseek.model = " deepseek-v3 ".to_string();
|
||||
let added = upsert_provider_on_connection(&conn, deepseek, ProviderUpsertMode::Manual)
|
||||
.expect("save DeepSeek profile with shared credentials");
|
||||
assert_eq!(added.kind, ProviderUpsertKind::Added);
|
||||
assert_eq!(provider_count(&conn), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_listing_preserves_insertion_order_when_timestamps_match() {
|
||||
let conn = test_connection();
|
||||
for id in ["z-original", "m-sibling", "a-copy"] {
|
||||
write_provider_on_connection(&conn, &provider(id, id, Some("sk-shared")))
|
||||
.expect("insert provider");
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE providers
|
||||
SET created_at = '2026-01-01T00:00:00Z',
|
||||
updated_at = CASE id
|
||||
WHEN 'z-original' THEN '2026-03-01T00:00:00Z'
|
||||
WHEN 'm-sibling' THEN '2026-02-01T00:00:00Z'
|
||||
ELSE '2026-01-01T00:00:00Z'
|
||||
END",
|
||||
[],
|
||||
)
|
||||
.expect("align creation timestamps");
|
||||
|
||||
let listed_ids = list_saved_providers_on_connection(&conn)
|
||||
.expect("list providers")
|
||||
.into_iter()
|
||||
.map(|provider| provider.id)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(listed_ids, ["z-original", "m-sibling", "a-copy"]);
|
||||
|
||||
let mut edited = provider("z-original", "Edited original", Some("sk-shared"));
|
||||
edited.model = "gpt-5.6".to_string();
|
||||
upsert_provider_on_connection(&conn, edited, ProviderUpsertMode::Manual)
|
||||
.expect("update first provider");
|
||||
let listed_ids = list_saved_providers_on_connection(&conn)
|
||||
.expect("list providers after edit")
|
||||
.into_iter()
|
||||
.map(|provider| provider.id)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(listed_ids, ["z-original", "m-sibling", "a-copy"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_save_preserves_an_existing_legacy_reserved_id() {
|
||||
let conn = test_connection();
|
||||
let legacy = provider("custom", "Legacy Provider", Some("sk-legacy"));
|
||||
write_provider_on_connection(&conn, &legacy).expect("seed legacy custom record");
|
||||
|
||||
let edited = SavedProvider {
|
||||
provider_name: "Edited Legacy Provider".to_string(),
|
||||
..legacy
|
||||
};
|
||||
let saved = save_manual_provider_on_connection(&conn, edited)
|
||||
.expect("edit the existing legacy custom record");
|
||||
|
||||
assert_eq!(saved.id, "custom");
|
||||
assert_eq!(saved.provider_name, "Edited Legacy Provider");
|
||||
assert_eq!(provider_count(&conn), 1);
|
||||
assert!(provider_by_id_on_connection(&conn, "custom-custom")
|
||||
.expect("look up normalized duplicate")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccswitch_import_keeps_ambiguous_manual_profiles_and_updates_by_source_id() {
|
||||
let conn = test_connection();
|
||||
let first = provider("manual-one", "Same API", Some("sk-shared"));
|
||||
let second = SavedProvider {
|
||||
id: "manual-two".to_string(),
|
||||
..first.clone()
|
||||
};
|
||||
upsert_provider_on_connection(&conn, first, ProviderUpsertMode::Manual)
|
||||
.expect("save first manual profile");
|
||||
upsert_provider_on_connection(&conn, second, ProviderUpsertMode::Manual)
|
||||
.expect("save second manual profile");
|
||||
|
||||
let imported = provider("cc-profile", "Same API", Some("sk-shared"));
|
||||
let added = upsert_ccswitch_provider_on_connection(&conn, imported.clone(), "cc-source-id")
|
||||
.expect("import alongside ambiguous manual profiles");
|
||||
assert_eq!(added.kind, ProviderUpsertKind::Added);
|
||||
assert_eq!(added.provider.id, "cc-profile");
|
||||
assert_eq!(provider_count(&conn), 3);
|
||||
|
||||
let repeated = SavedProvider {
|
||||
provider_name: "Updated from CC Switch".to_string(),
|
||||
..imported
|
||||
};
|
||||
let updated = upsert_ccswitch_provider_on_connection(&conn, repeated, "cc-source-id")
|
||||
.expect("update the same imported source row");
|
||||
assert_eq!(updated.kind, ProviderUpsertKind::Updated);
|
||||
assert_eq!(updated.provider.id, "cc-profile");
|
||||
assert_eq!(updated.provider.provider_name, "Updated from CC Switch");
|
||||
assert_eq!(provider_count(&conn), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccswitch_import_keeps_a_manual_copy_of_its_current_profile() {
|
||||
let conn = test_connection();
|
||||
let imported = provider("cc-profile", "Same API", Some("sk-shared"));
|
||||
upsert_ccswitch_provider_on_connection(&conn, imported.clone(), "cc-source-id")
|
||||
.expect("seed imported profile");
|
||||
let manual_copy = SavedProvider {
|
||||
id: "manual-copy".to_string(),
|
||||
..imported.clone()
|
||||
};
|
||||
upsert_provider_on_connection(&conn, manual_copy, ProviderUpsertMode::Manual)
|
||||
.expect("save manual copy");
|
||||
|
||||
let updated = upsert_ccswitch_provider_on_connection(&conn, imported, "cc-source-id")
|
||||
.expect("refresh imported profile by source ID");
|
||||
assert_eq!(updated.kind, ProviderUpsertKind::Updated);
|
||||
assert_eq!(updated.provider.id, "cc-profile");
|
||||
assert_eq!(provider_count(&conn), 2);
|
||||
assert!(provider_by_id_on_connection(&conn, "manual-copy")
|
||||
.expect("read manual copy")
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_import_updates_its_source_without_claiming_a_manual_id() {
|
||||
let conn = test_connection();
|
||||
let mut imported = provider("cc-old", "Imported old", Some("sk-old"));
|
||||
imported.base_url = "https://old.example.com/v1".to_string();
|
||||
@@ -1301,8 +1245,8 @@ mod tests {
|
||||
|
||||
let mut manual = provider("manual-local", "Locally edited", Some("sk-shared"));
|
||||
manual.base_url = "https://shared.example.com/v1".to_string();
|
||||
manual.model = "local-model".to_string();
|
||||
manual.toml_config = Some("model = \"local-model\"".to_string());
|
||||
manual.model = "remote-model".to_string();
|
||||
manual.toml_config = Some("model = \"remote-model\"".to_string());
|
||||
upsert_provider_on_connection(&conn, manual, ProviderUpsertMode::Manual)
|
||||
.expect("seed manual provider");
|
||||
|
||||
@@ -1310,20 +1254,26 @@ mod tests {
|
||||
changed.base_url = "https://shared.example.com/v1".to_string();
|
||||
changed.model = "remote-model".to_string();
|
||||
let result = upsert_ccswitch_provider_on_connection(&conn, changed, "cc-row")
|
||||
.expect("merge source update into manual record");
|
||||
.expect("update the imported source record");
|
||||
|
||||
assert_eq!(result.kind, ProviderUpsertKind::Merged);
|
||||
assert_eq!(result.provider.id, "manual-local");
|
||||
assert_eq!(result.kind, ProviderUpsertKind::Updated);
|
||||
assert_eq!(result.provider.id, "cc-old");
|
||||
assert_eq!(result.provider.provider_name, "Imported changed");
|
||||
assert_eq!(result.provider.model, "remote-model");
|
||||
assert_eq!(
|
||||
result.provider.toml_config.as_deref(),
|
||||
Some("model = \"local-model\"")
|
||||
);
|
||||
let rows = stored_providers_on_connection(&conn).expect("read merged providers");
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].source, CCSWITCH_LOCAL_PROVIDER_SOURCE);
|
||||
assert_eq!(rows[0].source_id.as_deref(), Some("cc-row"));
|
||||
let rows = stored_providers_on_connection(&conn).expect("read independent providers");
|
||||
assert_eq!(rows.len(), 2);
|
||||
let manual = rows
|
||||
.iter()
|
||||
.find(|row| row.provider.id == "manual-local")
|
||||
.expect("manual provider remains");
|
||||
assert_eq!(manual.provider.provider_name, "Locally edited");
|
||||
assert_eq!(manual.source, MANUAL_PROVIDER_SOURCE);
|
||||
let imported = rows
|
||||
.iter()
|
||||
.find(|row| row.provider.id == "cc-old")
|
||||
.expect("imported provider remains");
|
||||
assert_eq!(imported.source, CCSWITCH_PROVIDER_SOURCE);
|
||||
assert_eq!(imported.source_id.as_deref(), Some("cc-row"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1417,7 +1367,7 @@ enabled = true
|
||||
);
|
||||
let rows = stored_providers_on_connection(&conn).expect("read imported row");
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].source, CCSWITCH_LOCAL_PROVIDER_SOURCE);
|
||||
assert_eq!(rows[0].source, CCSWITCH_PROVIDER_SOURCE);
|
||||
assert_eq!(rows[0].source_id.as_deref(), Some("cc-row"));
|
||||
}
|
||||
|
||||
@@ -1463,7 +1413,7 @@ enabled = true
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_custom_ghost_cleanup_requires_one_stable_endpoint_match() {
|
||||
fn legacy_cleanup_preserves_historical_custom_ids() {
|
||||
let conn = test_connection();
|
||||
write_provider_on_connection(&conn, &provider("local", "Same API", Some("sk-current")))
|
||||
.unwrap();
|
||||
@@ -1475,12 +1425,12 @@ enabled = true
|
||||
|
||||
assert_eq!(
|
||||
consolidate_legacy_provider_duplicates_on_connection(&conn).unwrap(),
|
||||
1
|
||||
0
|
||||
);
|
||||
let rows = list_saved_providers_on_connection(&conn).unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].id, "local");
|
||||
assert_eq!(rows[0].api_key.as_deref(), Some("sk-current"));
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert!(rows.iter().any(|row| row.id == "local"));
|
||||
assert!(rows.iter().any(|row| row.id == "custom"));
|
||||
|
||||
let ambiguous = test_connection();
|
||||
write_provider_on_connection(
|
||||
@@ -1504,13 +1454,44 @@ enabled = true
|
||||
0
|
||||
);
|
||||
assert_eq!(provider_count(&ambiguous), 3);
|
||||
|
||||
let different_models = test_connection();
|
||||
let mut stable = provider("local", "Same API", Some("sk-current"));
|
||||
stable.model = "deepseek-v3".to_string();
|
||||
write_provider_on_connection(&different_models, &stable).unwrap();
|
||||
let mut ghost = provider("custom", " same api ", Some("sk-old-live"));
|
||||
ghost.model = "gpt-5.6".to_string();
|
||||
write_provider_on_connection(&different_models, &ghost).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
consolidate_legacy_provider_duplicates_on_connection(&different_models).unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(provider_count(&different_models), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_consolidation_keeps_local_id_but_next_import_is_authoritative() {
|
||||
fn legacy_consolidation_keeps_distinct_models_for_shared_credentials() {
|
||||
let conn = test_connection();
|
||||
let mut gpt = provider("gpt-profile", "Same API", Some("sk-shared"));
|
||||
gpt.model = "gpt-5.6".to_string();
|
||||
write_provider_on_connection(&conn, &gpt).unwrap();
|
||||
let mut deepseek = provider("deepseek-profile", "Same API", Some("sk-shared"));
|
||||
deepseek.model = "deepseek-v3".to_string();
|
||||
write_provider_on_connection(&conn, &deepseek).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
consolidate_legacy_provider_duplicates_on_connection(&conn).unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(provider_count(&conn), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_consolidation_preserves_manual_and_imported_ids() {
|
||||
let conn = test_connection();
|
||||
let mut imported = provider("imported-old", "Imported old", Some("sk-same"));
|
||||
imported.model = "old-model".to_string();
|
||||
imported.model = "latest-model".to_string();
|
||||
write_provider_with_origin(&conn, &imported, Some((CCSWITCH_PROVIDER_SOURCE, "cc-row")))
|
||||
.unwrap();
|
||||
let mut manual = provider("manual-local", "Edited locally", Some("sk-same"));
|
||||
@@ -1527,19 +1508,12 @@ enabled = true
|
||||
|
||||
assert_eq!(
|
||||
consolidate_legacy_provider_duplicates_on_connection(&conn).unwrap(),
|
||||
1
|
||||
0
|
||||
);
|
||||
let rows = stored_providers_on_connection(&conn).unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].provider.id, "manual-local");
|
||||
assert_eq!(rows[0].provider.provider_name, "Edited locally");
|
||||
assert_eq!(rows[0].provider.model, "latest-model");
|
||||
assert_eq!(
|
||||
rows[0].provider.toml_config.as_deref(),
|
||||
Some("model = \"latest-model\"")
|
||||
);
|
||||
assert_eq!(rows[0].source, CCSWITCH_LOCAL_PROVIDER_SOURCE);
|
||||
assert_eq!(rows[0].source_id.as_deref(), Some("cc-row"));
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert!(rows.iter().any(|row| row.provider.id == "manual-local"));
|
||||
assert!(rows.iter().any(|row| row.provider.id == "imported-old"));
|
||||
|
||||
let mut repeated = provider("imported-old", "Remote replacement", Some("sk-same"));
|
||||
repeated.model = "remote-model".to_string();
|
||||
@@ -1548,16 +1522,24 @@ enabled = true
|
||||
.expect("repeat import after legacy consolidation");
|
||||
|
||||
let rows = stored_providers_on_connection(&conn).unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].provider.id, "manual-local");
|
||||
assert_eq!(rows[0].provider.provider_name, "Remote replacement");
|
||||
assert_eq!(rows[0].provider.model, "remote-model");
|
||||
assert_eq!(rows.len(), 2);
|
||||
let manual = rows
|
||||
.iter()
|
||||
.find(|row| row.provider.id == "manual-local")
|
||||
.expect("manual provider remains");
|
||||
assert_eq!(manual.provider.provider_name, "Edited locally");
|
||||
assert_eq!(
|
||||
rows[0].provider.toml_config.as_deref(),
|
||||
Some("model = \"remote-model\"")
|
||||
manual.provider.toml_config.as_deref(),
|
||||
Some("model = \"latest-model\"")
|
||||
);
|
||||
assert_eq!(rows[0].source, CCSWITCH_LOCAL_PROVIDER_SOURCE);
|
||||
assert_eq!(rows[0].source_id.as_deref(), Some("cc-row"));
|
||||
let imported = rows
|
||||
.iter()
|
||||
.find(|row| row.provider.id == "imported-old")
|
||||
.expect("imported provider remains");
|
||||
assert_eq!(imported.provider.provider_name, "Remote replacement");
|
||||
assert_eq!(imported.provider.model, "remote-model");
|
||||
assert_eq!(imported.source, CCSWITCH_PROVIDER_SOURCE);
|
||||
assert_eq!(imported.source_id.as_deref(), Some("cc-row"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1584,13 +1566,13 @@ enabled = true
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_custom_matches_only_one_stable_provider_without_trusting_a_stale_key() {
|
||||
fn live_custom_matches_a_preserved_historical_record_by_its_profile() {
|
||||
let stable = provider("stable", "Same API", Some("sk-current"));
|
||||
let persisted_ghost = provider("custom", "Same API", Some("sk-old-live"));
|
||||
let historical = provider("custom", "Same API", Some("sk-old-live"));
|
||||
let live = provider("custom", " same api ", Some("sk-old-live"));
|
||||
assert_eq!(
|
||||
unique_saved_provider_id_for_live(&live, &[stable.clone(), persisted_ghost]),
|
||||
Some("stable".to_string())
|
||||
unique_saved_provider_id_for_live(&live, &[stable.clone(), historical]),
|
||||
Some("custom".to_string())
|
||||
);
|
||||
|
||||
let second = provider("second", "Same API", Some("sk-second"));
|
||||
@@ -1600,6 +1582,21 @@ enabled = true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_custom_matches_the_saved_profile_with_the_same_model() {
|
||||
let mut gpt = provider("gpt-profile", "Same API", Some("sk-shared"));
|
||||
gpt.model = "gpt-5.6".to_string();
|
||||
let mut deepseek = provider("deepseek-profile", "Same API", Some("sk-shared"));
|
||||
deepseek.model = "deepseek-v3".to_string();
|
||||
let mut live = provider("custom", "Same API", Some("sk-shared"));
|
||||
live.model = " deepseek-v3 ".to_string();
|
||||
|
||||
assert_eq!(
|
||||
unique_saved_provider_id_for_live(&live, &[gpt, deepseek]),
|
||||
Some("deepseek-profile".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_toml_keeps_extensions_and_syncs_explicit_field_edits() {
|
||||
let mut item = provider("saved", "Edited Name", Some("sk-explicit"));
|
||||
|
||||
@@ -194,13 +194,13 @@ fn is_locked_io_error(error: &std::io::Error) -> bool {
|
||||
}
|
||||
|
||||
pub(crate) fn scan_rollouts(codex_dir: &Path, target_provider: &str) -> Result<RolloutScan> {
|
||||
scan_rollouts_with_thread_filter(codex_dir, target_provider, None, None, true, None)
|
||||
scan_rollouts_with_thread_filter(codex_dir, target_provider, None, None, true, None, false)
|
||||
}
|
||||
|
||||
pub(super) fn scan_provider_rollouts(
|
||||
codex_dir: &Path,
|
||||
target_provider: &str,
|
||||
archived_thread_ids: &HashSet<String>,
|
||||
excluded_thread_ids: &HashSet<String>,
|
||||
) -> Result<RolloutScan> {
|
||||
scan_rollouts_with_thread_filter(
|
||||
codex_dir,
|
||||
@@ -208,7 +208,8 @@ pub(super) fn scan_provider_rollouts(
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
Some(archived_thread_ids),
|
||||
Some(excluded_thread_ids),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -225,6 +226,7 @@ pub(super) fn scan_rollouts_for_thread_ids(
|
||||
Some(rollout_paths_by_thread_id),
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -235,6 +237,7 @@ fn scan_rollouts_with_thread_filter(
|
||||
rollout_paths_by_thread_id: Option<&HashMap<String, String>>,
|
||||
include_archived_storage: bool,
|
||||
excluded_thread_ids: Option<&HashSet<String>>,
|
||||
exclude_source_marked_subagents: bool,
|
||||
) -> Result<RolloutScan> {
|
||||
let mut paths = Vec::new();
|
||||
let mut scan = RolloutScan::default();
|
||||
@@ -313,6 +316,7 @@ fn scan_rollouts_with_thread_filter(
|
||||
let mut invalid_session_meta = 0usize;
|
||||
let mut thread_id = None;
|
||||
let mut cwd = None;
|
||||
let mut is_subagent = false;
|
||||
|
||||
for segment in text.split_inclusive('\n') {
|
||||
let (line, line_ending) = split_line_ending(segment);
|
||||
@@ -336,6 +340,9 @@ fn scan_rollouts_with_thread_filter(
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_workspace_path);
|
||||
}
|
||||
if payload.get("source").is_some_and(source_value_is_subagent) {
|
||||
is_subagent = true;
|
||||
}
|
||||
if payload.get("model_provider").and_then(Value::as_str)
|
||||
!= Some(target_provider)
|
||||
{
|
||||
@@ -360,9 +367,10 @@ fn scan_rollouts_with_thread_filter(
|
||||
next_text.push_str(line_ending);
|
||||
}
|
||||
|
||||
if thread_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| excluded_thread_ids.is_some_and(|excluded| excluded.contains(id)))
|
||||
if (exclude_source_marked_subagents && is_subagent)
|
||||
|| thread_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| excluded_thread_ids.is_some_and(|excluded| excluded.contains(id)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -971,15 +979,7 @@ pub(super) fn sqlite_subagent_thread_ids(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
let source_is_subagent = source.eq_ignore_ascii_case("subagent")
|
||||
|| serde_json::from_str::<Value>(source)
|
||||
.ok()
|
||||
.is_some_and(|value| {
|
||||
value
|
||||
.as_object()
|
||||
.is_some_and(|object| object.contains_key("subagent"))
|
||||
});
|
||||
if source_is_subagent {
|
||||
if source_text_is_subagent(source) {
|
||||
ids.insert(id);
|
||||
} else {
|
||||
ids.remove(&id);
|
||||
@@ -991,6 +991,23 @@ pub(super) fn sqlite_subagent_thread_ids(
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn source_value_is_subagent(source: &Value) -> bool {
|
||||
match source {
|
||||
Value::String(source) => source.trim().eq_ignore_ascii_case("subagent"),
|
||||
Value::Object(source) => source.contains_key("subagent"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn source_text_is_subagent(source: &str) -> bool {
|
||||
let source = source.trim();
|
||||
source.eq_ignore_ascii_case("subagent")
|
||||
|| serde_json::from_str::<Value>(source)
|
||||
.ok()
|
||||
.as_ref()
|
||||
.is_some_and(source_value_is_subagent)
|
||||
}
|
||||
|
||||
pub(super) struct SqliteThreadIndexState<'a> {
|
||||
pub(super) thread_id: &'a str,
|
||||
pub(super) provider: Option<&'a str>,
|
||||
@@ -1121,12 +1138,17 @@ pub(super) fn scan_sqlite_with_paths(
|
||||
subagent_ids.extend(sqlite_subagent_thread_ids(&conn, &cols)?);
|
||||
}
|
||||
subagent_ids.retain(|id| thread_ids.contains(id));
|
||||
syncable_thread_ids.retain(|id| !subagent_ids.contains(id));
|
||||
rollout_paths_by_thread_id.retain(|id, _| !subagent_ids.contains(id));
|
||||
mismatched_ids.retain(|id| !subagent_ids.contains(id));
|
||||
scan.sqlite_threads = thread_ids.len();
|
||||
scan.subagent_threads = subagent_ids.len();
|
||||
scan.top_level_threads = thread_ids.len().saturating_sub(subagent_ids.len());
|
||||
scan.mismatched_threads = mismatched_ids.len();
|
||||
scan.thread_ids = thread_ids;
|
||||
scan.syncable_thread_ids = syncable_thread_ids;
|
||||
scan.archived_thread_ids = archived_thread_ids;
|
||||
scan.subagent_thread_ids = subagent_ids;
|
||||
scan.rollout_paths_by_thread_id = rollout_paths_by_thread_id;
|
||||
scan.mismatched_thread_ids = mismatched_ids;
|
||||
Ok(scan)
|
||||
@@ -1234,7 +1256,9 @@ pub(super) fn list_session_previews_with_paths(
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty());
|
||||
let is_archived = archived != 0;
|
||||
let is_subagent = subagent_thread_ids.contains(&id);
|
||||
let needs_sync = !is_archived
|
||||
&& !is_subagent
|
||||
&& (rollouts.mismatched_thread_ids.contains(&id)
|
||||
|| sqlite_thread_needs_alignment(
|
||||
rollouts,
|
||||
@@ -1247,7 +1271,6 @@ pub(super) fn list_session_previews_with_paths(
|
||||
archived: is_archived,
|
||||
},
|
||||
));
|
||||
let is_subagent = subagent_thread_ids.contains(&id);
|
||||
Ok(SessionPreview {
|
||||
id,
|
||||
title: clean_title,
|
||||
|
||||
@@ -97,15 +97,27 @@ fn scan_provider_sync_data(
|
||||
)?;
|
||||
let mut syncable_thread_ids = sqlite.syncable_thread_ids.clone();
|
||||
let mut archived_thread_ids = sqlite.archived_thread_ids.clone();
|
||||
let mut subagent_thread_ids = sqlite.subagent_thread_ids.clone();
|
||||
let mut mismatched_thread_ids = sqlite.mismatched_thread_ids.clone();
|
||||
if active_sqlite.sqlite_dbs > 0 {
|
||||
syncable_thread_ids.retain(|id| !active_sqlite.archived_thread_ids.contains(id));
|
||||
archived_thread_ids.retain(|id| !active_sqlite.syncable_thread_ids.contains(id));
|
||||
syncable_thread_ids.retain(|id| !active_sqlite.thread_ids.contains(id));
|
||||
archived_thread_ids.retain(|id| !active_sqlite.thread_ids.contains(id));
|
||||
subagent_thread_ids.retain(|id| !active_sqlite.thread_ids.contains(id));
|
||||
mismatched_thread_ids.retain(|id| !active_sqlite.thread_ids.contains(id));
|
||||
syncable_thread_ids.extend(active_sqlite.syncable_thread_ids.iter().cloned());
|
||||
archived_thread_ids.extend(active_sqlite.archived_thread_ids.iter().cloned());
|
||||
subagent_thread_ids.extend(active_sqlite.subagent_thread_ids.iter().cloned());
|
||||
mismatched_thread_ids.extend(active_sqlite.mismatched_thread_ids.iter().cloned());
|
||||
}
|
||||
// Without an authoritative active database, conflicting legacy rows are unsafe to mutate.
|
||||
syncable_thread_ids.retain(|id| !archived_thread_ids.contains(id));
|
||||
sqlite
|
||||
.mismatched_thread_ids
|
||||
.retain(|id| syncable_thread_ids.contains(id));
|
||||
let mut excluded_thread_ids = archived_thread_ids.clone();
|
||||
excluded_thread_ids.extend(subagent_thread_ids.iter().cloned());
|
||||
// Without an active authority, conflicting legacy classifications stay conservatively excluded.
|
||||
syncable_thread_ids.retain(|id| !excluded_thread_ids.contains(id));
|
||||
mismatched_thread_ids.retain(|id| syncable_thread_ids.contains(id));
|
||||
sqlite.syncable_thread_ids = syncable_thread_ids.clone();
|
||||
sqlite.archived_thread_ids = archived_thread_ids;
|
||||
sqlite.subagent_thread_ids = subagent_thread_ids;
|
||||
sqlite.mismatched_thread_ids = mismatched_thread_ids;
|
||||
sqlite.mismatched_threads = sqlite.mismatched_thread_ids.len();
|
||||
|
||||
let indexed_rollouts = if active_sqlite.sqlite_dbs > 0 {
|
||||
@@ -114,11 +126,11 @@ fn scan_provider_sync_data(
|
||||
scan_provider_buckets(codex_dir, target_provider, &sqlite)?
|
||||
};
|
||||
let legacy_index_warnings = scan_legacy_index_warnings(codex_dir, target_provider, discovery);
|
||||
let mut rollouts = scan_provider_rollouts(codex_dir, target_provider, &archived_thread_ids)?;
|
||||
let mut rollouts = scan_provider_rollouts(codex_dir, target_provider, &excluded_thread_ids)?;
|
||||
// Provider synchronization changes provider routing only; cwd remains independently managed.
|
||||
rollouts.cwd_by_thread_id.clear();
|
||||
syncable_thread_ids.extend(rollouts.thread_ids.iter().cloned());
|
||||
syncable_thread_ids.retain(|id| !archived_thread_ids.contains(id));
|
||||
syncable_thread_ids.retain(|id| !excluded_thread_ids.contains(id));
|
||||
let catalog = scan_catalog_sync(
|
||||
&discovery.thread_paths,
|
||||
&discovery.related_paths,
|
||||
@@ -235,7 +247,7 @@ pub(super) fn session_sync_status_with_discovery(
|
||||
let mut mismatched_ids = sqlite_mismatch_ids.clone();
|
||||
mismatched_ids.extend(scan.rollouts.mismatched_thread_ids.iter().cloned());
|
||||
for session in &mut sessions {
|
||||
if !session.archived && mismatched_ids.contains(&session.id) {
|
||||
if !session.archived && !session.is_subagent && mismatched_ids.contains(&session.id) {
|
||||
session.needs_sync = true;
|
||||
}
|
||||
}
|
||||
@@ -632,6 +644,34 @@ mod tests {
|
||||
.expect("write rollout");
|
||||
}
|
||||
|
||||
fn write_subagent_rollout(
|
||||
codex_dir: &Path,
|
||||
id: &str,
|
||||
parent_id: &str,
|
||||
provider: &str,
|
||||
) -> std::path::PathBuf {
|
||||
let path = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
|
||||
fs::create_dir_all(path.parent().expect("rollout parent")).expect("create rollout parent");
|
||||
let record = serde_json::json!({
|
||||
"type": "session_meta",
|
||||
"payload": {
|
||||
"id": id,
|
||||
"model_provider": provider,
|
||||
"source": {
|
||||
"subagent": {
|
||||
"thread_spawn": {
|
||||
"parent_thread_id": parent_id,
|
||||
"depth": 1,
|
||||
"agent_path": "/root/test-agent"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
fs::write(&path, format!("{record}\n")).expect("write subagent rollout");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_provider_is_used_as_the_sync_target() {
|
||||
let codex_dir = temp_codex_dir("live-provider-target");
|
||||
@@ -1032,6 +1072,68 @@ mod tests {
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_user_thread_overrides_stale_legacy_subagent_marker() {
|
||||
let codex_dir = temp_codex_dir("active-user-legacy-subagent");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let id = "019f6000-0000-7000-8000-000000000575";
|
||||
let active = codex_dir.join("state_5.sqlite");
|
||||
let legacy = codex_dir.join("sqlite/state_5.sqlite");
|
||||
for database in [&active, &legacy] {
|
||||
create_thread_database(database, id, "openai");
|
||||
Connection::open(database)
|
||||
.expect("open thread classification database")
|
||||
.execute_batch("ALTER TABLE threads ADD COLUMN thread_source TEXT;")
|
||||
.expect("add thread source column");
|
||||
}
|
||||
Connection::open(&active)
|
||||
.expect("open active user database")
|
||||
.execute(
|
||||
"UPDATE threads SET thread_source = 'user' WHERE id = ?1",
|
||||
[id],
|
||||
)
|
||||
.expect("mark active thread as user");
|
||||
Connection::open(&legacy)
|
||||
.expect("open legacy subagent database")
|
||||
.execute(
|
||||
"UPDATE threads SET thread_source = 'subagent' WHERE id = ?1",
|
||||
[id],
|
||||
)
|
||||
.expect("mark legacy thread as subagent");
|
||||
let rollout = write_rollout(&codex_dir, id, "openai");
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(id, "openai")]);
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan active user with stale legacy subagent marker");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert!(status.needs_sync);
|
||||
assert_eq!(status.subagent_threads, 0);
|
||||
assert_eq!(status.mismatched_threads, 1);
|
||||
assert_eq!(status.mismatched_sessions, 1);
|
||||
let preview = status
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|session| session.id == id)
|
||||
.expect("active user preview");
|
||||
assert!(!preview.is_subagent);
|
||||
assert!(preview.needs_sync);
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("synchronize authoritative active user thread");
|
||||
assert!(!result.status.needs_sync);
|
||||
assert_eq!(result.updated_rollouts, 1);
|
||||
assert!(result.updated_threads > 0);
|
||||
assert_eq!(thread_provider(&active, id), SHARED_SESSION_PROVIDER);
|
||||
assert_eq!(thread_provider(&legacy, id), SHARED_SESSION_PROVIDER);
|
||||
assert_eq!(catalog_provider(&catalog, id), SHARED_SESSION_PROVIDER);
|
||||
assert!(fs::read_to_string(rollout)
|
||||
.expect("read synchronized active user rollout")
|
||||
.contains("\"model_provider\":\"custom\""));
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_legacy_rollout_reference_warns_without_blocking_active_sync() {
|
||||
let codex_dir = temp_codex_dir("stale-legacy-rollout");
|
||||
@@ -1293,6 +1395,171 @@ mod tests {
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagents_are_visible_but_excluded_from_provider_sync() {
|
||||
let codex_dir = temp_codex_dir("subagents-not-syncable");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let parent_id = "019f6000-0000-7000-8000-000000000570";
|
||||
let edge_child_id = "019f6000-0000-7000-8000-000000000571";
|
||||
let source_child_id = "019f6000-0000-7000-8000-000000000572";
|
||||
let parent_rollout = write_rollout(&codex_dir, parent_id, SHARED_SESSION_PROVIDER);
|
||||
let edge_child_rollout =
|
||||
write_subagent_rollout(&codex_dir, edge_child_id, parent_id, "openai");
|
||||
let source_child_rollout =
|
||||
write_subagent_rollout(&codex_dir, source_child_id, parent_id, "openai");
|
||||
let edge_child_original = fs::read(&edge_child_rollout).expect("read edge child rollout");
|
||||
let source_child_original =
|
||||
fs::read(&source_child_rollout).expect("read source child rollout");
|
||||
|
||||
let state = codex_dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(
|
||||
&state,
|
||||
parent_id,
|
||||
SHARED_SESSION_PROVIDER,
|
||||
&parent_rollout,
|
||||
);
|
||||
let state_conn = Connection::open(&state).expect("open subagent thread database");
|
||||
state_conn
|
||||
.execute_batch(
|
||||
"ALTER TABLE threads ADD COLUMN thread_source TEXT;
|
||||
ALTER TABLE threads ADD COLUMN source TEXT;
|
||||
CREATE TABLE thread_spawn_edges (
|
||||
parent_thread_id TEXT NOT NULL,
|
||||
child_thread_id TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.expect("add subagent metadata");
|
||||
state_conn
|
||||
.execute(
|
||||
"INSERT INTO threads (id, model_provider, title, rollout_path)
|
||||
VALUES (?1, 'openai', 'edge child', ?2)",
|
||||
(edge_child_id, edge_child_rollout.display().to_string()),
|
||||
)
|
||||
.expect("insert edge child thread");
|
||||
state_conn
|
||||
.execute(
|
||||
"INSERT INTO thread_spawn_edges (parent_thread_id, child_thread_id)
|
||||
VALUES (?1, ?2)",
|
||||
(parent_id, edge_child_id),
|
||||
)
|
||||
.expect("insert child spawn edge");
|
||||
let source = serde_json::json!({
|
||||
"subagent": {
|
||||
"thread_spawn": {
|
||||
"parent_thread_id": parent_id,
|
||||
"depth": 1
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
state_conn
|
||||
.execute(
|
||||
"INSERT INTO threads (id, model_provider, title, rollout_path, source)
|
||||
VALUES (?1, 'openai', 'source child', ?2, ?3)",
|
||||
(
|
||||
source_child_id,
|
||||
source_child_rollout.display().to_string(),
|
||||
source,
|
||||
),
|
||||
)
|
||||
.expect("insert source child thread");
|
||||
drop(state_conn);
|
||||
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(
|
||||
&catalog,
|
||||
&[
|
||||
(parent_id, SHARED_SESSION_PROVIDER),
|
||||
(edge_child_id, "openai"),
|
||||
(source_child_id, "openai"),
|
||||
],
|
||||
);
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan subagent threads");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert!(!status.needs_sync);
|
||||
assert_eq!(status.sqlite_threads, 3);
|
||||
assert_eq!(status.top_level_threads, 1);
|
||||
assert_eq!(status.subagent_threads, 2);
|
||||
assert_eq!(status.mismatched_threads, 0);
|
||||
assert_eq!(status.mismatched_rollouts, 0);
|
||||
assert_eq!(status.mismatched_session_meta, 0);
|
||||
assert_eq!(status.mismatched_sessions, 0);
|
||||
for child_id in [edge_child_id, source_child_id] {
|
||||
let preview = status
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|session| session.id == child_id)
|
||||
.expect("subagent preview remains visible");
|
||||
assert!(preview.is_subagent);
|
||||
assert!(!preview.needs_sync);
|
||||
}
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("subagent-only mismatch is a no-op");
|
||||
assert_eq!(result.updated_rollouts, 0);
|
||||
assert_eq!(result.updated_threads, 0);
|
||||
assert!(result.backup_dir.is_empty());
|
||||
assert_eq!(thread_provider(&state, edge_child_id), "openai");
|
||||
assert_eq!(thread_provider(&state, source_child_id), "openai");
|
||||
assert_eq!(catalog_provider(&catalog, edge_child_id), "openai");
|
||||
assert_eq!(catalog_provider(&catalog, source_child_id), "openai");
|
||||
assert_eq!(
|
||||
fs::read(edge_child_rollout).expect("read preserved edge child rollout"),
|
||||
edge_child_original
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(source_child_rollout).expect("read preserved source child rollout"),
|
||||
source_child_original
|
||||
);
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_only_subagent_source_is_excluded_from_provider_sync() {
|
||||
let codex_dir = temp_codex_dir("rollout-only-subagent");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let parent_id = "019f6000-0000-7000-8000-000000000573";
|
||||
let child_id = "019f6000-0000-7000-8000-000000000574";
|
||||
create_thread_database(
|
||||
&codex_dir.join("state_5.sqlite"),
|
||||
parent_id,
|
||||
SHARED_SESSION_PROVIDER,
|
||||
);
|
||||
write_rollout(&codex_dir, parent_id, SHARED_SESSION_PROVIDER);
|
||||
let child_rollout = write_subagent_rollout(&codex_dir, child_id, parent_id, "openai");
|
||||
let child_original = fs::read(&child_rollout).expect("read rollout-only subagent");
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(
|
||||
&catalog,
|
||||
&[(parent_id, SHARED_SESSION_PROVIDER), (child_id, "openai")],
|
||||
);
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan rollout-only subagent");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert!(!status.needs_sync);
|
||||
assert_eq!(status.mismatched_threads, 0);
|
||||
assert_eq!(status.mismatched_rollouts, 0);
|
||||
assert_eq!(status.mismatched_session_meta, 0);
|
||||
assert_eq!(status.mismatched_sessions, 0);
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("rollout-only subagent is a no-op");
|
||||
assert_eq!(result.updated_rollouts, 0);
|
||||
assert_eq!(result.updated_threads, 0);
|
||||
assert!(result.backup_dir.is_empty());
|
||||
assert_eq!(catalog_provider(&catalog, child_id), "openai");
|
||||
assert_eq!(
|
||||
fs::read(child_rollout).expect("read preserved rollout-only subagent"),
|
||||
child_original
|
||||
);
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_between_scan_and_sqlite_lock_is_excluded_from_sync() {
|
||||
let codex_dir = temp_codex_dir("archive-during-sync");
|
||||
|
||||
@@ -81,8 +81,10 @@ pub(crate) struct SqliteScan {
|
||||
pub(crate) top_level_threads: usize,
|
||||
pub(crate) subagent_threads: usize,
|
||||
pub(crate) mismatched_threads: usize,
|
||||
pub(crate) thread_ids: HashSet<String>,
|
||||
pub(crate) syncable_thread_ids: HashSet<String>,
|
||||
pub(crate) archived_thread_ids: HashSet<String>,
|
||||
pub(crate) subagent_thread_ids: HashSet<String>,
|
||||
pub(crate) rollout_paths_by_thread_id: HashMap<String, String>,
|
||||
pub(crate) mismatched_thread_ids: HashSet<String>,
|
||||
pub(crate) warnings: Vec<String>,
|
||||
|
||||
@@ -297,6 +297,7 @@ pub(super) fn list_mcp_from_config(codex_dir: &Path) -> Result<Vec<ManagedMcpSer
|
||||
enabled: true,
|
||||
source: "config.toml".to_string(),
|
||||
summary,
|
||||
note: None,
|
||||
command,
|
||||
url,
|
||||
config_json: config,
|
||||
@@ -486,6 +487,7 @@ pub(super) fn preview_ccswitch_mcp_servers_for_codex(
|
||||
enabled: enabled_codex || live_enabled.contains(&id),
|
||||
source: "cc-switch".to_string(),
|
||||
summary,
|
||||
note: None,
|
||||
command,
|
||||
url,
|
||||
config_json: config,
|
||||
|
||||
@@ -18,17 +18,74 @@ pub(crate) use types::ManagedSkill;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::file_io::{ensure_directory, io_err};
|
||||
use crate::paths::home_dir;
|
||||
use crate::paths::{home_dir, normalized_path_scope};
|
||||
use crate::resolve_codex_dir;
|
||||
use crate::{now_rfc3339, open_db};
|
||||
use mcp::{
|
||||
db_managed_mcp, import_ccswitch_mcp_servers_for_codex, list_mcp_from_config, mcp_summary,
|
||||
preview_ccswitch_mcp_servers_for_codex, save_managed_mcp,
|
||||
};
|
||||
use rusqlite::params;
|
||||
use skills::{
|
||||
codex_skills_dir, copy_dir_recursive, disabled_skills_dir, sanitize_dir_name, scan_skill_dir,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const SKILLS_MCP_NOTE_MAX_CHARS: usize = 1000;
|
||||
|
||||
fn normalized_note_item_kind(item_kind: &str) -> Result<&'static str> {
|
||||
match item_kind.trim().to_ascii_lowercase().as_str() {
|
||||
"skill" => Ok("skill"),
|
||||
"mcp" => Ok("mcp"),
|
||||
_ => Err(crate::error::CodexxError::Config(
|
||||
"备注类型必须是 skill 或 mcp".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn skills_mcp_notes(codex_dir: &Path) -> Result<HashMap<(String, String), String>> {
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT item_kind, item_id, note
|
||||
FROM skills_mcp_notes
|
||||
WHERE codex_dir = ?1
|
||||
ORDER BY item_kind ASC, item_id ASC",
|
||||
)
|
||||
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([normalized_path_scope(codex_dir)], |row| {
|
||||
Ok((
|
||||
(row.get::<_, String>(0)?, row.get::<_, String>(1)?),
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
|
||||
let mut notes = HashMap::new();
|
||||
for row in rows {
|
||||
let (key, note) =
|
||||
row.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
|
||||
notes.insert(key, note);
|
||||
}
|
||||
Ok(notes)
|
||||
}
|
||||
|
||||
fn attach_skills_mcp_notes(
|
||||
codex_dir: &Path,
|
||||
skills: &mut [types::ManagedSkill],
|
||||
mcp_servers: &mut [ManagedMcpServer],
|
||||
) -> Result<()> {
|
||||
let notes = skills_mcp_notes(codex_dir)?;
|
||||
for skill in skills {
|
||||
skill.note = notes.get(&("skill".to_string(), skill.id.clone())).cloned();
|
||||
}
|
||||
for server in mcp_servers {
|
||||
server.note = notes.get(&("mcp".to_string(), server.id.clone())).cloned();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extend_unmanaged_mcp_candidates(
|
||||
output: &mut Vec<ManagedMcpServer>,
|
||||
@@ -82,11 +139,13 @@ pub(crate) fn build_skills_mcp_state_inner(config_dir: Option<String>) -> Result
|
||||
enabled,
|
||||
source: "Codex-X".to_string(),
|
||||
summary,
|
||||
note: None,
|
||||
command,
|
||||
url,
|
||||
config_json: config,
|
||||
});
|
||||
}
|
||||
attach_skills_mcp_notes(&codex_dir, &mut skills, &mut mcp_servers)?;
|
||||
sort_managed_mcp_servers(&mut mcp_servers);
|
||||
sort_managed_skills(&mut skills);
|
||||
Ok(SkillsMcpState {
|
||||
@@ -99,6 +158,63 @@ pub(crate) fn build_skills_mcp_state_inner(config_dir: Option<String>) -> Result
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn save_skills_mcp_note_inner(
|
||||
config_dir: Option<String>,
|
||||
item_kind: String,
|
||||
id: String,
|
||||
note: String,
|
||||
) -> Result<SkillsMcpState> {
|
||||
let item_kind = normalized_note_item_kind(&item_kind)?;
|
||||
if id.trim().is_empty() {
|
||||
return Err(crate::error::CodexxError::Config(
|
||||
"备注对象 ID 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
let id = id.as_str();
|
||||
let note = note.trim();
|
||||
if note.chars().count() > SKILLS_MCP_NOTE_MAX_CHARS {
|
||||
return Err(crate::error::CodexxError::Config(format!(
|
||||
"备注不能超过 {SKILLS_MCP_NOTE_MAX_CHARS} 个字符"
|
||||
)));
|
||||
}
|
||||
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
let codex_dir_scope = normalized_path_scope(&codex_dir);
|
||||
let current = build_skills_mcp_state_inner(config_dir.clone())?;
|
||||
let exists = match item_kind {
|
||||
"skill" => current.skills.iter().any(|skill| skill.id == id),
|
||||
"mcp" => current.mcp_servers.iter().any(|server| server.id == id),
|
||||
_ => false,
|
||||
};
|
||||
if !exists {
|
||||
return Err(crate::error::CodexxError::Config(format!(
|
||||
"未找到要备注的 {item_kind}: {id}"
|
||||
)));
|
||||
}
|
||||
|
||||
let conn = open_db()?;
|
||||
if note.is_empty() {
|
||||
conn.execute(
|
||||
"DELETE FROM skills_mcp_notes
|
||||
WHERE codex_dir = ?1 AND item_kind = ?2 AND item_id = ?3",
|
||||
params![codex_dir_scope, item_kind, id],
|
||||
)
|
||||
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
|
||||
} else {
|
||||
conn.execute(
|
||||
"INSERT INTO skills_mcp_notes
|
||||
(codex_dir, item_kind, item_id, note, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(codex_dir, item_kind, item_id) DO UPDATE SET
|
||||
note = excluded.note,
|
||||
updated_at = excluded.updated_at",
|
||||
params![codex_dir_scope, item_kind, id, note, now_rfc3339()],
|
||||
)
|
||||
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
|
||||
}
|
||||
build_skills_mcp_state_inner(config_dir)
|
||||
}
|
||||
|
||||
pub(crate) fn import_existing_skills_mcp_inner(
|
||||
config_dir: Option<String>,
|
||||
) -> Result<SkillsMcpActionResult> {
|
||||
@@ -223,6 +339,7 @@ mod tests {
|
||||
enabled: true,
|
||||
source: source.to_string(),
|
||||
summary: id.to_string(),
|
||||
note: None,
|
||||
command: Some(id.to_string()),
|
||||
url: None,
|
||||
config_json: json!({ "command": id }),
|
||||
@@ -271,4 +388,161 @@ mod tests {
|
||||
);
|
||||
assert!(second_preview.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_notes_are_isolated_by_kind_and_codex_home_and_empty_text_clears_them() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let item_id = format!("shared-note-item-{}", std::process::id());
|
||||
let spaced_id = " spaced-mcp ";
|
||||
let codex_dir =
|
||||
std::env::temp_dir().join(format!("codex-x-note-state-{}", std::process::id()));
|
||||
let other_codex_dir =
|
||||
std::env::temp_dir().join(format!("codex-x-note-state-other-{}", std::process::id()));
|
||||
let prepare_codex_dir = |dir: &Path| {
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
let skill_dir = dir.join("skills").join(&item_id);
|
||||
fs::create_dir_all(&skill_dir).expect("create test skill directory");
|
||||
fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
format!("---\nname: {item_id}\ndescription: test\n---\n"),
|
||||
)
|
||||
.expect("write test skill");
|
||||
fs::write(
|
||||
dir.join("config.toml"),
|
||||
format!(
|
||||
"[mcp_servers.{item_id}]\ncommand = \"test-server\"\n\
|
||||
[mcp_servers.\"{spaced_id}\"]\ncommand = \"spaced-server\"\n"
|
||||
),
|
||||
)
|
||||
.expect("write test MCP config");
|
||||
};
|
||||
prepare_codex_dir(&codex_dir);
|
||||
prepare_codex_dir(&other_codex_dir);
|
||||
|
||||
let conn = open_db().expect("open app database");
|
||||
conn.execute(
|
||||
"DELETE FROM skills_mcp_notes WHERE item_id IN (?1, ?2)",
|
||||
params![&item_id, spaced_id],
|
||||
)
|
||||
.expect("clear stale test notes");
|
||||
drop(conn);
|
||||
let config_dir = Some(codex_dir.display().to_string());
|
||||
|
||||
save_skills_mcp_note_inner(
|
||||
config_dir.clone(),
|
||||
"skill".to_string(),
|
||||
item_id.clone(),
|
||||
" 我的 Skill 备注 ".to_string(),
|
||||
)
|
||||
.expect("save skill note");
|
||||
let state = save_skills_mcp_note_inner(
|
||||
config_dir.clone(),
|
||||
"mcp".to_string(),
|
||||
item_id.clone(),
|
||||
"MCP note".to_string(),
|
||||
)
|
||||
.expect("save MCP note");
|
||||
assert_eq!(
|
||||
state
|
||||
.skills
|
||||
.iter()
|
||||
.find(|skill| skill.id == item_id)
|
||||
.and_then(|skill| skill.note.as_deref()),
|
||||
Some("我的 Skill 备注")
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.find(|server| server.id == item_id)
|
||||
.and_then(|server| server.note.as_deref()),
|
||||
Some("MCP note")
|
||||
);
|
||||
let state = save_skills_mcp_note_inner(
|
||||
config_dir.clone(),
|
||||
"mcp".to_string(),
|
||||
spaced_id.to_string(),
|
||||
"Spaced ID note".to_string(),
|
||||
)
|
||||
.expect("save note without normalizing the MCP ID");
|
||||
assert_eq!(
|
||||
state
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.find(|server| server.id == spaced_id)
|
||||
.and_then(|server| server.note.as_deref()),
|
||||
Some("Spaced ID note")
|
||||
);
|
||||
|
||||
let other_config_dir = Some(other_codex_dir.display().to_string());
|
||||
let other_state = build_skills_mcp_state_inner(other_config_dir.clone())
|
||||
.expect("load other CODEX_HOME state");
|
||||
assert!(other_state
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.find(|server| server.id == item_id)
|
||||
.is_some_and(|server| server.note.is_none()));
|
||||
save_skills_mcp_note_inner(
|
||||
other_config_dir,
|
||||
"mcp".to_string(),
|
||||
item_id.clone(),
|
||||
"Other MCP note".to_string(),
|
||||
)
|
||||
.expect("save note for other CODEX_HOME");
|
||||
let original_state = build_skills_mcp_state_inner(config_dir.clone())
|
||||
.expect("reload original CODEX_HOME state");
|
||||
assert_eq!(
|
||||
original_state
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.find(|server| server.id == item_id)
|
||||
.and_then(|server| server.note.as_deref()),
|
||||
Some("MCP note")
|
||||
);
|
||||
|
||||
let state = save_skills_mcp_note_inner(
|
||||
config_dir.clone(),
|
||||
"skill".to_string(),
|
||||
item_id.clone(),
|
||||
" ".to_string(),
|
||||
)
|
||||
.expect("clear skill note");
|
||||
assert!(state
|
||||
.skills
|
||||
.iter()
|
||||
.find(|skill| skill.id == item_id)
|
||||
.is_some_and(|skill| skill.note.is_none()));
|
||||
assert_eq!(
|
||||
state
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.find(|server| server.id == item_id)
|
||||
.and_then(|server| server.note.as_deref()),
|
||||
Some("MCP note")
|
||||
);
|
||||
assert!(save_skills_mcp_note_inner(
|
||||
config_dir.clone(),
|
||||
"unknown".to_string(),
|
||||
item_id.clone(),
|
||||
"note".to_string(),
|
||||
)
|
||||
.is_err());
|
||||
assert!(save_skills_mcp_note_inner(
|
||||
config_dir,
|
||||
"mcp".to_string(),
|
||||
item_id.clone(),
|
||||
"x".repeat(SKILLS_MCP_NOTE_MAX_CHARS + 1),
|
||||
)
|
||||
.is_err());
|
||||
|
||||
let conn = open_db().expect("reopen app database");
|
||||
conn.execute(
|
||||
"DELETE FROM skills_mcp_notes WHERE item_id IN (?1, ?2)",
|
||||
params![&item_id, spaced_id],
|
||||
)
|
||||
.expect("remove test notes");
|
||||
drop(conn);
|
||||
fs::remove_dir_all(&codex_dir).expect("remove test Codex directory");
|
||||
fs::remove_dir_all(&other_codex_dir).expect("remove other test Codex directory");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +258,7 @@ pub(super) fn scan_skill_dir(
|
||||
id: id.clone(),
|
||||
name,
|
||||
description,
|
||||
note: None,
|
||||
directory,
|
||||
enabled,
|
||||
source: source.to_string(),
|
||||
|
||||
@@ -10,6 +10,7 @@ pub(crate) struct ManagedMcpServer {
|
||||
pub(crate) enabled: bool,
|
||||
pub(crate) source: String,
|
||||
pub(crate) summary: String,
|
||||
pub(crate) note: Option<String>,
|
||||
pub(crate) command: Option<String>,
|
||||
pub(crate) url: Option<String>,
|
||||
pub(crate) config_json: Value,
|
||||
@@ -21,6 +22,7 @@ pub(crate) struct ManagedSkill {
|
||||
pub(crate) id: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) description: Option<String>,
|
||||
pub(crate) note: Option<String>,
|
||||
pub(crate) directory: String,
|
||||
pub(crate) enabled: bool,
|
||||
pub(crate) source: String,
|
||||
|
||||
@@ -7,8 +7,9 @@ use crate::prompts::{
|
||||
agents_path, managed_agents_template_key, prompt_template_key_for_instruction,
|
||||
};
|
||||
use crate::providers::{
|
||||
detected_live_custom_provider, document_is_official, list_saved_providers_inner,
|
||||
official_auth_available, unique_saved_provider_id_for_live, SavedProvider,
|
||||
clear_active_provider_on_connection, detected_live_custom_provider, document_is_official,
|
||||
list_saved_providers_on_connection, matching_saved_provider_ids_for_live,
|
||||
official_auth_available, open_store, reconcile_active_provider_on_connection, SavedProvider,
|
||||
};
|
||||
use crate::{auth_path, config_path, string_value};
|
||||
use serde::Serialize;
|
||||
@@ -31,7 +32,7 @@ struct ProviderSummary {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CodexState {
|
||||
codex_dir: String,
|
||||
pub(crate) codex_dir: String,
|
||||
config_path: String,
|
||||
auth_path: String,
|
||||
config_exists: bool,
|
||||
@@ -45,7 +46,7 @@ pub(crate) struct CodexState {
|
||||
pub(crate) instruction_injection_mode: Option<String>,
|
||||
pub(crate) instruction_template_key: Option<String>,
|
||||
agents_path: String,
|
||||
active_saved_provider_id: Option<String>,
|
||||
pub(crate) active_saved_provider_id: Option<String>,
|
||||
providers: Vec<ProviderSummary>,
|
||||
pub(crate) config_text: String,
|
||||
auth_preview: Option<Value>,
|
||||
@@ -159,15 +160,15 @@ fn normalized_provider_toml_for_match(text: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn active_saved_provider_id_from_config(
|
||||
fn matching_saved_provider_ids_from_config(
|
||||
config_text: &str,
|
||||
providers: &[SavedProvider],
|
||||
) -> Option<String> {
|
||||
) -> Vec<String> {
|
||||
let live = normalized_provider_toml_for_match(config_text);
|
||||
if live.is_empty() {
|
||||
return None;
|
||||
return Vec::new();
|
||||
}
|
||||
let matches = providers
|
||||
providers
|
||||
.iter()
|
||||
.filter(|provider| {
|
||||
provider
|
||||
@@ -175,8 +176,17 @@ pub(crate) fn active_saved_provider_id_from_config(
|
||||
.as_deref()
|
||||
.is_some_and(|toml| normalized_provider_toml_for_match(toml) == live)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(matches.len() == 1).then(|| matches[0].id.clone())
|
||||
.map(|provider| provider.id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn active_saved_provider_id_from_config(
|
||||
config_text: &str,
|
||||
providers: &[SavedProvider],
|
||||
) -> Option<String> {
|
||||
let matches = matching_saved_provider_ids_from_config(config_text, providers);
|
||||
(matches.len() == 1).then(|| matches[0].clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -210,13 +220,17 @@ pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexSta
|
||||
};
|
||||
let instruction_enabled = instruction_template_key.is_some();
|
||||
let providers = extract_providers(&doc, model_provider.as_deref());
|
||||
let saved_providers = list_saved_providers_inner()?;
|
||||
let conn = open_store()?;
|
||||
let saved_providers = list_saved_providers_on_connection(&conn)?;
|
||||
let active_saved_provider_id = if is_official_provider {
|
||||
clear_active_provider_on_connection(&conn, &codex_dir)?;
|
||||
None
|
||||
} else if let Some(live) = detected_live_custom_provider(&codex_dir)? {
|
||||
unique_saved_provider_id_for_live(&live, &saved_providers)
|
||||
let candidates = matching_saved_provider_ids_for_live(&live, &saved_providers);
|
||||
reconcile_active_provider_on_connection(&conn, &codex_dir, &candidates)?
|
||||
} else {
|
||||
active_saved_provider_id_from_config(&text, &saved_providers)
|
||||
let candidates = matching_saved_provider_ids_from_config(&text, &saved_providers);
|
||||
reconcile_active_provider_on_connection(&conn, &codex_dir, &candidates)?
|
||||
};
|
||||
|
||||
Ok(CodexState {
|
||||
|
||||
@@ -261,7 +261,7 @@ experimental_bearer_token = "sk-same"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_provider_save_upserts_same_url_and_key_but_keeps_different_keys() {
|
||||
fn manual_provider_save_keeps_independent_ids_with_any_configuration() {
|
||||
let conn = provider_test_connection();
|
||||
let first = normalize_saved_provider(provider_fixture(
|
||||
"first",
|
||||
@@ -272,11 +272,29 @@ fn manual_provider_save_upserts_same_url_and_key_but_keeps_different_keys() {
|
||||
None,
|
||||
))
|
||||
.expect("normalize first");
|
||||
let added =
|
||||
upsert_provider_on_connection(&conn, first, ProviderUpsertMode::Manual).expect("add first");
|
||||
let added = upsert_provider_on_connection(&conn, first.clone(), ProviderUpsertMode::Manual)
|
||||
.expect("add first");
|
||||
assert_eq!(added.kind, ProviderUpsertKind::Added);
|
||||
|
||||
let renamed = normalize_saved_provider(provider_fixture(
|
||||
let exact_copy = SavedProvider {
|
||||
id: "exact-copy".to_string(),
|
||||
..first.clone()
|
||||
};
|
||||
let exact_add = upsert_provider_on_connection(&conn, exact_copy, ProviderUpsertMode::Manual)
|
||||
.expect("keep exact copy with a distinct ID");
|
||||
assert_eq!(exact_add.kind, ProviderUpsertKind::Added);
|
||||
|
||||
let renamed_copy = SavedProvider {
|
||||
id: "renamed-copy".to_string(),
|
||||
provider_name: "Only Name Changed".to_string(),
|
||||
..first
|
||||
};
|
||||
let renamed_add =
|
||||
upsert_provider_on_connection(&conn, renamed_copy, ProviderUpsertMode::Manual)
|
||||
.expect("keep renamed copy with a distinct ID");
|
||||
assert_eq!(renamed_add.kind, ProviderUpsertKind::Added);
|
||||
|
||||
let different_model = normalize_saved_provider(provider_fixture(
|
||||
"second",
|
||||
"Second Name",
|
||||
"HTTPS://EXAMPLE.COM:443/v1",
|
||||
@@ -284,13 +302,13 @@ fn manual_provider_save_upserts_same_url_and_key_but_keeps_different_keys() {
|
||||
"model-b",
|
||||
None,
|
||||
))
|
||||
.expect("normalize renamed");
|
||||
let merged = upsert_provider_on_connection(&conn, renamed, ProviderUpsertMode::Manual)
|
||||
.expect("merge same identity");
|
||||
assert_eq!(merged.kind, ProviderUpsertKind::Merged);
|
||||
assert_eq!(merged.provider.id, "first");
|
||||
assert_eq!(merged.provider.provider_name, "Second Name");
|
||||
assert_eq!(merged.provider.model, "model-b");
|
||||
.expect("normalize different model");
|
||||
let model_add =
|
||||
upsert_provider_on_connection(&conn, different_model, ProviderUpsertMode::Manual)
|
||||
.expect("keep different model");
|
||||
assert_eq!(model_add.kind, ProviderUpsertKind::Added);
|
||||
assert_eq!(model_add.provider.id, "second");
|
||||
assert_eq!(model_add.provider.model, "model-b");
|
||||
|
||||
let other_key = normalize_saved_provider(provider_fixture(
|
||||
"third",
|
||||
@@ -304,11 +322,11 @@ fn manual_provider_save_upserts_same_url_and_key_but_keeps_different_keys() {
|
||||
let second_add = upsert_provider_on_connection(&conn, other_key, ProviderUpsertMode::Manual)
|
||||
.expect("keep different credential");
|
||||
assert_eq!(second_add.kind, ProviderUpsertKind::Added);
|
||||
assert_eq!(list_saved_providers_on_connection(&conn).unwrap().len(), 2);
|
||||
assert_eq!(list_saved_providers_on_connection(&conn).unwrap().len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_provider_merge_keeps_local_id_and_applies_complete_source() {
|
||||
fn imported_provider_keeps_its_source_id_beside_a_matching_manual_record() {
|
||||
let conn = provider_test_connection();
|
||||
let local_toml = r#"model_provider = "custom"
|
||||
model = "local-model"
|
||||
@@ -329,7 +347,7 @@ experimental_bearer_token = "sk-same"
|
||||
|
||||
let imported_toml = r#"# authoritative cc-switch template
|
||||
model_provider = "custom"
|
||||
model = "cc-model"
|
||||
model = "local-model"
|
||||
service_tier = "priority"
|
||||
|
||||
[model_providers.custom]
|
||||
@@ -346,17 +364,17 @@ trust_level = "trusted"
|
||||
"CC Name",
|
||||
"https://EXAMPLE.com:443/v1/",
|
||||
Some("sk-same"),
|
||||
"cc-model",
|
||||
"local-model",
|
||||
Some(imported_toml),
|
||||
);
|
||||
imported_fixture.requires_openai_auth = false;
|
||||
let imported = normalize_saved_provider(imported_fixture).expect("normalize import");
|
||||
let result = upsert_ccswitch_provider_on_connection(&conn, imported.clone(), "cc-switch-id")
|
||||
.expect("merge import");
|
||||
assert_eq!(result.kind, ProviderUpsertKind::Merged);
|
||||
assert_eq!(result.provider.id, "local");
|
||||
.expect("import independent source record");
|
||||
assert_eq!(result.kind, ProviderUpsertKind::Added);
|
||||
assert_eq!(result.provider.id, "cc-switch-id");
|
||||
assert_eq!(result.provider.provider_name, "CC Name");
|
||||
assert_eq!(result.provider.model, "cc-model");
|
||||
assert_eq!(result.provider.model, "local-model");
|
||||
assert!(!result.provider.requires_openai_auth);
|
||||
let normalized_toml = result.provider.toml_config.as_deref().unwrap();
|
||||
assert!(normalized_toml.contains("# authoritative cc-switch template"));
|
||||
@@ -365,27 +383,28 @@ trust_level = "trusted"
|
||||
assert!(normalized_toml.contains("[projects.\"/work/project\"]"));
|
||||
assert!(normalized_toml.contains("wire_api = \"responses\""));
|
||||
assert!(!normalized_toml.contains("experimental_bearer_token"));
|
||||
assert_eq!(list_saved_providers_on_connection(&conn).unwrap().len(), 1);
|
||||
assert_eq!(list_saved_providers_on_connection(&conn).unwrap().len(), 2);
|
||||
|
||||
let repeated = upsert_ccswitch_provider_on_connection(&conn, imported, "cc-switch-id")
|
||||
.expect("repeat identical import");
|
||||
assert_eq!(repeated.provider.id, "local");
|
||||
assert_eq!(repeated.kind, ProviderUpsertKind::Updated);
|
||||
assert_eq!(repeated.provider.id, "cc-switch-id");
|
||||
assert_eq!(
|
||||
repeated.provider.toml_config.as_deref(),
|
||||
Some(normalized_toml)
|
||||
);
|
||||
assert_eq!(list_saved_providers_on_connection(&conn).unwrap().len(), 1);
|
||||
assert_eq!(list_saved_providers_on_connection(&conn).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_migration_keeps_the_latest_exact_credential_record() {
|
||||
fn provider_migration_preserves_independent_manual_profiles() {
|
||||
let conn = provider_test_connection();
|
||||
let first = provider_fixture(
|
||||
"first-id",
|
||||
"Local Name",
|
||||
"HTTPS://EXAMPLE.com:443/v1/",
|
||||
Some("sk-same"),
|
||||
"local-model",
|
||||
"imported-model",
|
||||
None,
|
||||
);
|
||||
let duplicate = provider_fixture(
|
||||
@@ -458,21 +477,15 @@ fn provider_migration_keeps_the_latest_exact_credential_record() {
|
||||
);
|
||||
assert_eq!(
|
||||
consolidate_legacy_provider_duplicates_on_connection(&conn).unwrap(),
|
||||
1
|
||||
0
|
||||
);
|
||||
let rows = list_saved_providers_on_connection(&conn).unwrap();
|
||||
assert_eq!(rows.len(), 4);
|
||||
let survivor = rows.iter().find(|row| row.id == "later-id").unwrap();
|
||||
assert_eq!(survivor.provider_name, "Imported Name");
|
||||
assert_eq!(survivor.model, "imported-model");
|
||||
assert_eq!(
|
||||
survivor.toml_config.as_deref(),
|
||||
Some("local preserved toml")
|
||||
);
|
||||
assert_eq!(rows.len(), 5);
|
||||
assert!(rows.iter().any(|row| row.id == "first-id"));
|
||||
assert!(rows.iter().any(|row| row.id == "later-id"));
|
||||
assert!(rows.iter().any(|row| row.id == "different-key"));
|
||||
assert!(rows.iter().any(|row| row.id == "anonymous-a"));
|
||||
assert!(rows.iter().any(|row| row.id == "anonymous-b"));
|
||||
assert!(!rows.iter().any(|row| row.id == "first-id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -588,6 +601,7 @@ fn skills_and_mcp_order_does_not_depend_on_enabled_state() {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
note: None,
|
||||
directory: id.to_string(),
|
||||
enabled,
|
||||
source: "test".to_string(),
|
||||
@@ -602,6 +616,7 @@ fn skills_and_mcp_order_does_not_depend_on_enabled_state() {
|
||||
enabled,
|
||||
source: "test".to_string(),
|
||||
summary: String::new(),
|
||||
note: None,
|
||||
command: None,
|
||||
url: None,
|
||||
config_json: json!({}),
|
||||
@@ -3146,6 +3161,116 @@ requires_openai_auth = true
|
||||
let _ = fs::remove_dir_all(codex_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_save_and_snapshot_commands_preserve_current_until_explicit_switch() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let codex_dir = temp_codex_dir("official-snapshot-preserves-current-duplicate");
|
||||
let custom_config = r#"model_provider = "custom"
|
||||
model = "duplicate-current-model"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "Duplicate Current Provider"
|
||||
base_url = "https://duplicate-current.example.com/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = true
|
||||
"#;
|
||||
write_text(&config_path(&codex_dir), custom_config).expect("write custom config");
|
||||
write_json(
|
||||
&auth_path(&codex_dir),
|
||||
&json!({"OPENAI_API_KEY": "sk-duplicate-current", "auth_mode": "apikey"}),
|
||||
)
|
||||
.expect("write proxy auth");
|
||||
|
||||
let original_id = "official-snapshot-current-original";
|
||||
let copy_id = "official-snapshot-current-copy";
|
||||
let original = SavedProvider {
|
||||
id: original_id.to_string(),
|
||||
provider_name: "Duplicate Current Provider".to_string(),
|
||||
base_url: "https://duplicate-current.example.com/v1".to_string(),
|
||||
model: "duplicate-current-model".to_string(),
|
||||
api_key: Some("sk-duplicate-current".to_string()),
|
||||
toml_config: Some(custom_config.trim_end().to_string()),
|
||||
wire_api: "responses".to_string(),
|
||||
requires_openai_auth: true,
|
||||
};
|
||||
save_provider_inner(original.clone()).expect("save original provider");
|
||||
assert_eq!(
|
||||
build_state(codex_dir.clone())
|
||||
.expect("discover and remember the unique current provider")
|
||||
.active_saved_provider_id
|
||||
.as_deref(),
|
||||
Some(original_id)
|
||||
);
|
||||
save_provider_inner(SavedProvider {
|
||||
id: copy_id.to_string(),
|
||||
..original
|
||||
})
|
||||
.expect("save identical copy");
|
||||
assert_eq!(
|
||||
build_state(codex_dir.clone())
|
||||
.expect("refresh after saving copy")
|
||||
.active_saved_provider_id
|
||||
.as_deref(),
|
||||
Some(original_id)
|
||||
);
|
||||
|
||||
let saved = save_official_config_inner(
|
||||
Some(codex_dir.display().to_string()),
|
||||
Some("official-model".to_string()),
|
||||
Some(
|
||||
json!({
|
||||
"auth_mode": "chatgpt",
|
||||
"tokens": {"access_token": "official-access-token"}
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
None,
|
||||
)
|
||||
.expect("save independent official snapshot");
|
||||
let saved = finish_provider_selection(saved, ActiveProviderSelectionUpdate::ClearIfOfficial);
|
||||
assert!(!saved.state.is_official_provider);
|
||||
assert_eq!(
|
||||
saved.state.active_saved_provider_id.as_deref(),
|
||||
Some(original_id)
|
||||
);
|
||||
|
||||
let restored = restore_official_provider_inner(Some(codex_dir.display().to_string()))
|
||||
.expect("restore independent official snapshot");
|
||||
let restored =
|
||||
finish_provider_selection(restored, ActiveProviderSelectionUpdate::ClearIfOfficial);
|
||||
assert!(!restored.state.is_official_provider);
|
||||
assert_eq!(
|
||||
restored.state.active_saved_provider_id.as_deref(),
|
||||
Some(original_id)
|
||||
);
|
||||
assert_eq!(
|
||||
build_state(codex_dir.clone())
|
||||
.expect("refresh current provider state")
|
||||
.active_saved_provider_id
|
||||
.as_deref(),
|
||||
Some(original_id)
|
||||
);
|
||||
|
||||
let switched = save_provider_toml_config_inner(ProviderTomlInput {
|
||||
config_dir: Some(codex_dir.display().to_string()),
|
||||
config_text: custom_config.to_string(),
|
||||
api_key: Some("sk-duplicate-current".to_string()),
|
||||
})
|
||||
.expect("apply the identical copied provider");
|
||||
let switched = finish_provider_selection(
|
||||
switched,
|
||||
ActiveProviderSelectionUpdate::Set(copy_id.to_string()),
|
||||
);
|
||||
assert_eq!(
|
||||
switched.state.active_saved_provider_id.as_deref(),
|
||||
Some(copy_id)
|
||||
);
|
||||
|
||||
providers::delete_provider_inner(copy_id).expect("delete copied provider");
|
||||
providers::delete_provider_inner(original_id).expect("delete original provider");
|
||||
let _ = fs::remove_dir_all(codex_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_official_draft_from_proxy_preserves_complete_live_config() {
|
||||
let codex_dir = temp_codex_dir("first-official-draft-from-proxy");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Codex-X",
|
||||
"version": "0.3.13",
|
||||
"version": "0.3.15",
|
||||
"identifier": "com.yynxxxxx.codexx",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+146
-80
@@ -11,7 +11,7 @@ import {
|
||||
import { OverviewPage } from "./pages/OverviewPage";
|
||||
import { AboutPage, SettingsPage, TomlConfigPage } from "./pages/UtilityPages";
|
||||
import { PromptsPage } from "./pages/PromptsPage";
|
||||
import { SkillsMcpPage } from "./pages/SkillsMcpPage";
|
||||
import { SkillsMcpPage, type SkillsMcpNoteKind } from "./pages/SkillsMcpPage";
|
||||
import { ProvidersPage, type ProviderCopy, type ProviderRow } from "./pages/ProvidersPage";
|
||||
import { AppShell, type AppTab, type AppTheme } from "./components/AppShell";
|
||||
import {
|
||||
@@ -22,12 +22,15 @@ import {
|
||||
import { PageTransition } from "./components/PageTransition";
|
||||
import { cx } from "./components/ui";
|
||||
import { appUpdater, useAppUpdater } from "./appUpdater";
|
||||
import { providerProfilesMatch, type ProviderProfile } from "./providerProfiles";
|
||||
import { orderProviderRows } from "./providerRowOrder";
|
||||
import type {
|
||||
AboutInfo,
|
||||
ActionResult,
|
||||
AppUpdateInfo,
|
||||
BuiltinPromptDetail,
|
||||
BuiltinPromptStatus,
|
||||
CodexDesktopRestartResult,
|
||||
CodexState,
|
||||
ImportResult,
|
||||
InstructionMode,
|
||||
@@ -355,6 +358,7 @@ function getProviderPageCopy(lang: Lang): ProviderCopy {
|
||||
enableLabel: isChinese ? "启用" : "Enable",
|
||||
testLabel: isChinese ? "测试连接" : "Test connection",
|
||||
editLabel: t.provider.edit,
|
||||
duplicateLabel: isChinese ? "复制供应商" : "Duplicate provider",
|
||||
removeLabel: t.provider.remove,
|
||||
deleteTitle: isChinese ? "删除供应商" : "Delete provider",
|
||||
deleteDescription: (providerName) => isChinese
|
||||
@@ -481,25 +485,6 @@ function extractOpenAiApiKey(authText?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProviderBaseUrl(value?: string | null) {
|
||||
const raw = (value || "").trim();
|
||||
if (!raw) return "";
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
const credentials = parsed.username
|
||||
? `${parsed.username}${parsed.password ? `:${parsed.password}` : ""}@`
|
||||
: "";
|
||||
const path = parsed.pathname.replace(/\/+$/, "");
|
||||
return `${parsed.protocol.toLowerCase()}//${credentials}${parsed.host.toLowerCase()}${path}${parsed.search}`;
|
||||
} catch {
|
||||
return raw.replace(/\/+$/, "");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProviderName(value?: string | null) {
|
||||
return (value || "").trim().replace(/\s+/gu, " ").toLowerCase();
|
||||
}
|
||||
|
||||
function parseTomlStringValue(value: string) {
|
||||
const raw = value.trim();
|
||||
if (raw.startsWith('"')) {
|
||||
@@ -558,14 +543,13 @@ function savedProviderApiKey(provider: SavedProvider) {
|
||||
|| extractTomlProviderApiKey(provider.tomlConfig, providerId || undefined);
|
||||
}
|
||||
|
||||
function providerIdentityKey(baseUrl?: string | null, apiKey?: string | null, providerName?: string | null) {
|
||||
const normalizedUrl = normalizeProviderBaseUrl(baseUrl);
|
||||
if (!normalizedUrl) return "";
|
||||
const normalizedKey = (apiKey || "").trim();
|
||||
return JSON.stringify([
|
||||
normalizedUrl,
|
||||
normalizedKey ? `key:${normalizedKey}` : `name:${normalizeProviderName(providerName)}`,
|
||||
]);
|
||||
function savedProviderMatchesProfile(provider: SavedProvider, profile: ProviderProfile) {
|
||||
return providerProfilesMatch({
|
||||
baseUrl: provider.baseUrl,
|
||||
providerName: provider.providerName,
|
||||
model: provider.model,
|
||||
apiKey: savedProviderApiKey(provider),
|
||||
}, profile);
|
||||
}
|
||||
|
||||
function buildProviderTomlPreview(provider: SavedProvider) {
|
||||
@@ -798,6 +782,8 @@ function App() {
|
||||
const [availableProviderModels, setAvailableProviderModels] = React.useState<ProviderModel[]>([]);
|
||||
const [providerModelsLoading, setProviderModelsLoading] = React.useState(false);
|
||||
const [actionBusy, setActionBusy] = React.useState<string>("");
|
||||
const [skillsMcpNoteBusy, setSkillsMcpNoteBusy] = React.useState("");
|
||||
const [restartCodexBusy, setRestartCodexBusy] = React.useState(false);
|
||||
const [promptSyncing, setPromptSyncing] = React.useState(false);
|
||||
const [promptCatalogReady, setPromptCatalogReady] = React.useState(false);
|
||||
const [promptForm, setPromptForm] = React.useState<SavedPrompt>(blankPromptForm);
|
||||
@@ -820,6 +806,8 @@ function App() {
|
||||
const loadingTokensRef = React.useRef(new Set<number>());
|
||||
const actionBusyGenerationRef = React.useRef(0);
|
||||
const actionBusyTokensRef = React.useRef(new Map<number, string>());
|
||||
const skillsMcpNoteBusyRef = React.useRef("");
|
||||
const restartCodexBusyRef = React.useRef(false);
|
||||
const promptModeHelpRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const promptRefreshRequestRef = React.useRef(0);
|
||||
const refreshRequestRef = React.useRef(0);
|
||||
@@ -1084,8 +1072,17 @@ function App() {
|
||||
if (state?.activeSavedProviderId && savedProviders.some((item) => item.id === state.activeSavedProviderId)) {
|
||||
return state.activeSavedProviderId;
|
||||
}
|
||||
return liveProviderId !== "custom" ? liveProviderId : "";
|
||||
}, [liveProviderId, savedProviders, state?.activeSavedProviderId, state?.isOfficialProvider]);
|
||||
const fallback = liveProviderId === "custom"
|
||||
? savedProviders.find((item) => item.id === activeProviderId)
|
||||
: savedProviders.find((item) => item.id === liveProviderId);
|
||||
if (!fallback || !currentProvider) return "";
|
||||
return savedProviderMatchesProfile(fallback, {
|
||||
baseUrl: currentProvider.baseUrl,
|
||||
apiKey: liveProviderApiKey,
|
||||
providerName: currentProvider.name,
|
||||
model: state?.model,
|
||||
}) ? fallback.id : "";
|
||||
}, [activeProviderId, currentProvider, liveProviderApiKey, liveProviderId, savedProviders, state?.activeSavedProviderId, state?.isOfficialProvider, state?.model]);
|
||||
const effectiveActiveProviderId = state?.isOfficialProvider ? "" : inferredActiveProviderId;
|
||||
const currentInstructionPath = (state?.instructionFile || "").replace(/\\/g, "/");
|
||||
const currentInstructionFilename = currentInstructionPath.split("/").pop() || "";
|
||||
@@ -1104,22 +1101,6 @@ function App() {
|
||||
|| currentInstructionFilename
|
||||
|| (lang === "zh" ? "当前提示词" : "Current prompt");
|
||||
}, [currentInstructionFilename, instructionTemplates, lang, savedPrompts, state?.instructionTemplateKey]);
|
||||
const canonicalSavedProviders = React.useMemo(() => {
|
||||
const groups = new Map<string, SavedProvider[]>();
|
||||
savedProviders.forEach((provider) => {
|
||||
const identity = providerIdentityKey(provider.baseUrl, savedProviderApiKey(provider), provider.providerName);
|
||||
const key = identity || `id:${provider.id}`;
|
||||
const group = groups.get(key);
|
||||
if (group) group.push(provider);
|
||||
else groups.set(key, [provider]);
|
||||
});
|
||||
return Array.from(groups.values()).map((group) =>
|
||||
group.find((item) => item.id === effectiveActiveProviderId)
|
||||
|| group.find((item) => item.id === activeProviderId)
|
||||
|| group[0],
|
||||
);
|
||||
}, [activeProviderId, effectiveActiveProviderId, savedProviders]);
|
||||
|
||||
const detectedRows = React.useMemo(() => {
|
||||
if (state?.isOfficialProvider) return [];
|
||||
return (state?.providers || []).filter((p) => p.isCurrent).map((p) => {
|
||||
@@ -1140,12 +1121,12 @@ function App() {
|
||||
}, [liveProviderApiKey, state?.configText, state?.isOfficialProvider, state?.model, state?.providers]);
|
||||
|
||||
const localRows = React.useMemo(() => {
|
||||
return canonicalSavedProviders.map((p) => ({
|
||||
return savedProviders.map((p) => ({
|
||||
...p,
|
||||
source: "local" as const,
|
||||
isCurrent: effectiveActiveProviderId === p.id,
|
||||
}));
|
||||
}, [canonicalSavedProviders, effectiveActiveProviderId]);
|
||||
}, [effectiveActiveProviderId, savedProviders]);
|
||||
|
||||
const providerRows = React.useMemo(() => {
|
||||
const officialRow = {
|
||||
@@ -1159,42 +1140,36 @@ function App() {
|
||||
requiresOpenaiAuth: false,
|
||||
isCurrent: Boolean(state?.isOfficialProvider),
|
||||
};
|
||||
const seen = new Set<string>();
|
||||
const rows: Array<typeof officialRow | (typeof detectedRows)[number] | (typeof localRows)[number]> = [officialRow];
|
||||
localRows.forEach((row) => {
|
||||
const key = providerIdentityKey(row.baseUrl, savedProviderApiKey(row), row.providerName);
|
||||
if (key) seen.add(key);
|
||||
rows.push(row);
|
||||
});
|
||||
detectedRows.forEach((row) => {
|
||||
if (row.id === "detected-custom" && inferredActiveProviderId) return;
|
||||
const key = providerIdentityKey(row.baseUrl, row.apiKey, row.providerName);
|
||||
if (key && seen.has(key)) return;
|
||||
if (key) seen.add(key);
|
||||
rows.push(row);
|
||||
});
|
||||
return rows;
|
||||
}, [detectedRows, inferredActiveProviderId, localRows, state?.isOfficialProvider, state?.model]);
|
||||
return orderProviderRows(officialRow, detectedRows, localRows);
|
||||
}, [detectedRows, localRows, state?.isOfficialProvider, state?.model]);
|
||||
|
||||
const findLocalProviderForRow = React.useCallback((row: ProviderRow) => {
|
||||
if (row.source === "official") return undefined;
|
||||
return canonicalSavedProviders.find((item) =>
|
||||
row.source === "local"
|
||||
? item.id === row.id
|
||||
: providerIdentityKey(item.baseUrl, savedProviderApiKey(item), item.providerName)
|
||||
=== providerIdentityKey(row.baseUrl, row.apiKey, row.providerName),
|
||||
);
|
||||
}, [canonicalSavedProviders]);
|
||||
if (row.source === "local") return savedProviders.find((item) => item.id === row.id);
|
||||
const matches = savedProviders.filter((item) => savedProviderMatchesProfile(item, row));
|
||||
return matches.find((item) => item.id === effectiveActiveProviderId)
|
||||
|| matches.find((item) => item.id === activeProviderId)
|
||||
|| (matches.length === 1 ? matches[0] : undefined);
|
||||
}, [activeProviderId, effectiveActiveProviderId, savedProviders]);
|
||||
|
||||
const providerCopySourceForRow = React.useCallback((row: ProviderRow): SavedProvider | undefined => {
|
||||
const local = findLocalProviderForRow(row);
|
||||
if (local) return local;
|
||||
if (row.source !== "detected") return undefined;
|
||||
return {
|
||||
id: customProviderId(row.providerName || row.baseUrl),
|
||||
providerName: row.providerName,
|
||||
baseUrl: row.baseUrl,
|
||||
model: row.model,
|
||||
apiKey: row.apiKey || "",
|
||||
tomlConfig: state?.configText || "",
|
||||
wireApi: row.wireApi,
|
||||
requiresOpenaiAuth: row.requiresOpenaiAuth,
|
||||
};
|
||||
}, [findLocalProviderForRow, state?.configText]);
|
||||
|
||||
const providerPageRows = React.useMemo<ProviderRow[]>(() => providerRows.map((row) => {
|
||||
const local = row.source === "official"
|
||||
? undefined
|
||||
: canonicalSavedProviders.find((item) =>
|
||||
row.source === "local"
|
||||
? item.id === row.id
|
||||
: providerIdentityKey(item.baseUrl, savedProviderApiKey(item), item.providerName)
|
||||
=== providerIdentityKey(row.baseUrl, row.apiKey, row.providerName),
|
||||
);
|
||||
const local = findLocalProviderForRow(row);
|
||||
return {
|
||||
id: row.id,
|
||||
source: row.source,
|
||||
@@ -1207,11 +1182,12 @@ function App() {
|
||||
isCurrent: row.isCurrent,
|
||||
sourceLabel: row.source === "official" ? (lang === "zh" ? "Codex 登录" : "Codex login") : undefined,
|
||||
editable: row.source === "official" || Boolean(local) || row.source === "detected",
|
||||
duplicable: Boolean(providerCopySourceForRow(row)),
|
||||
deletable: Boolean(local),
|
||||
testable: row.source !== "official",
|
||||
testingKey: `${row.source}-${row.id}`,
|
||||
};
|
||||
}), [canonicalSavedProviders, lang, providerRows]);
|
||||
}), [findLocalProviderForRow, lang, providerCopySourceForRow, providerRows]);
|
||||
|
||||
const visibleSessions = React.useMemo(
|
||||
() => (sessionStatus?.sessions || []).filter((item) => showInternalSessions || !item.isSubagent),
|
||||
@@ -1645,6 +1621,7 @@ function App() {
|
||||
configText: tomlConfig,
|
||||
apiKey: provider.apiKey || "",
|
||||
},
|
||||
providerId: provider.id,
|
||||
});
|
||||
}
|
||||
return invoke<ActionResult>("switch_provider", {
|
||||
@@ -2113,6 +2090,60 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const saveSkillsMcpNote = async (itemKind: SkillsMcpNoteKind, id: string, note: string) => {
|
||||
if (skillsMcpNoteBusyRef.current) return false;
|
||||
const noteBusyKey = `note:${itemKind}:${id}`;
|
||||
skillsMcpNoteBusyRef.current = noteBusyKey;
|
||||
setSkillsMcpNoteBusy(noteBusyKey);
|
||||
const request = beginSkillsMcpRequest();
|
||||
const actionToken = beginActionBusy(noteBusyKey);
|
||||
setError("");
|
||||
try {
|
||||
const result = await invoke<SkillsMcpState>("save_skills_mcp_note", {
|
||||
configDir: request.configDir,
|
||||
itemKind,
|
||||
id,
|
||||
note,
|
||||
});
|
||||
if (!isCurrentSkillsMcpRequest(request.requestId, request.configDirKey)) return false;
|
||||
skillsMcpLoadedRef.current = request.configDirKey;
|
||||
setSkillsMcpState(result);
|
||||
setToast(lang === "zh" ? "备注已保存" : "Note saved");
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (isCurrentSkillsMcpRequest(request.requestId, request.configDirKey)) setError(String(e));
|
||||
return false;
|
||||
} finally {
|
||||
endActionBusy(actionToken);
|
||||
if (skillsMcpNoteBusyRef.current === noteBusyKey) {
|
||||
skillsMcpNoteBusyRef.current = "";
|
||||
setSkillsMcpNoteBusy("");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const restartCodexDesktop = async () => {
|
||||
if (restartCodexBusyRef.current) return false;
|
||||
restartCodexBusyRef.current = true;
|
||||
setRestartCodexBusy(true);
|
||||
const actionToken = beginActionBusy("restartCodexDesktop");
|
||||
setError("");
|
||||
try {
|
||||
const result = await invoke<CodexDesktopRestartResult>("restart_codex_desktop");
|
||||
setToast(result.wasRunning
|
||||
? (lang === "zh" ? `${result.appName} 已重新启动` : `${result.appName} restarted`)
|
||||
: (lang === "zh" ? `${result.appName} 已启动` : `${result.appName} started`));
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
return false;
|
||||
} finally {
|
||||
endActionBusy(actionToken);
|
||||
restartCodexBusyRef.current = false;
|
||||
setRestartCodexBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const installSkillZipFile = async (file?: File | null) => {
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith(".zip")) {
|
||||
@@ -2224,6 +2255,20 @@ function App() {
|
||||
setProviderMode("form");
|
||||
};
|
||||
|
||||
const openDuplicateProvider = (provider: SavedProvider) => {
|
||||
resetAvailableProviderModels();
|
||||
const duplicate = {
|
||||
...provider,
|
||||
id: uniqueId(provider.id || provider.providerName, savedProviders.map((item) => item.id)),
|
||||
};
|
||||
setEditingProviderId(null);
|
||||
setEditingDetectedProvider(false);
|
||||
setProviderForm(duplicate);
|
||||
setProviderTomlDraft(duplicate.tomlConfig?.trim() || buildProviderTomlPreview(duplicate));
|
||||
setProviderTomlDirty(false);
|
||||
setProviderMode("form");
|
||||
};
|
||||
|
||||
const openEditDetectedProvider = (provider: { id: string; providerName: string; baseUrl: string; model: string; apiKey?: string; wireApi: string; requiresOpenaiAuth: boolean }) => {
|
||||
resetAvailableProviderModels();
|
||||
const id = uniqueId(
|
||||
@@ -2575,6 +2620,10 @@ function App() {
|
||||
if (local) openEditProvider(local);
|
||||
else if (row.source === "detected") openEditDetectedProvider(row);
|
||||
}}
|
||||
onDuplicateProvider={(row) => {
|
||||
const copySource = providerCopySourceForRow(row);
|
||||
if (copySource) openDuplicateProvider(copySource);
|
||||
}}
|
||||
onDeleteProvider={(row) => {
|
||||
const local = findLocalProviderForRow(row);
|
||||
return local ? removeProvider(local.id, row.isCurrent) : Promise.resolve(false);
|
||||
@@ -2697,6 +2746,8 @@ function App() {
|
||||
onCheckUpdates={checkSkillUpdatesAction}
|
||||
onToggleSkill={toggleSkillEnabled}
|
||||
onToggleMcp={toggleMcpEnabled}
|
||||
noteBusyKey={skillsMcpNoteBusy}
|
||||
onSaveNote={saveSkillsMcpNote}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2846,9 +2897,24 @@ function App() {
|
||||
? "重新检测 CODEX_HOME、config.toml、auth.json 和 SQLite 会话库。"
|
||||
: "Recheck CODEX_HOME, config.toml, auth.json and SQLite session stores.",
|
||||
recheckLabel: lang === "zh" ? "重新检测" : "Recheck",
|
||||
restartTitle: lang === "zh" ? "Codex 桌面客户端" : "Codex desktop app",
|
||||
restartDescription: lang === "zh"
|
||||
? "重新启动本机的 Codex(ChatGPT)桌面客户端,不会重启 Codex-X。"
|
||||
: "Restart the local Codex (ChatGPT) desktop app without restarting Codex-X.",
|
||||
restartLabel: lang === "zh" ? "重启 Codex" : "Restart Codex",
|
||||
restartTargetLabel: lang === "zh" ? "Codex(ChatGPT)桌面客户端" : "Codex (ChatGPT) desktop app",
|
||||
restartConfirmTitle: lang === "zh" ? "重启 Codex?" : "Restart Codex?",
|
||||
restartConfirmDescription: lang === "zh"
|
||||
? "正在运行的 Codex 窗口会关闭并重新打开,未保存的输入可能丢失。"
|
||||
: "The running Codex window will close and reopen. Unsaved input may be lost.",
|
||||
restartCancelLabel: lang === "zh" ? "取消" : "Cancel",
|
||||
restartConfirmLabel: lang === "zh" ? "确认重启" : "Restart",
|
||||
restartingLabel: lang === "zh" ? "正在重启" : "Restarting",
|
||||
}}
|
||||
onLanguageChange={setLang}
|
||||
recheckBusy={loading || refreshing}
|
||||
restartBusy={restartCodexBusy}
|
||||
onRestartCodex={restartCodexDesktop}
|
||||
onRecheck={() => {
|
||||
localStorage.removeItem(STARTUP_WIZARD_SEEN_KEY);
|
||||
setStartupWizardOpen(true);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
@@ -37,6 +38,7 @@ export type ProviderRow = {
|
||||
isCurrent: boolean;
|
||||
sourceLabel?: string;
|
||||
editable?: boolean;
|
||||
duplicable?: boolean;
|
||||
deletable?: boolean;
|
||||
testable?: boolean;
|
||||
testingKey?: string;
|
||||
@@ -69,6 +71,7 @@ export type ProviderCopy = {
|
||||
enableLabel: string;
|
||||
testLabel: string;
|
||||
editLabel: string;
|
||||
duplicateLabel: string;
|
||||
removeLabel: string;
|
||||
deleteTitle: string;
|
||||
deleteDescription: (providerName: string) => string;
|
||||
@@ -154,6 +157,7 @@ export type ProvidersPageProps = {
|
||||
onEnableProvider: (row: ProviderRow) => void;
|
||||
onTestProvider: (row: ProviderRow) => void;
|
||||
onEditProvider: (row: ProviderRow) => void;
|
||||
onDuplicateProvider: (row: ProviderRow) => void;
|
||||
onDeleteProvider: (row: ProviderRow) => Promise<boolean>;
|
||||
onCancelMode: () => void;
|
||||
onOfficialModelChange: (value: string) => void;
|
||||
@@ -242,8 +246,9 @@ function ListPage({
|
||||
onEnableProvider,
|
||||
onTestProvider,
|
||||
onEditProvider,
|
||||
onDuplicateProvider,
|
||||
onDeleteProvider,
|
||||
}: Pick<ProvidersPageProps, "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onRestoreOfficial" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDeleteProvider">) {
|
||||
}: Pick<ProvidersPageProps, "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onRestoreOfficial" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDuplicateProvider" | "onDeleteProvider">) {
|
||||
const [providerToDelete, setProviderToDelete] = useState<ProviderRow | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const providerActionsBusy = loading || Boolean(actionBusy);
|
||||
@@ -337,6 +342,9 @@ function ListPage({
|
||||
{row.editable !== false && (
|
||||
<ActionIconButton icon={PencilLine} label={copy.editLabel} onClick={() => onEditProvider(row)} disabled={providerActionsBusy} />
|
||||
)}
|
||||
{row.duplicable && (
|
||||
<ActionIconButton icon={Copy} label={copy.duplicateLabel} onClick={() => onDuplicateProvider(row)} disabled={providerActionsBusy} />
|
||||
)}
|
||||
{row.deletable && (
|
||||
<ActionIconButton icon={Trash2} label={copy.removeLabel} onClick={() => setProviderToDelete(row)} disabled={providerActionsBusy} danger />
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useId } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import type { ChangeEvent, RefObject } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FolderInput,
|
||||
Loader2,
|
||||
PackageOpen,
|
||||
PencilLine,
|
||||
PlugZap,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
@@ -14,11 +15,12 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { PageTransition } from "../components/PageTransition";
|
||||
import { Button, ModalShell, StatusBadge, Toggle, cx } from "../components/ui";
|
||||
import { Button, IconButton, ModalShell, StatusBadge, Toggle, cx } from "../components/ui";
|
||||
import type { Lang, ManagedMcpServer, ManagedSkill, SkillsMcpImportPreview, SkillsMcpState } from "../types";
|
||||
import "../styles/skills-prompts-pages.css";
|
||||
|
||||
export type SkillsMcpTab = "mcp" | "skills";
|
||||
export type SkillsMcpNoteKind = "mcp" | "skill";
|
||||
|
||||
type MaybeAsyncAction = () => void | Promise<void>;
|
||||
|
||||
@@ -27,6 +29,7 @@ export type SkillsMcpPageProps = {
|
||||
state: SkillsMcpState | null;
|
||||
activeTab: SkillsMcpTab;
|
||||
actionBusy: string;
|
||||
noteBusyKey: string;
|
||||
importOpen: boolean;
|
||||
importPreview: SkillsMcpImportPreview | null;
|
||||
zipInputRef: RefObject<HTMLInputElement>;
|
||||
@@ -40,6 +43,14 @@ export type SkillsMcpPageProps = {
|
||||
onCheckUpdates: MaybeAsyncAction;
|
||||
onToggleSkill: (id: string, enabled: boolean) => void | Promise<void>;
|
||||
onToggleMcp: (id: string, enabled: boolean) => void | Promise<void>;
|
||||
onSaveNote: (itemKind: SkillsMcpNoteKind, id: string, note: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
type NoteTarget = {
|
||||
itemKind: SkillsMcpNoteKind;
|
||||
id: string;
|
||||
name: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
type SkillsMcpCopy = ReturnType<typeof getCopy>;
|
||||
@@ -65,6 +76,16 @@ function getCopy(lang: Lang) {
|
||||
enableSkill: "启用 Skill",
|
||||
disableSkill: "禁用 Skill",
|
||||
updateStatus: "更新状态",
|
||||
addNote: "添加备注",
|
||||
editNote: "编辑备注",
|
||||
noteTitle: "自定义备注",
|
||||
noteDescription: (name: string) => `为“${name}”添加便于识别的说明。`,
|
||||
noteLabel: "备注内容",
|
||||
notePlaceholder: "例如:用于查询项目文档,依赖本地 Node.js",
|
||||
noteEmptyHint: "留空保存将清除现有备注",
|
||||
noteCount: (count: number) => `${count} / 1000`,
|
||||
saveNote: "保存备注",
|
||||
savingNote: "保存中",
|
||||
importTitle: "确认导入已有内容",
|
||||
importDescription: "以下内容来自本机现有配置。导入后可在此统一启用或禁用。",
|
||||
noImportItems: "没有发现可导入的已有 Skills / MCP。",
|
||||
@@ -94,6 +115,16 @@ function getCopy(lang: Lang) {
|
||||
enableSkill: "Enable Skill",
|
||||
disableSkill: "Disable Skill",
|
||||
updateStatus: "Update status",
|
||||
addNote: "Add note",
|
||||
editNote: "Edit note",
|
||||
noteTitle: "Custom note",
|
||||
noteDescription: (name: string) => `Add a description that helps you recognize “${name}”.`,
|
||||
noteLabel: "Note",
|
||||
notePlaceholder: "For example: searches project docs; requires local Node.js",
|
||||
noteEmptyHint: "Save an empty note to remove it",
|
||||
noteCount: (count: number) => `${count} / 1000`,
|
||||
saveNote: "Save note",
|
||||
savingNote: "Saving",
|
||||
importTitle: "Confirm import",
|
||||
importDescription: "These items were found in the existing local configuration. Import them to manage their state here.",
|
||||
noImportItems: "No existing Skills / MCP items were found.",
|
||||
@@ -116,22 +147,33 @@ function McpRow({
|
||||
disabled,
|
||||
copy,
|
||||
onToggle,
|
||||
onEditNote,
|
||||
}: {
|
||||
server: ManagedMcpServer;
|
||||
busy: boolean;
|
||||
disabled: boolean;
|
||||
copy: SkillsMcpCopy;
|
||||
onToggle: SkillsMcpPageProps["onToggleMcp"];
|
||||
onEditNote: (target: NoteTarget) => void;
|
||||
}) {
|
||||
const name = server.name || server.id;
|
||||
const details = [server.summary, server.transport, server.source].filter(Boolean).join("\n");
|
||||
const details = [server.note, server.summary, server.transport, server.source].filter(Boolean).join("\n");
|
||||
return (
|
||||
<article className="cx-skills-row">
|
||||
<div className="cx-skills-row-copy">
|
||||
<div className={cx("cx-skills-row-copy", Boolean(server.note) && "cx-skills-row-copy--noted")}>
|
||||
<strong title={details ? `${name}\n${details}` : name}>{name}</strong>
|
||||
{server.note && <span className="cx-skills-row-note" title={server.note}>{server.note}</span>}
|
||||
</div>
|
||||
<div className="cx-skills-row-control">
|
||||
{busy && <Loader2 size={15} className="cx-skills-spin" aria-hidden="true" />}
|
||||
<IconButton
|
||||
icon={<PencilLine size={15} strokeWidth={1.9} />}
|
||||
label={server.note ? copy.editNote : copy.addNote}
|
||||
size="sm"
|
||||
className="cx-skills-note-button"
|
||||
onClick={() => onEditNote({ itemKind: "mcp", id: server.id, name, note: server.note || "" })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Toggle
|
||||
checked={server.enabled}
|
||||
onCheckedChange={(enabled) => void onToggle(server.id, enabled)}
|
||||
@@ -149,12 +191,14 @@ function SkillRow({
|
||||
disabled,
|
||||
copy,
|
||||
onToggle,
|
||||
onEditNote,
|
||||
}: {
|
||||
skill: ManagedSkill;
|
||||
busy: boolean;
|
||||
disabled: boolean;
|
||||
copy: SkillsMcpCopy;
|
||||
onToggle: SkillsMcpPageProps["onToggleSkill"];
|
||||
onEditNote: (target: NoteTarget) => void;
|
||||
}) {
|
||||
const name = skill.name || skill.directory;
|
||||
const updateTone = skill.updateStatus.includes("失败") || skill.updateStatus.toLowerCase().includes("fail")
|
||||
@@ -164,20 +208,29 @@ function SkillRow({
|
||||
: "neutral";
|
||||
|
||||
const showUpdateStatus = Boolean(skill.updateStatus && skill.updateStatus !== "未检查");
|
||||
const details = [skill.description, skill.source].filter(Boolean).join("\n");
|
||||
const details = [skill.note, skill.description, skill.source].filter(Boolean).join("\n");
|
||||
|
||||
return (
|
||||
<article className="cx-skills-row">
|
||||
<div className="cx-skills-row-copy">
|
||||
<div className={cx("cx-skills-row-copy", Boolean(skill.note) && "cx-skills-row-copy--noted")}>
|
||||
<strong title={details ? `${name}\n${details}` : name}>{name}</strong>
|
||||
{skill.note && <span className="cx-skills-row-note" title={skill.note}>{skill.note}</span>}
|
||||
{showUpdateStatus && (
|
||||
<StatusBadge tone={updateTone} dot={false} title={copy.updateStatus}>
|
||||
<StatusBadge tone={updateTone} dot={false} title={`${copy.updateStatus}: ${skill.updateStatus}`}>
|
||||
{skill.updateStatus}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className="cx-skills-row-control">
|
||||
{busy && <Loader2 size={15} className="cx-skills-spin" aria-hidden="true" />}
|
||||
<IconButton
|
||||
icon={<PencilLine size={15} strokeWidth={1.9} />}
|
||||
label={skill.note ? copy.editNote : copy.addNote}
|
||||
size="sm"
|
||||
className="cx-skills-note-button"
|
||||
onClick={() => onEditNote({ itemKind: "skill", id: skill.id, name, note: skill.note || "" })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Toggle
|
||||
checked={skill.enabled}
|
||||
onCheckedChange={(enabled) => void onToggle(skill.id, enabled)}
|
||||
@@ -255,6 +308,7 @@ export function SkillsMcpPage({
|
||||
state,
|
||||
activeTab,
|
||||
actionBusy,
|
||||
noteBusyKey,
|
||||
importOpen,
|
||||
importPreview,
|
||||
zipInputRef,
|
||||
@@ -268,12 +322,34 @@ export function SkillsMcpPage({
|
||||
onCheckUpdates,
|
||||
onToggleSkill,
|
||||
onToggleMcp,
|
||||
onSaveNote,
|
||||
}: SkillsMcpPageProps) {
|
||||
const copy = getCopy(lang);
|
||||
const tabId = useId();
|
||||
const anyBusy = Boolean(actionBusy);
|
||||
const importBusy = actionBusy === "importExistingSkillsMcp";
|
||||
const activeItems = activeTab === "mcp" ? state?.mcpServers ?? [] : state?.skills ?? [];
|
||||
const [noteTarget, setNoteTarget] = useState<NoteTarget | null>(null);
|
||||
const [noteDraft, setNoteDraft] = useState("");
|
||||
const noteBusy = noteTarget
|
||||
? noteBusyKey === `note:${noteTarget.itemKind}:${noteTarget.id}`
|
||||
: false;
|
||||
const openNoteEditor = (target: NoteTarget) => {
|
||||
setNoteTarget(target);
|
||||
setNoteDraft(target.note);
|
||||
};
|
||||
const closeNoteEditor = () => {
|
||||
if (!noteBusy) setNoteTarget(null);
|
||||
};
|
||||
const updateNoteDraft = (value: string) => {
|
||||
setNoteDraft(Array.from(value).slice(0, 1000).join(""));
|
||||
};
|
||||
const saveNote = async () => {
|
||||
if (!noteTarget || noteBusy) return;
|
||||
if (await onSaveNote(noteTarget.itemKind, noteTarget.id, noteDraft)) {
|
||||
setNoteTarget(null);
|
||||
}
|
||||
};
|
||||
const handleZipChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
void onInstallZip(event.currentTarget.files?.[0]);
|
||||
};
|
||||
@@ -395,6 +471,7 @@ export function SkillsMcpPage({
|
||||
disabled={anyBusy}
|
||||
copy={copy}
|
||||
onToggle={onToggleMcp}
|
||||
onEditNote={openNoteEditor}
|
||||
/>
|
||||
))
|
||||
) : state.skills.length === 0 ? (
|
||||
@@ -407,6 +484,7 @@ export function SkillsMcpPage({
|
||||
disabled={anyBusy}
|
||||
copy={copy}
|
||||
onToggle={onToggleSkill}
|
||||
onEditNote={openNoteEditor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -453,6 +531,48 @@ export function SkillsMcpPage({
|
||||
>
|
||||
<ImportPreviewContent preview={importPreview} copy={copy} />
|
||||
</ModalShell>
|
||||
|
||||
<ModalShell
|
||||
open={Boolean(noteTarget)}
|
||||
onClose={closeNoteEditor}
|
||||
title={copy.noteTitle}
|
||||
description={noteTarget ? copy.noteDescription(noteTarget.name) : undefined}
|
||||
size="sm"
|
||||
closeOnBackdrop={!noteBusy}
|
||||
closeOnEscape={!noteBusy}
|
||||
showCloseButton={!noteBusy}
|
||||
className="cx-skills-note-modal"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={closeNoteEditor} disabled={noteBusy}>
|
||||
{copy.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
icon={noteBusy ? <Loader2 className="cx-skills-spin" /> : <PencilLine />}
|
||||
onClick={() => void saveNote()}
|
||||
disabled={noteBusy}
|
||||
>
|
||||
{noteBusy ? copy.savingNote : copy.saveNote}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<label className="cx-skills-note-field">
|
||||
<span>{copy.noteLabel}</span>
|
||||
<textarea
|
||||
value={noteDraft}
|
||||
onChange={(event) => updateNoteDraft(event.target.value)}
|
||||
placeholder={copy.notePlaceholder}
|
||||
rows={5}
|
||||
disabled={noteBusy}
|
||||
data-initial-focus
|
||||
/>
|
||||
</label>
|
||||
<div className="cx-skills-note-meta">
|
||||
<span>{copy.noteEmptyHint}</span>
|
||||
<span>{copy.noteCount(Array.from(noteDraft).length)}</span>
|
||||
</div>
|
||||
</ModalShell>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
@@ -5,10 +6,12 @@ import {
|
||||
ExternalLink,
|
||||
Globe2,
|
||||
Loader2,
|
||||
Power,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Button, ModalShell } from "../components/ui";
|
||||
import "../styles/utility-pages.css";
|
||||
|
||||
export type UtilityLanguage = "zh" | "en";
|
||||
@@ -84,6 +87,15 @@ export type SettingsCopy = {
|
||||
recheckTitle: ReactNode;
|
||||
recheckDescription: ReactNode;
|
||||
recheckLabel: ReactNode;
|
||||
restartTitle: ReactNode;
|
||||
restartDescription: ReactNode;
|
||||
restartLabel: ReactNode;
|
||||
restartTargetLabel: ReactNode;
|
||||
restartConfirmTitle: ReactNode;
|
||||
restartConfirmDescription: ReactNode;
|
||||
restartCancelLabel: string;
|
||||
restartConfirmLabel: ReactNode;
|
||||
restartingLabel: ReactNode;
|
||||
};
|
||||
|
||||
export type SettingsPageProps = {
|
||||
@@ -91,7 +103,9 @@ export type SettingsPageProps = {
|
||||
copy: SettingsCopy;
|
||||
onLanguageChange: (lang: UtilityLanguage) => void;
|
||||
onRecheck: () => void;
|
||||
onRestartCodex: () => Promise<boolean>;
|
||||
recheckBusy?: boolean;
|
||||
restartBusy?: boolean;
|
||||
};
|
||||
|
||||
type SettingRowProps = {
|
||||
@@ -121,8 +135,19 @@ export function SettingsPage({
|
||||
copy,
|
||||
onLanguageChange,
|
||||
onRecheck,
|
||||
onRestartCodex,
|
||||
recheckBusy = false,
|
||||
restartBusy = false,
|
||||
}: SettingsPageProps) {
|
||||
const [restartConfirmOpen, setRestartConfirmOpen] = useState(false);
|
||||
const closeRestartConfirm = () => {
|
||||
if (!restartBusy) setRestartConfirmOpen(false);
|
||||
};
|
||||
const confirmRestart = async () => {
|
||||
if (restartBusy) return;
|
||||
if (await onRestartCodex()) setRestartConfirmOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="cx-utility cx-page cx-page--settings">
|
||||
<PageHeader eyebrow={copy.eyebrow} title={copy.title} />
|
||||
@@ -176,7 +201,56 @@ export function SettingsPage({
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
icon={Power}
|
||||
title={copy.restartTitle}
|
||||
description={copy.restartDescription}
|
||||
action={(
|
||||
<button
|
||||
type="button"
|
||||
className="cx-page-button cx-page-button--secondary"
|
||||
onClick={() => setRestartConfirmOpen(true)}
|
||||
disabled={restartBusy}
|
||||
>
|
||||
{restartBusy ? <Loader2 size={15} className="cx-page-spin" aria-hidden="true" /> : <Power size={15} aria-hidden="true" />}
|
||||
{restartBusy ? copy.restartingLabel : copy.restartLabel}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModalShell
|
||||
open={restartConfirmOpen}
|
||||
onClose={closeRestartConfirm}
|
||||
title={copy.restartConfirmTitle}
|
||||
description={copy.restartConfirmDescription}
|
||||
size="sm"
|
||||
closeLabel={copy.restartCancelLabel}
|
||||
closeOnBackdrop={!restartBusy}
|
||||
closeOnEscape={!restartBusy}
|
||||
showCloseButton={!restartBusy}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={closeRestartConfirm} disabled={restartBusy} data-initial-focus>
|
||||
{copy.restartCancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={restartBusy ? <Loader2 size={16} className="cx-page-spin" /> : <Power size={16} />}
|
||||
onClick={() => void confirmRestart()}
|
||||
disabled={restartBusy}
|
||||
>
|
||||
{restartBusy ? copy.restartingLabel : copy.restartConfirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="cx-page-restart-target">
|
||||
<Power size={18} aria-hidden="true" />
|
||||
<strong>{copy.restartTargetLabel}</strong>
|
||||
</div>
|
||||
</ModalShell>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export type ProviderProfile = {
|
||||
baseUrl?: string | null;
|
||||
providerName?: string | null;
|
||||
model?: string | null;
|
||||
apiKey?: string | null;
|
||||
};
|
||||
|
||||
function normalizeProviderBaseUrl(value?: string | null) {
|
||||
const raw = (value || "").trim();
|
||||
if (!raw) return "";
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
const credentials = parsed.username
|
||||
? `${parsed.username}${parsed.password ? `:${parsed.password}` : ""}@`
|
||||
: "";
|
||||
const path = parsed.pathname.replace(/\/+$/, "");
|
||||
return `${parsed.protocol.toLowerCase()}//${credentials}${parsed.host.toLowerCase()}${path}${parsed.search}`;
|
||||
} catch {
|
||||
return raw.replace(/\/+$/, "");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProviderName(value?: string | null) {
|
||||
return (value || "").trim().replace(/\s+/gu, " ").toLowerCase();
|
||||
}
|
||||
|
||||
export function providerProfilesMatch(left: ProviderProfile, right: ProviderProfile) {
|
||||
const leftUrl = normalizeProviderBaseUrl(left.baseUrl);
|
||||
if (!leftUrl || leftUrl !== normalizeProviderBaseUrl(right.baseUrl)) return false;
|
||||
if ((left.model || "").trim() !== (right.model || "").trim()) return false;
|
||||
|
||||
const leftKey = (left.apiKey || "").trim();
|
||||
const rightKey = (right.apiKey || "").trim();
|
||||
if (leftKey && rightKey) return leftKey === rightKey;
|
||||
return normalizeProviderName(left.providerName) === normalizeProviderName(right.providerName);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
type OrderedProviderRow = {
|
||||
isCurrent: boolean;
|
||||
};
|
||||
|
||||
export function orderProviderRows<
|
||||
Official extends OrderedProviderRow,
|
||||
Detected extends OrderedProviderRow,
|
||||
Local extends OrderedProviderRow,
|
||||
>(
|
||||
official: Official,
|
||||
detected: readonly Detected[],
|
||||
local: readonly Local[],
|
||||
): Array<Official | Detected | Local> {
|
||||
const rows: Array<Official | Detected | Local> = [official];
|
||||
if (!local.some((row) => row.isCurrent)) rows.push(...detected);
|
||||
rows.push(...local);
|
||||
return rows;
|
||||
}
|
||||
@@ -438,6 +438,10 @@
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-providers-row-meta {
|
||||
color: #aeb3bc;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-providers-info-grid code {
|
||||
color: var(--cx-dark-muted);
|
||||
background: transparent;
|
||||
@@ -770,6 +774,20 @@
|
||||
box-shadow: 0 7px 18px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skills-note-field > span {
|
||||
color: #d8dbe0;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skills-note-field textarea {
|
||||
color: #e5e7eb;
|
||||
background: var(--cx-dark-surface-recessed);
|
||||
border-color: var(--cx-dark-border);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skills-note-meta {
|
||||
color: var(--cx-dark-subtle);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-prompts-row:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(139, 92, 246, 0.4);
|
||||
|
||||
@@ -328,10 +328,13 @@
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.cx-skills-row-copy > strong {
|
||||
min-width: 0;
|
||||
flex: 0 1 auto;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: #2a2e34;
|
||||
@@ -343,14 +346,38 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cx-skills-row-copy--noted > strong {
|
||||
max-width: min(40%, 360px);
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.cx-skills-row-note {
|
||||
min-width: 0;
|
||||
flex: 1 1 180px;
|
||||
overflow: hidden;
|
||||
color: var(--cx-tool-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cx-skills-row-copy .ui-status-badge {
|
||||
flex: 0 0 auto;
|
||||
max-width: min(30%, 220px);
|
||||
overflow: hidden;
|
||||
min-height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 5px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.cx-skills-row-copy .ui-status-badge > span:last-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cx-skills-row-control,
|
||||
.cx-prompts-row-actions {
|
||||
min-width: 58px;
|
||||
@@ -360,6 +387,67 @@
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.cx-skills-note-button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.cx-skills-note-modal .ui-modal__body {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cx-skills-note-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cx-skills-note-field > span {
|
||||
color: #3e434b;
|
||||
font-size: 12px;
|
||||
font-weight: 680;
|
||||
}
|
||||
|
||||
.cx-skills-note-field textarea {
|
||||
width: 100%;
|
||||
min-height: 118px;
|
||||
resize: vertical;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid #d8dce2;
|
||||
border-radius: 7px;
|
||||
color: #292d33;
|
||||
background: #ffffff;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cx-skills-note-field textarea:focus {
|
||||
border-color: #8db2f5;
|
||||
outline: 2px solid rgba(37, 99, 235, 0.16);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.cx-skills-note-field textarea:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.cx-skills-note-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
color: #858b94;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cx-skills-note-meta span:last-child {
|
||||
flex: 0 0 auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.cx-skills-empty {
|
||||
min-height: 150px;
|
||||
display: flex;
|
||||
@@ -1444,12 +1532,37 @@
|
||||
}
|
||||
|
||||
.cx-skills-row {
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.cx-skills-row-control {
|
||||
grid-column: 2;
|
||||
justify-content: flex-start;
|
||||
grid-row: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cx-skills-row-copy {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.cx-skills-row-copy--noted {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cx-skills-row-copy--noted > strong {
|
||||
max-width: 100%;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.cx-skills-row-note {
|
||||
flex-basis: 100%;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.cx-prompts-list {
|
||||
|
||||
@@ -232,6 +232,18 @@
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.cx-page-restart-target {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
color: var(--cx-page-text);
|
||||
}
|
||||
|
||||
.cx-page-restart-target > svg {
|
||||
flex: 0 0 auto;
|
||||
color: var(--cx-page-blue);
|
||||
}
|
||||
|
||||
.cx-page-setting-copy p {
|
||||
margin: 5px 0 0;
|
||||
color: var(--cx-page-muted);
|
||||
|
||||
@@ -137,6 +137,11 @@ export type AboutInfo = {
|
||||
nativeUpdaterSupported: boolean;
|
||||
};
|
||||
|
||||
export type CodexDesktopRestartResult = {
|
||||
appName: string;
|
||||
wasRunning: boolean;
|
||||
};
|
||||
|
||||
export type ReleaseInfo = {
|
||||
status: "idle" | "checking" | "ok" | "error";
|
||||
latestVersion?: string;
|
||||
@@ -191,6 +196,7 @@ export type ManagedSkill = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
note?: string | null;
|
||||
directory: string;
|
||||
enabled: boolean;
|
||||
source: string;
|
||||
@@ -206,6 +212,7 @@ export type ManagedMcpServer = {
|
||||
enabled: boolean;
|
||||
source: string;
|
||||
summary: string;
|
||||
note?: string | null;
|
||||
command?: string | null;
|
||||
url?: string | null;
|
||||
configJson: unknown;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { providerProfilesMatch } from "../src/providerProfiles.ts";
|
||||
|
||||
const saved = {
|
||||
baseUrl: "https://api.example.com/v1/",
|
||||
providerName: "Example API",
|
||||
model: "gpt-5.6",
|
||||
apiKey: "sk-saved",
|
||||
};
|
||||
|
||||
test("remembered provider still matches when the live key is unavailable", () => {
|
||||
assert.equal(providerProfilesMatch(saved, {
|
||||
baseUrl: "https://API.EXAMPLE.com/v1",
|
||||
providerName: " example api ",
|
||||
model: "gpt-5.6",
|
||||
apiKey: "",
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("different models or two different explicit keys do not match", () => {
|
||||
assert.equal(providerProfilesMatch(saved, { ...saved, model: "deepseek-v3" }), false);
|
||||
assert.equal(providerProfilesMatch(saved, { ...saved, apiKey: "sk-other" }), false);
|
||||
});
|
||||
|
||||
test("matching explicit credentials identify the same profile even after a rename", () => {
|
||||
assert.equal(providerProfilesMatch(saved, {
|
||||
...saved,
|
||||
providerName: "Renamed copy",
|
||||
}), true);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { orderProviderRows } from "../src/providerRowOrder.ts";
|
||||
|
||||
const official = { id: "official", isCurrent: false };
|
||||
const detected = { id: "detected", isCurrent: true };
|
||||
|
||||
test("a copied provider is last while the selected saved ID stays current", () => {
|
||||
const rows = orderProviderRows(official, [detected], [
|
||||
{ id: "older", isCurrent: false },
|
||||
{ id: "selected", isCurrent: true },
|
||||
{ id: "copy", isCurrent: false },
|
||||
]);
|
||||
|
||||
assert.deepEqual(rows.map((row) => row.id), ["official", "older", "selected", "copy"]);
|
||||
assert.deepEqual(rows.filter((row) => row.isCurrent).map((row) => row.id), ["selected"]);
|
||||
});
|
||||
|
||||
test("an unresolved detected current remains visible before saved rows", () => {
|
||||
const rows = orderProviderRows(official, [detected], [
|
||||
{ id: "original", isCurrent: false },
|
||||
{ id: "copy", isCurrent: false },
|
||||
]);
|
||||
|
||||
assert.deepEqual(rows.map((row) => row.id), ["official", "detected", "original", "copy"]);
|
||||
assert.deepEqual(rows.filter((row) => row.isCurrent).map((row) => row.id), ["detected"]);
|
||||
});
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codex-x-workspace",
|
||||
"private": true,
|
||||
"version": "0.3.13",
|
||||
"version": "0.3.15",
|
||||
"scripts": {
|
||||
"dev": "pnpm --dir apps/desktop tauri dev",
|
||||
"build": "pnpm --dir apps/desktop tauri build",
|
||||
|
||||
Reference in New Issue
Block a user