diff --git a/.claude/rules/config-system.md b/.claude/rules/config-system.md index cb77ada..0c98aa9 100644 --- a/.claude/rules/config-system.md +++ b/.claude/rules/config-system.md @@ -48,7 +48,7 @@ paths: ## 内置 Provider -- 内置 Provider 维护在 `src-tauri/resources/builtin-providers.json`,是唯一供应商来源(不支持自定义),当前覆盖 Anthropic、DeepSeek、智谱 GLM、Kimi、MiniMax、小米 MiMo、OpenRouter、火山方舟、万界方舟和 Ollama。 +- 内置 Provider 维护在 `src-tauri/resources/builtin-providers.json`,是唯一供应商来源(不支持自定义),当前覆盖 Anthropic、DeepSeek、智谱 GLM、Kimi、MiniMax、小米 MiMo、OpenRouter、火山方舟、万界方舟、OpenCode Go 和 Ollama。 - 新增 provider 时同步 `localizedName`、`slug`、`baseUrl`、`docUrl` 和模型 `category`。 - 配置编辑器的环境变量自动填充逻辑要覆盖默认 model 字段:`ANTHROPIC_MODEL`、`ANTHROPIC_DEFAULT_OPUS_MODEL`、`ANTHROPIC_DEFAULT_SONNET_MODEL`、`ANTHROPIC_DEFAULT_HAIKU_MODEL`、`CLAUDE_CODE_SUBAGENT_MODEL`。 @@ -75,7 +75,10 @@ paths: - 权限编辑器只管理 `defaultMode`、`disableBypassPermissionsMode`、`allow`、`deny`、`ask`、`additionalDirectories`;写回时保留其它顶层字段,例如 `disableAutoMode`。 - 修复权限 dirty 问题时优先做局部语义比较,不要扩大到全局 dirty 系统。 -- 状态行默认脚本按平台分发:非 Windows 用 `src-tauri/resources/statusline/default.sh`(Bash,依赖 jq),Windows 用 `src-tauri/resources/statusline/default.ps1`(PowerShell,免 jq)。安装走后端 `install_status_line_preset`:Windows 写入 `~/.claude/statusline.ps1` 并把 `command` 设为绝对正斜杠路径的 `powershell -NoProfile -ExecutionPolicy Bypass -File ...`;两份脚本功能需保持对齐。 +- 状态行默认脚本按平台分发:非 Windows 用 `src-tauri/resources/statusline/default.sh`(Bash,依赖 jq),Windows 用 `src-tauri/resources/statusline/default.ps1`(PowerShell,免 jq)。安装走后端 `install_status_line_preset`:Windows 写入 `~/.claude/statusline.ps1` 并把 `command` 设为绝对正斜杠**且加引号**的 `powershell -NoProfile -ExecutionPolicy Bypass -File "..."`(用户名含空格时不加引号会截断参数);两份脚本功能需保持对齐。 +- 两份脚本源文件都**不带 BOM**;Windows 落盘时由 `config.rs::expected_status_line_script()` 前置 UTF-8 BOM。Windows PowerShell 5.1 读取无 BOM 的 `.ps1` 时按系统代码页(简中 CP936)解码,UTF-8 中文注释错位后残留的悬空 lead byte 会吞掉行尾换行,使下一行代码并入注释并触发 `ParserError`,状态行整行无输出。给源文件加 BOM 会变成双 BOM,Bash 脚本加 BOM 会让 shebang 失效——两者都不要做。 +- `expected_status_line_script()` 同时是落盘内容和幂等比较基准,写入与比较必须共用它。若只改一处,已带 BOM 的脚本会被误判为“用户自定义”,安装预设时要求覆盖确认并把 BOM 覆盖掉,故障复发。 +- `default.ps1` 必须显式以 UTF-8 读取 stdin(`[Console]::OpenStandardInput()` + 无 BOM `UTF8Encoding` 的 `StreamReader`):PS 5.1 的 `[Console]::In` 按系统代码页解码,含中文目录名或 session_name 的 JSON 会乱码;直接设 `[Console]::InputEncoding` 在 stdin 已重定向时可能抛异常。赋给 `[Console]::OutputEncoding` 的实例也必须无 BOM,否则输出头可能混入 `EF BB BF`(MD5 处的 `[System.Text.Encoding]::UTF8.GetBytes()` 不输出 preamble,属正常用法)。 ## 新增配置字段同步点 diff --git a/.claude/rules/tauri-backend.md b/.claude/rules/tauri-backend.md index afa40f3..0928316 100644 --- a/.claude/rules/tauri-backend.md +++ b/.claude/rules/tauri-backend.md @@ -25,6 +25,7 @@ paths: | `project.rs` | 项目 Git 状态、worktree、分支/worktree 清理 preview/apply、本地数据清理 | | `claude_directory.rs` | `~/.claude` 文件树、文件预览、创建、重命名、删除与外部打开 | | `claude_directory_watcher.rs` | `~/.claude` 变更监听并广播 `claude-directory-changed` | +| `claude_cli.rs` | Claude CLI 解析与执行:优先当前 `PATH`,再查官方 native 安装目录与 macOS Homebrew 标准目录 | | `native_open.rs` | 默认终端 / 编辑器跨平台启动、本机检测受支持工具清单 | | `terminal_focus.rs` | macOS 上 `pid -> tty -> AppleScript` 聚焦 Terminal.app / iTerm / Ghostty;herdr 会话两跳聚焦编排 | | `herdr.rs` | herdr 会话聚焦:socket API 客户端(NDJSON)、pane 定位(pid 精确 + cwd 兜底)、附着 client 进程发现 | diff --git a/docs/adr/0005-provider-auth-frontend-hardcode.md b/docs/adr/0005-provider-auth-frontend-hardcode.md new file mode 100644 index 0000000..382a4c6 --- /dev/null +++ b/docs/adr/0005-provider-auth-frontend-hardcode.md @@ -0,0 +1,24 @@ +# opencode-go 认证字段切换:前端按供应商 slug 硬编码,不建模进 provider 数据层 + +## Context + +opencode-go 是 Anthropic 兼容网关(端点 `https://opencode.ai/zen/go`),用 `x-api-key` 认证,对应 Claude Code 的 `ANTHROPIC_API_KEY`;而默认认证区展示的是 `ANTHROPIC_AUTH_TOKEN`(Bearer)。 + +两条前置约束: +- Provider 数据层(`builtin-providers.json`)只承载供应商**客观信息**——连接地址 `env.ANTHROPIC_BASE_URL`、模型映射与元数据,**不含认证密钥、不含认证方式**;认证密钥属于 Profile 的 `settings.env`。 +- Claude Code 的认证语义是 `ANTHROPIC_AUTH_TOKEN`(Bearer)**优先**、`ANTHROPIC_API_KEY`(x-api-key)回退;后端 `resolve_model_test_request` 已按此实现,前端认证字段默认也只对应 `ANTHROPIC_AUTH_TOKEN`。 + +因此 opencode-go 这类"需要 x-api-key 认证"的供应商,其认证字段与默认 UI 冲突:若仍显示 `ANTHROPIC_AUTH_TOKEN`,用户填进去的 key 会被按 Bearer 发送,认证失败。 + +## Decision + +1. **前端按 slug 硬编码切换**:`ProfileEditor.tsx` 用 `providerSlugFromId(providerId) === "opencode-go"` 判定,命中时认证区字段由 `ANTHROPIC_AUTH_TOKEN` 切换为 `ANTHROPIC_API_KEY`(label / placeholder / value / onChange 全部联动),并把 `ANTHROPIC_API_KEY` 追加进 `hiddenEnvKeys` 从通用环境变量分区隐藏。 +2. **切换时清理互斥残留**:`applyProviderAutofill` 切到可解析的 opencode-go 时,在清空地址之外再置空 `ANTHROPIC_AUTH_TOKEN`——否则残留的 Bearer token 会被"Bearer 优先"语义遮蔽用户新填的 API Key,且两者都被隐藏、用户无从察觉。 +3. **坚持"Bearer 优先、API_KEY 回退"不变式**:后端 `resolve_model_test_request` 保持通用回退,不感知具体供应商;前端只做展示层切换,不复制该认证选择逻辑。 +4. **不把认证方式建模进 provider 数据层**:暂不引入 `authScheme` / `credentialEnvKey` 之类的 provider 字段。理由:当前仅 opencode-go 一个特例,数据层建模的收益尚未覆盖其同步成本(前端 schema、后端解析、契约、测试)。 + +## Consequences + +- **硬编码特例会随供应商增加而扩散**:`=== "opencode-go"` 散落在 `ProfileEditor.tsx`、`config-workspace-utils.ts` 与测试。出现第二个 x-api-key(或其它非 Bearer)供应商时,应重新评估把认证方式建模进 provider 数据层,并回看本 ADR。 +- **清理是单向的**:只清"切向 opencode-go"方向的 `ANTHROPIC_AUTH_TOKEN`;切走时保留 `ANTHROPIC_API_KEY`——x-api-key 是 Anthropic 兼容通用认证,切走后 Bearer 优先时它不遮蔽任何东西,属无害保留。 +- **后端不感知供应商**:认证回退语义与真实 Claude Code 保持一致,新增供应商无需改后端认证逻辑;后端"Bearer 优先"不变式成为前端切换与残留清理的共同依据。 diff --git a/docs/user-manual.md b/docs/user-manual.md index b252c05..304de10 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -143,7 +143,7 @@ The scheme is registered for packaged installs on macOS / Windows / Linux; Linux ## Providers -Providers are all built-in and read-only. They carry only objective provider information (the connection endpoint `ANTHROPIC_BASE_URL`, the model mapping, and optional additional environment variables) and contain no authentication keys. They currently cover Anthropic, DeepSeek, Zhipu GLM Coding Plan, Kimi Code Plan, MiniMax Token Plan, Xiaomi MiMo Token Plan, OpenRouter, Volcengine Ark Coding Plan, Alibaba Cloud Bailian Coding Plan, Wanjie Ark, and Ollama. +Providers are all built-in and read-only. They carry only objective provider information (the connection endpoint `ANTHROPIC_BASE_URL`, the model mapping, and optional additional environment variables) and contain no authentication keys. They currently cover Anthropic, DeepSeek, Zhipu GLM Coding Plan, Kimi Code Plan, MiniMax Token Plan, Xiaomi MiMo Token Plan, OpenRouter, Volcengine Ark Coding Plan, Wanjie Ark, OpenCode Go, and Ollama. Custom providers are not supported. After you select a built-in provider under the "Provider" option in the configuration editor, its connection endpoint and model mapping are filled in automatically; you only need to add the authentication key and behavior settings. Clicking "View built-in providers" below that option opens a read-only overview where you can see each provider's name, ID, API endpoint, official documentation link, and recommended models. diff --git a/docs/user-manual.zh-CN.md b/docs/user-manual.zh-CN.md index da690c3..8a3949b 100644 --- a/docs/user-manual.zh-CN.md +++ b/docs/user-manual.zh-CN.md @@ -143,7 +143,7 @@ macOS / Windows / Linux 在安装包场景下均可注册 scheme;开发态下 Li ## 供应商 Provider -供应商均为内置且只读,只承载供应商客观信息(连接地址 `ANTHROPIC_BASE_URL`、模型映射与可选附加环境变量),不含认证密钥。当前覆盖 Anthropic、DeepSeek、智谱 GLM Coding Plan、Kimi Code Plan、MiniMax Token Plan、小米 MiMo Token Plan、OpenRouter、火山方舟 Coding Plan、万界方舟和 Ollama。 +供应商均为内置且只读,只承载供应商客观信息(连接地址 `ANTHROPIC_BASE_URL`、模型映射与可选附加环境变量),不含认证密钥。当前覆盖 Anthropic、DeepSeek、智谱 GLM Coding Plan、Kimi Code Plan、MiniMax Token Plan、小米 MiMo Token Plan、OpenRouter、火山方舟 Coding Plan、万界方舟、OpenCode Go 和 Ollama。 不支持自定义供应商。在配置编辑器的「供应商」选项处选择一个内置供应商后,其连接地址与模型映射会自动带入;你只需补充认证密钥与行为设置。点击该选项下方的「查看内置供应商」可打开只读一览,查看每个供应商的名称、ID、API 地址、官方文档链接和推荐模型。 diff --git a/src-tauri/resources/builtin-providers.json b/src-tauri/resources/builtin-providers.json index b53377f..03b0975 100644 --- a/src-tauri/resources/builtin-providers.json +++ b/src-tauri/resources/builtin-providers.json @@ -114,6 +114,26 @@ "docUrl": "https://openrouter.ai/docs/guides/coding-agents/claude-code-integration", "models": [] }, + { + "name": "OpenCode Go", + "localizedName": { "zh": "OpenCode Go", "en": "OpenCode Go" }, + "slug": "opencode-go", + "baseUrl": "https://opencode.ai/zen/go", + "docUrl": "https://opencode.ai/docs/zh-cn/go", + "env": { + "ANTHROPIC_MODEL": "deepseek-v4-pro[1m]", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "deepseek-v4-pro[1m]", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "deepseek-v4-flash[1m]", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "deepseek-v4-flash", + "CLAUDE_CODE_SUBAGENT_MODEL": "deepseek-v4-flash" + }, + "models": [ + { "id": "deepseek-v4-pro[1m]", "name": "DeepSeek V4 Pro" }, + { "id": "deepseek-v4-flash[1m]", "name": "DeepSeek V4 Flash" }, + { "id": "minimax-m3", "name": "MiniMax M3" }, + { "id": "qwen3.8-max", "name": "Qwen3.8 Max" } + ] + }, { "name": "Ollama", "localizedName": { "zh": "Ollama", "en": "Ollama" }, diff --git a/src-tauri/resources/statusline/default.ps1 b/src-tauri/resources/statusline/default.ps1 index 7fdc896..92238bc 100644 --- a/src-tauri/resources/statusline/default.ps1 +++ b/src-tauri/resources/statusline/default.ps1 @@ -6,8 +6,10 @@ # 状态行追求健壮而非严格:单个字段异常不应导致整行无输出 $ErrorActionPreference = 'SilentlyContinue' -# 强制 UTF-8 输出,避免 -> 等字符被系统代码页破坏 -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +# 强制 UTF-8 输入输出,避免 -> 等字符与中文被系统代码页破坏。 +# 必须用无 BOM 实例:[System.Text.Encoding]::UTF8 带 preamble,PS 5.1 下可能把 EF BB BF 混进输出头 +$Utf8NoBom = New-Object System.Text.UTF8Encoding $false +[Console]::OutputEncoding = $Utf8NoBom # ── ANSI 颜色常量(用拼接构造,避免字符串插值把 $var[ 当作索引)── $ESC = [char]27 @@ -100,7 +102,15 @@ function Format-K($n) { } # ── 读取并解析 stdin JSON ────────────────────────────────── -$stdin = [Console]::In.ReadToEnd() +# 显式以 UTF-8 读取标准输入:PS 5.1 的 [Console]::In 按系统代码页解码, +# Claude Code 传入的 UTF-8 JSON 一旦含中文(目录名、session_name)就会乱码。 +# 直接设 [Console]::InputEncoding 在 stdin 已重定向时可能抛异常,故改用显式编码的 StreamReader。 +try { + $stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), $Utf8NoBom) + $stdin = $stdinReader.ReadToEnd() +} catch { + $stdin = [Console]::In.ReadToEnd() +} if ([string]::IsNullOrWhiteSpace($stdin)) { exit 0 } try { $data = $stdin | ConvertFrom-Json } catch { exit 0 } diff --git a/src-tauri/src/claude_cli.rs b/src-tauri/src/claude_cli.rs new file mode 100644 index 0000000..3adead9 --- /dev/null +++ b/src-tauri/src/claude_cli.rs @@ -0,0 +1,244 @@ +use std::env; +use std::fmt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +#[cfg(windows)] +const CLAUDE_FILE_NAMES: &[&str] = &["claude.exe", "claude.cmd", "claude.bat", "claude"]; +#[cfg(not(windows))] +const CLAUDE_FILE_NAMES: &[&str] = &["claude"]; + +#[derive(Debug)] +pub(crate) enum ClaudeCliError { + NotFound, + Spawn(std::io::Error), +} + +impl fmt::Display for ClaudeCliError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotFound => write!( + formatter, + "未找到 claude CLI,请确认 Claude Code 已安装并可在 PATH 或标准安装目录中访问" + ), + Self::Spawn(error) => write!(formatter, "执行 claude CLI 失败: {error}"), + } + } +} + +pub(crate) fn run(args: &[String]) -> Result { + // 保留调用进程显式 PATH 的优先级,再覆盖 GUI 应用缺少 shell PATH 的标准安装场景。 + let program = path_executable() + .or_else(|| native_installer_path().filter(|path| is_executable(path))) + .or_else(standard_install_executable); + let Some(program) = program else { + return Err(ClaudeCliError::NotFound); + }; + + let mut command = Command::new(program); + command.args(args); + crate::utils::hide_command_window(&mut command); + command.output().map_err(ClaudeCliError::Spawn) +} + +fn path_executable() -> Option { + let paths = env::var_os("PATH")?; + env::split_paths(&paths) + .flat_map(|directory| { + CLAUDE_FILE_NAMES + .iter() + .copied() + .map(move |file_name| directory.join(file_name)) + }) + .find(|path| is_executable(path)) +} + +fn native_installer_path() -> Option { + let file_name = if cfg!(windows) { + "claude.exe" + } else { + "claude" + }; + crate::utils::get_home_dir() + .ok() + .map(|home| home.join(".local/bin").join(file_name)) +} + +fn standard_install_executable() -> Option { + standard_bin_directories() + .into_iter() + .flat_map(|directory| { + CLAUDE_FILE_NAMES + .iter() + .copied() + .map(move |file_name| directory.join(file_name)) + }) + .find(|path| is_executable(path)) +} + +fn standard_bin_directories() -> Vec { + #[cfg(test)] + if let Some(paths) = env::var_os("CODE_MANAGER_TEST_CLAUDE_STANDARD_BIN_DIRS") { + return env::split_paths(&paths).collect(); + } + + #[cfg(target_os = "macos")] + { + vec![ + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/usr/local/bin"), + ] + } + + #[cfg(not(target_os = "macos"))] + { + Vec::new() + } +} + +fn is_executable(path: &Path) -> bool { + let Ok(metadata) = path.metadata() else { + return false; + }; + if !metadata.is_file() { + return false; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + + #[cfg(not(unix))] + { + true + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::run; + use std::env; + use std::ffi::OsString; + use std::fs; + + struct EnvVarGuard { + key: &'static str, + original: Option, + } + + impl EnvVarGuard { + fn capture(key: &'static str) -> Self { + Self { + key, + original: env::var_os(key), + } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.original { + Some(value) => env::set_var(self.key, value), + None => env::remove_var(self.key), + } + } + } + + #[cfg(unix)] + #[test] + fn run_finds_native_installer_when_gui_path_is_restricted() { + use std::os::unix::fs::PermissionsExt; + + let _guard = crate::utils::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let _path_guard = EnvVarGuard::capture("PATH"); + let _home_guard = EnvVarGuard::capture("CODE_MANAGER_HOME_OVERRIDE"); + let home = tempfile::tempdir().expect("应可创建临时 home"); + let cli_dir = home.path().join(".local/bin"); + let cli_path = cli_dir.join("claude"); + fs::create_dir_all(&cli_dir).expect("应可创建 native installer 目录"); + fs::write(&cli_path, "#!/bin/sh\nprintf '%s\\n' \"$@\"\n") + .expect("应可写入模拟 claude CLI"); + fs::set_permissions(&cli_path, fs::Permissions::from_mode(0o755)) + .expect("应可设置模拟 CLI 为可执行"); + env::set_var("PATH", "/usr/bin:/bin:/usr/sbin:/sbin"); + env::set_var("CODE_MANAGER_HOME_OVERRIDE", home.path()); + let args = ["plugin", "list", "--available", "--json"].map(str::to_string); + + let output = run(&args).expect("受限 GUI PATH 下应能运行 native installer 中的 CLI"); + + assert!(output.status.success()); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "plugin\nlist\n--available\n--json\n" + ); + } + + #[cfg(unix)] + #[test] + fn run_prefers_cli_from_process_path() { + use std::os::unix::fs::PermissionsExt; + + let _guard = crate::utils::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let _path_guard = EnvVarGuard::capture("PATH"); + let _home_guard = EnvVarGuard::capture("CODE_MANAGER_HOME_OVERRIDE"); + let root = tempfile::tempdir().expect("应可创建临时目录"); + let home_cli_dir = root.path().join("home/.local/bin"); + let path_cli_dir = root.path().join("path-bin"); + fs::create_dir_all(&home_cli_dir).expect("应可创建 native installer 目录"); + fs::create_dir_all(&path_cli_dir).expect("应可创建 PATH 目录"); + let home_cli = home_cli_dir.join("claude"); + let path_cli = path_cli_dir.join("claude"); + fs::write(&home_cli, "#!/bin/sh\nprintf 'native\\n'\n").expect("应可写入 native CLI"); + fs::write(&path_cli, "#!/bin/sh\nprintf 'path\\n'\n").expect("应可写入 PATH CLI"); + fs::set_permissions(&home_cli, fs::Permissions::from_mode(0o755)) + .expect("应可设置 native CLI 为可执行"); + fs::set_permissions(&path_cli, fs::Permissions::from_mode(0o755)) + .expect("应可设置 PATH CLI 为可执行"); + env::set_var("PATH", &path_cli_dir); + env::set_var("CODE_MANAGER_HOME_OVERRIDE", root.path().join("home")); + + let output = run(&["--version".to_string()]).expect("应优先运行 PATH 中的 CLI"); + + assert!(output.status.success()); + assert_eq!(String::from_utf8_lossy(&output.stdout), "path\n"); + } + + #[cfg(unix)] + #[test] + fn run_finds_cli_in_standard_install_directory() { + use std::os::unix::fs::PermissionsExt; + + let _guard = crate::utils::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let _path_guard = EnvVarGuard::capture("PATH"); + let _home_guard = EnvVarGuard::capture("CODE_MANAGER_HOME_OVERRIDE"); + let _standard_dirs_guard = + EnvVarGuard::capture("CODE_MANAGER_TEST_CLAUDE_STANDARD_BIN_DIRS"); + let root = tempfile::tempdir().expect("应可创建临时目录"); + let standard_bin_dir = root.path().join("standard-bin"); + fs::create_dir_all(&standard_bin_dir).expect("应可创建标准安装目录替身"); + let cli_path = standard_bin_dir.join("claude"); + fs::write(&cli_path, "#!/bin/sh\nprintf 'standard\\n'\n") + .expect("应可写入标准安装目录中的 CLI"); + fs::set_permissions(&cli_path, fs::Permissions::from_mode(0o755)) + .expect("应可设置标准安装 CLI 为可执行"); + env::set_var("PATH", "/usr/bin:/bin:/usr/sbin:/sbin"); + env::set_var("CODE_MANAGER_HOME_OVERRIDE", root.path().join("home")); + env::set_var( + "CODE_MANAGER_TEST_CLAUDE_STANDARD_BIN_DIRS", + &standard_bin_dir, + ); + + let output = run(&["--version".to_string()]).expect("应运行标准安装目录中的 CLI"); + + assert!(output.status.success()); + assert_eq!(String::from_utf8_lossy(&output.stdout), "standard\n"); + } +} diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 5c805d9..b7f382d 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -1982,12 +1982,32 @@ fn status_line_preset_target_path() -> Result { Ok(crate::utils::get_home_dir()?.join(".claude").join(filename)) } +// UTF-8 BOM。Windows PowerShell 5.1 读取无 BOM 的 .ps1 时按系统代码页(简中为 CP936)解码, +// UTF-8 中文注释会被错位解码,残留的悬空 lead byte 会吞掉行尾换行, +// 使下一行代码被并入注释行,最终触发 ParserError 让状态行整行无输出。 +#[cfg(windows)] +const STATUS_LINE_SCRIPT_UTF8_BOM: &str = "\u{feff}"; + +// 期望写入磁盘的脚本内容,同时作为幂等比较基准; +// 两处必须共用同一来源,否则带 BOM 的已安装脚本会被误判成用户自定义脚本。 +#[cfg(windows)] +fn expected_status_line_script() -> String { + format!("{STATUS_LINE_SCRIPT_UTF8_BOM}{DEFAULT_STATUS_LINE_SCRIPT}") +} + +// Bash 脚本不能带 BOM:shebang 之前出现 BOM 会让 `#!/bin/bash` 失效。 +#[cfg(not(windows))] +fn expected_status_line_script() -> String { + DEFAULT_STATUS_LINE_SCRIPT.to_string() +} + // 计算写入 settings.json 的 statusLine.command // Windows 用绝对正斜杠路径调用 PowerShell,规避 ~ 在 -File 参数中不展开的问题 #[cfg(windows)] fn status_line_preset_command(target_path: &std::path::Path) -> String { let normalized = target_path.display().to_string().replace('\\', "/"); - format!("powershell -NoProfile -ExecutionPolicy Bypass -File {normalized}") + // 路径必须加引号:用户名含空格时(如 C:/Users/demo user)-File 参数会在空格处被截断 + format!("powershell -NoProfile -ExecutionPolicy Bypass -File \"{normalized}\"") } #[cfg(not(windows))] @@ -2059,11 +2079,12 @@ fn install_status_line_preset_inner( ensure_status_line_preset_supported()?; let target_path = status_line_preset_target_path()?; + let expected_script = expected_status_line_script(); if target_path.exists() { let existing = fs::read_to_string(&target_path) .map_err(|e| format!("读取状态行脚本失败 {:?}: {}", target_path, e))?; - if existing == DEFAULT_STATUS_LINE_SCRIPT { + if existing == expected_script { ensure_status_line_script_executable(&target_path)?; return Ok(build_status_line_preset_result( preset_id, @@ -2083,7 +2104,7 @@ fn install_status_line_preset_inner( } } - write_status_line_script(&target_path, DEFAULT_STATUS_LINE_SCRIPT)?; + write_status_line_script(&target_path, &expected_script)?; Ok(build_status_line_preset_result( preset_id, &target_path, @@ -3189,6 +3210,46 @@ mod tests { assert!(!env.contains_key("ANTHROPIC_AUTH_TOKEN")); } + #[test] + fn builtin_providers_include_opencode_go_claude_code_env() { + let opencode_go = builtin_providers() + .iter() + .find(|provider| provider.id == "builtin:opencode-go") + .unwrap(); + let env = &opencode_go.env; + + assert_eq!(opencode_go.name, "OpenCode Go"); + assert_eq!( + opencode_go.doc_url, + Some("https://opencode.ai/docs/zh-cn/go".to_string()) + ); + assert_eq!( + opencode_go.model_suggestions, + vec![ + "deepseek-v4-pro[1m]".to_string(), + "deepseek-v4-flash[1m]".to_string(), + "minimax-m3".to_string(), + "qwen3.8-max".to_string() + ] + ); + assert_eq!( + env.get("ANTHROPIC_BASE_URL"), + Some(&Value::String("https://opencode.ai/zen/go".to_string())) + ); + assert_eq!( + env.get("ANTHROPIC_MODEL"), + Some(&Value::String("deepseek-v4-pro[1m]".to_string())) + ); + assert_eq!( + env.get("ANTHROPIC_DEFAULT_HAIKU_MODEL"), + Some(&Value::String("deepseek-v4-flash".to_string())) + ); + assert_eq!( + env.get("CLAUDE_CODE_SUBAGENT_MODEL"), + Some(&Value::String("deepseek-v4-flash".to_string())) + ); + } + #[test] fn resolve_profile_settings_merges_provider_env_then_profile_overrides() { // 供应商只提供 env(地址 + 模型映射),无继承;这里用内置 DeepSeek 供应商 @@ -3886,6 +3947,53 @@ mod tests { assert!(DEFAULT_STATUS_LINE_SCRIPT.contains("ConvertFrom-Json")); // 强制 UTF-8 输出,避免 emoji 与中文乱码 assert!(DEFAULT_STATUS_LINE_SCRIPT.contains("[Console]::OutputEncoding")); + // 编码实例必须无 BOM,否则输出头可能混入 EF BB BF + assert!(DEFAULT_STATUS_LINE_SCRIPT.contains("New-Object System.Text.UTF8Encoding $false")); + // 只禁止把带 preamble 的实例赋给 OutputEncoding; + // MD5 处的 [System.Text.Encoding]::UTF8.GetBytes() 不输出 preamble,属正常用法 + assert!(!DEFAULT_STATUS_LINE_SCRIPT + .contains("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8")); + // stdin 必须显式按 UTF-8 读取,否则含中文目录名的 JSON 会按系统代码页乱码 + assert!(DEFAULT_STATUS_LINE_SCRIPT.contains("[Console]::OpenStandardInput()")); + } + + #[cfg(windows)] + #[test] + fn expected_status_line_script_prepends_utf8_bom_on_windows() { + let expected = expected_status_line_script(); + + // PowerShell 5.1 靠 BOM 才会按 UTF-8 解析脚本 + assert!(expected.as_bytes().starts_with(b"\xEF\xBB\xBF")); + // BOM 之后必须是原始脚本,不能篡改或重复内容 + assert_eq!( + expected.strip_prefix('\u{feff}'), + Some(DEFAULT_STATUS_LINE_SCRIPT) + ); + } + + #[cfg(not(windows))] + #[test] + fn expected_status_line_script_has_no_bom_on_unix() { + let expected = expected_status_line_script(); + + // Bash 脚本带 BOM 会让 shebang 失效,必须保持裸内容 + assert!(!expected.starts_with('\u{feff}')); + assert!(expected.starts_with("#!/bin/bash")); + assert_eq!(expected, DEFAULT_STATUS_LINE_SCRIPT); + } + + #[cfg(windows)] + #[test] + fn status_line_preset_command_quotes_path_with_spaces() { + // 用户名含空格时,未加引号的 -File 参数会在空格处被截断,导致状态行整行无输出 + let command = status_line_preset_command(std::path::Path::new( + "C:\\Users\\demo user\\.claude\\statusline.ps1", + )); + + assert_eq!( + command, + "powershell -NoProfile -ExecutionPolicy Bypass -File \"C:/Users/demo user/.claude/statusline.ps1\"" + ); } #[cfg(windows)] @@ -3907,8 +4015,55 @@ mod tests { assert!(!result.needs_overwrite); assert_eq!( fs::read_to_string(&target_path).unwrap(), - DEFAULT_STATUS_LINE_SCRIPT + expected_status_line_script() ); + // 落盘文件必须真带 BOM,否则 PS 5.1 会按系统代码页解析并报 ParserError + assert!(fs::read(&target_path).unwrap().starts_with(b"\xEF\xBB\xBF")); + + clear_test_env(); + } + + #[cfg(windows)] + #[test] + fn install_status_line_preset_keeps_bom_script_without_requesting_overwrite() { + let _guard = crate::utils::lock_config().unwrap(); + let root = temp_root("status-line-existing-bom-windows"); + set_test_env(&root); + let target_path = root.join(".claude").join("statusline.ps1"); + fs::create_dir_all(target_path.parent().unwrap()).unwrap(); + // 模拟已安装(或用户手工补过 BOM)的脚本:必须判为已最新,而不是"已被自定义" + fs::write(&target_path, expected_status_line_script()).unwrap(); + + let result = install_status_line_preset_inner("default", false).unwrap(); + + assert!(!result.installed); + assert!(!result.needs_overwrite); + assert_eq!( + fs::read_to_string(&target_path).unwrap(), + expected_status_line_script() + ); + + clear_test_env(); + } + + #[cfg(windows)] + #[test] + fn install_status_line_preset_reports_overwrite_needed_for_bomless_script() { + let _guard = crate::utils::lock_config().unwrap(); + let root = temp_root("status-line-existing-bomless-windows"); + set_test_env(&root); + let target_path = root.join(".claude").join("statusline.ps1"); + fs::create_dir_all(target_path.parent().unwrap()).unwrap(); + // 旧版本装下的无 BOM 脚本:内容虽同,但需要重装才能修好解析问题 + fs::write(&target_path, DEFAULT_STATUS_LINE_SCRIPT).unwrap(); + + let result = install_status_line_preset_inner("default", false).unwrap(); + assert!(!result.installed); + assert!(result.needs_overwrite); + + let overwritten = install_status_line_preset_inner("default", true).unwrap(); + assert!(overwritten.installed); + assert!(fs::read(&target_path).unwrap().starts_with(b"\xEF\xBB\xBF")); clear_test_env(); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f6acffd..0e56e3f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod auto_memory; +mod claude_cli; mod claude_directory; mod claude_directory_watcher; mod config; diff --git a/src-tauri/src/plugins.rs b/src-tauri/src/plugins.rs index aacd37f..ee2d710 100644 --- a/src-tauri/src/plugins.rs +++ b/src-tauri/src/plugins.rs @@ -1,5 +1,3 @@ -use std::process::Command; - /// 触发 claude 读取插件目录缓存,按其默认 24h TTL 策略刷新安装数。 /// /// 不主动删缓存、不强制刷新:执行 `claude plugin list --available --json`,claude 内部若发现 @@ -20,16 +18,8 @@ pub fn refresh_plugin_install_counts() -> Result<(), String> { // 执行 `claude plugin list --available --json`:claude 读取 catalog 时按 TTL 决定是否重拉缓存。 // 输出仅用于失败诊断,不回传 UI。 fn trigger_claude_catalog_refresh() -> Result<(), String> { - let mut command = Command::new("claude"); - command.args(["plugin", "list", "--available", "--json"]); - crate::utils::hide_command_window(&mut command); - let output = command.output().map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - "未找到 claude CLI,请确认 Claude Code 已安装并可在 PATH 中访问".to_string() - } else { - format!("执行 claude plugin list 失败: {e}") - } - })?; + let args = ["plugin", "list", "--available", "--json"].map(str::to_string); + let output = crate::claude_cli::run(&args).map_err(|error| error.to_string())?; if output.status.success() { Ok(()) diff --git a/src-tauri/src/project.rs b/src-tauri/src/project.rs index eca38f3..25ec872 100644 --- a/src-tauri/src/project.rs +++ b/src-tauri/src/project.rs @@ -1275,13 +1275,7 @@ fn run_claude_project_purge( mode: ProjectPurgeMode, ) -> Result { let (project_display, args) = prepare_claude_project_purge(project, mode)?; - let output = Command::new("claude").args(&args).output().map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - "未找到 claude CLI,请确认 Claude Code 已安装并可在 PATH 中访问".to_string() - } else { - format!("执行 claude project purge 失败: {}", e) - } - })?; + let output = crate::claude_cli::run(&args).map_err(|error| error.to_string())?; parse_claude_project_purge_output( project_display, diff --git a/src/components/ProfileEditor.tsx b/src/components/ProfileEditor.tsx index 394befa..15435d5 100644 --- a/src/components/ProfileEditor.tsx +++ b/src/components/ProfileEditor.tsx @@ -228,7 +228,15 @@ const ProfileEditor = forwardRef(functi allowedKeys: COMMON_JSON_ALLOWED_KEYS, }); const envObject = useMemo(() => readTopLevelObject(settings, "env"), [settings]); - const hiddenEnvKeys = useMemo(() => [...AUTH_ENV_KEYS, ...COMMON_ENV_SETTINGS_KEYS], []); + // opencode-go 通过 ANTHROPIC_API_KEY(x-api-key)认证,认证区切换为 API Key 字段 + const usesApiKeyAuth = providerSlugFromId(providerId) === "opencode-go"; + const hiddenEnvKeys = useMemo(() => { + const authKeys: string[] = [...AUTH_ENV_KEYS]; + if (usesApiKeyAuth) { + authKeys.push("ANTHROPIC_API_KEY"); + } + return [...authKeys, ...COMMON_ENV_SETTINGS_KEYS]; + }, [usesApiKeyAuth]); const hiddenEnvEntries = useMemo( () => buildHiddenEnvEntries(envObject, hiddenEnvKeys), [envObject, hiddenEnvKeys], @@ -743,6 +751,8 @@ const ProfileEditor = forwardRef(functi openProviderDocs: t("providers.actions.openDocs"), authToken: t("profiles.editor.fields.authToken"), authTokenEnv: t("profiles.editor.fields.authTokenEnv"), + authApiKey: t("profiles.editor.fields.authApiKey"), + authApiKeyEnv: t("profiles.editor.fields.authApiKeyEnv"), showAuthToken: t("common.showToken"), hideAuthToken: t("common.hideToken"), baseUrl: t("profiles.editor.fields.baseUrl"), @@ -952,18 +962,31 @@ const ProfileEditor = forwardRef(functi - - {messages.authTokenEnv} + + + {usesApiKeyAuth ? messages.authApiKeyEnv : messages.authTokenEnv} + - applySettings(setEnvString(settings, "ANTHROPIC_AUTH_TOKEN", value)) + applySettings( + setEnvString( + settings, + usesApiKeyAuth ? "ANTHROPIC_API_KEY" : "ANTHROPIC_AUTH_TOKEN", + value, + ), + ) } /> diff --git a/src/components/__tests__/ProfileEditor.test.tsx b/src/components/__tests__/ProfileEditor.test.tsx index 84fc6e8..807b76d 100644 --- a/src/components/__tests__/ProfileEditor.test.tsx +++ b/src/components/__tests__/ProfileEditor.test.tsx @@ -4347,4 +4347,74 @@ describe("ProfileEditor", () => { expect(saved.settings.env).not.toHaveProperty("ENABLE_TOOL_SEARCH"); expect(saved.settings.env).not.toHaveProperty("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"); }); + + function opencodeGoProvider(): Provider { + return { + id: "builtin:opencode-go", + name: "OpenCode Go", + localizedName: { zh: "OpenCode Go", en: "OpenCode Go" }, + description: "OpenCode Go", + modelSuggestions: ["deepseek-v4-pro", "deepseek-v4-flash"], + env: { + ANTHROPIC_BASE_URL: "https://opencode.ai/zen/go", + ANTHROPIC_MODEL: "deepseek-v4-flash", + }, + }; + } + + function opencodeGoProfile(settings: Record): ConfigProfile { + return { + id: "user-opencode-go", + name: "OpenCode Go User", + description: "", + providerId: "builtin:opencode-go", + settings, + createdAt: "2026-08-16T12:00:00Z", + updatedAt: "2026-08-16T12:00:00Z", + }; + } + + it("opencode-go 供应商认证区切换到 ANTHROPIC_API_KEY 字段", () => { + renderEditor({ + providers: [opencodeGoProvider()], + profile: opencodeGoProfile({ env: { ANTHROPIC_API_KEY: "sk-test" } }), + }); + + const authSection = getSection("认证"); + expect(within(authSection).getByLabelText("ANTHROPIC_API_KEY")).toHaveValue("sk-test"); + expect(within(authSection).queryByLabelText("ANTHROPIC_AUTH_TOKEN")).not.toBeInTheDocument(); + }); + + it("opencode-go 认证密钥变更写入 ANTHROPIC_API_KEY", async () => { + const onSave = vi.fn(); + renderEditor({ + onSave, + providers: [opencodeGoProvider()], + profile: opencodeGoProfile({ env: { ANTHROPIC_API_KEY: "sk-old" } }), + }); + + fireEvent.change(screen.getByLabelText("ANTHROPIC_API_KEY"), { + target: { value: "sk-new" }, + }); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "保存" })); + await Promise.resolve(); + }); + + expect(onSave.mock.calls[0][0].settings.env).toMatchObject({ + ANTHROPIC_API_KEY: "sk-new", + }); + }); + + it("opencode-go 下 ANTHROPIC_API_KEY 从通用环境变量分区隐藏", () => { + renderEditor({ + providers: [opencodeGoProvider()], + profile: opencodeGoProfile({ env: { ANTHROPIC_API_KEY: "sk-test" } }), + }); + + toggleAccordionSection("环境变量"); + const envSection = getSection("环境变量"); + expect(within(envSection).queryByText("ANTHROPIC_API_KEY")).not.toBeInTheDocument(); + }); }); diff --git a/src/components/__tests__/config-workspace-utils.test.ts b/src/components/__tests__/config-workspace-utils.test.ts index 1cee440..6107b0f 100644 --- a/src/components/__tests__/config-workspace-utils.test.ts +++ b/src/components/__tests__/config-workspace-utils.test.ts @@ -237,6 +237,50 @@ describe("config-workspace-utils preset autofill", () => { // 无可解析 provider 时不动任何字段(含地址) expect(applyProviderAutofill(seededSettings, PRESETS, undefined)).toEqual(seededSettings); }); + + it("切到 opencode-go 时清掉残留的 ANTHROPIC_AUTH_TOKEN(避免 Bearer 优先遮蔽 API Key)", () => { + const opencodeGoProviders: Provider[] = [ + { + id: "builtin:opencode-go", + name: "OpenCode Go", + description: "OpenCode Go 供应商", + localizedName: { zh: "OpenCode Go", en: "OpenCode Go" }, + models: [], + modelSuggestions: [], + env: { ANTHROPIC_BASE_URL: "https://opencode.ai/zen/go" }, + }, + ]; + const staleSettings = { + env: { + ANTHROPIC_AUTH_TOKEN: "stale-token", + OTHER_ENV: "keep-me", + }, + }; + + // 切到 opencode-go:地址与旧认证 token 一并清空,无关 env 保留 + expect( + applyProviderAutofill(staleSettings, opencodeGoProviders, "builtin:opencode-go"), + ).toEqual({ env: { OTHER_ENV: "keep-me" } }); + }); + + it("从 opencode-go 切走时保留 ANTHROPIC_API_KEY(x-api-key 为 Anthropic 兼容通用认证,不反向清理)", () => { + const settings = { + env: { + ANTHROPIC_API_KEY: "sk-keep", + ANTHROPIC_MODEL: "manual-model", + OTHER_ENV: "keep-me", + }, + }; + + // 切到 deepseek(可解析)时只清地址,不清 API_KEY + expect(applyProviderAutofill(settings, PRESETS, "builtin:deepseek")).toEqual({ + env: { + ANTHROPIC_API_KEY: "sk-keep", + ANTHROPIC_MODEL: "manual-model", + OTHER_ENV: "keep-me", + }, + }); + }); }); describe("config-workspace-utils profile effective summary", () => { diff --git a/src/components/__tests__/ui-system-contract.test.ts b/src/components/__tests__/ui-system-contract.test.ts index 163c120..1f3e6cc 100644 --- a/src/components/__tests__/ui-system-contract.test.ts +++ b/src/components/__tests__/ui-system-contract.test.ts @@ -111,6 +111,7 @@ describe("ui system contract", () => { expect(css).toContain("--shadow-panel:"); expect(css).toContain("--shadow-floating:"); expect(css).toContain("--shadow-toolbar:"); + expect(css).toContain("--z-index-sticky: 10"); expect(css).toContain("--shadow-panel: 0 1px 2px"); expect(css).toContain("0 18px 45px"); expect(css).toContain("--shadow-toolbar: 0 1px 0"); diff --git a/src/components/config-workspace-utils.ts b/src/components/config-workspace-utils.ts index e8bb435..9b2e7f1 100644 --- a/src/components/config-workspace-utils.ts +++ b/src/components/config-workspace-utils.ts @@ -351,7 +351,13 @@ export function applyProviderAutofill( // 覆盖层只存差异:不再把 provider 的默认模型/effort 复制进 Profile settings // (编辑器按"有效值"显示这些默认,详见 readBehaviorFieldState)。 // 仅在切到可解析供应商时清掉残留的地址覆盖——地址由 Provider 合并层提供(单一事实源),与后端一致。 - return providerResolved ? setEnvString(settings, "ANTHROPIC_BASE_URL", "") : settings; + let next = providerResolved ? setEnvString(settings, "ANTHROPIC_BASE_URL", "") : settings; + // opencode-go 通过 ANTHROPIC_API_KEY(x-api-key)认证,而 Claude Code 与后端模型测试均 + // Bearer 优先;切到该供应商时清掉可能残留的 ANTHROPIC_AUTH_TOKEN,避免其遮蔽用户新填的 API Key。 + if (providerResolved && providerSlugFromId(providerId) === "opencode-go") { + next = setEnvString(next, "ANTHROPIC_AUTH_TOKEN", ""); + } + return next; } /** diff --git a/src/components/profile-editor/BrowseMarketplaceTab.tsx b/src/components/profile-editor/BrowseMarketplaceTab.tsx index 30084e3..a16f500 100644 --- a/src/components/profile-editor/BrowseMarketplaceTab.tsx +++ b/src/components/profile-editor/BrowseMarketplaceTab.tsx @@ -1,30 +1,12 @@ +import { useVirtualizer } from "@tanstack/react-virtual"; import { openUrl } from "@tauri-apps/plugin-opener"; -import { - ArrowDown, - ArrowUp, - ArrowUpDown, - Bot, - Braces, - ChevronDown, - CircleCheck, - ExternalLink, - Info, - Plug, - Plus, - RefreshCw, - Settings2, - Sparkles, - SquareTerminal, - Store, - Webhook, -} from "lucide-react"; +import { ArrowDown, ArrowUp, ArrowUpDown, CircleCheck, Info, RefreshCw, Store } from "lucide-react"; import type { KeyboardEvent, ReactNode } from "react"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { useToast } from "@/hooks/useToast"; import { cn } from "@/lib/utils"; -import { type TranslationKey, useI18n } from "../../i18n"; +import { useI18n } from "../../i18n"; import { ipc } from "../../ipc"; -import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Empty, EmptyContent, EmptyDescription, EmptyTitle } from "../ui/empty"; import { Input } from "../ui/input"; @@ -38,15 +20,16 @@ import { SelectTrigger, SelectValue, } from "../ui/select"; -import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { formatShortDateTime } from "../usage/format"; +import MarketplacePluginRow from "./MarketplacePluginRow"; import type { MarketplacePluginEntry } from "./marketplace-catalog"; +import { getProviderAffiliation } from "./marketplace-catalog"; +import { estimatePluginRowSize } from "./marketplace-plugin-row-utils"; import { OFFICIAL_MARKETPLACE_ID, OFFICIAL_MARKETPLACE_REPO } from "./marketplace-presets"; import { emptyPluginCatalog, loadPluginCatalog, type PluginCatalog, - type PluginComponents, type PluginInstallCounts, } from "./plugin-install-counts"; import type { PluginEntry } from "./useEnabledPluginsState"; @@ -79,42 +62,19 @@ const FILTER_CONTROL_CLASS = "flex h-10 min-w-[160px] items-center gap-2 rounded-md border border-border bg-card px-2.5 transition-[border-color,box-shadow,transform] focus-within:border-primary focus-within:ring-[3px] focus-within:ring-ring/50 hover:border-muted-foreground"; const FILTER_TRIGGER_CLASS = "h-full min-w-0 flex-1 border-0 bg-transparent p-0 shadow-none focus:ring-0"; -const DETAILS_COLLAPSE_THRESHOLD = 150; const MIN_REFRESH_FEEDBACK_MS = 500; +// 虚拟化列表可视区高度上限。插件分区嵌在 accordion 内的可滚动抽屉里,没有确定的可用高度可跟随, +// 故用固定上限而非 flex-1 min-h-0;类名契约在 BrowseMarketplaceTab.test.tsx 中断言。 +const PLUGIN_LIST_SCROLL_CLASS = "max-h-[480px] overflow-y-auto overscroll-contain"; type MarketplaceSortMode = "pluginId" | "installCount"; type SortDirection = "asc" | "desc"; type ProviderFilter = "all" | "anthropic" | "partner"; -// catalog 缓存里 Anthropic 第一方插件的作者名 -const ANTHROPIC_AUTHOR = "Anthropic"; // 官方 marketplace 仓库 commit 基址,用于 marketplace SHA 外链 const OFFICIAL_MARKETPLACE_COMMIT_BASE = "https://github.com/anthropics/claude-plugins-official/commit/"; -// 组成类别的展示顺序、图标与 i18n 文案 key -const COMPONENT_KINDS = [ - { - key: "commands", - icon: SquareTerminal, - labelKey: "profileEditor.plugins.browse.componentCommands", - }, - { key: "agents", icon: Bot, labelKey: "profileEditor.plugins.browse.componentAgents" }, - { key: "skills", icon: Sparkles, labelKey: "profileEditor.plugins.browse.componentSkills" }, - { key: "hooks", icon: Webhook, labelKey: "profileEditor.plugins.browse.componentHooks" }, - { key: "mcpServers", icon: Plug, labelKey: "profileEditor.plugins.browse.componentMcpServers" }, - { key: "lspServers", icon: Braces, labelKey: "profileEditor.plugins.browse.componentLspServers" }, -] as const satisfies ReadonlyArray<{ - key: keyof PluginComponents; - icon: typeof Bot; - labelKey: TranslationKey; -}>; - -// 按作者归属把官方市场插件分为 Anthropic 第一方与合作伙伴;空作者视为合作伙伴 -function getProviderAffiliation(plugin: MarketplacePluginEntry): "anthropic" | "partner" { - return plugin.authorName === ANTHROPIC_AUTHOR ? "anthropic" : "partner"; -} - // catalog 元信息的 ISO 时间 -> 本地短时间;空或非法返回占位符 function formatCatalogTime(iso: string | null): string { if (!iso) { @@ -290,6 +250,8 @@ export default function BrowseMarketplaceTab({ const { showToast } = useToast(); const { byMarketplace, refreshAll, refreshOne } = useMarketplaceCatalog({ sources, active }); const [searchQuery, setSearchQuery] = useState(""); + // 输入即时回显,列表用延迟值:快速键入时 filter+sort 与重渲染不阻塞输入 + const deferredSearchQuery = useDeferredValue(searchQuery); const [marketplaceFilter, setMarketplaceFilter] = useState<"all" | string>("all"); const [statusFilter, setStatusFilter] = useState<"all" | "enabled" | "disabled">("all"); const [categoryFilter, setCategoryFilter] = useState<"all" | string>("all"); @@ -358,7 +320,7 @@ export default function BrowseMarketplaceTab({ ); const filtered = useMemo(() => { - const q = searchQuery.trim().toLowerCase(); + const q = deferredSearchQuery.trim().toLowerCase(); const comparePluginId = (a: MarketplacePluginEntry, b: MarketplacePluginEntry) => a.pluginId.localeCompare(b.pluginId, undefined, { sensitivity: "base" }); return allPlugins @@ -394,16 +356,96 @@ export default function BrowseMarketplaceTab({ }, [ allPlugins, categoryFilter, + deferredSearchQuery, enabledMap, installCounts, marketplaceFilter, providerFilter, - searchQuery, sortDirection, sortMode, statusFilter, ]); + // useCallback 稳定引用,行组件 memo 依赖回调不变化才拦截重渲染 + const toggleDetails = useCallback((pluginId: string) => { + setExpandedPluginIds((current) => { + const next = new Set(current); + if (next.has(pluginId)) { + next.delete(pluginId); + } else { + next.add(pluginId); + } + return next; + }); + }, []); + + const handleDetailsKeyDown = useCallback( + (event: KeyboardEvent, pluginId: string) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + toggleDetails(pluginId); + }, + [toggleDetails], + ); + + // 稳定化父级传入的回调(父级内联箭头每次 render 新建引用,会击穿行组件 memo) + const handleAddPlugin = useCallback( + (pluginId: string) => { + onAddPlugin(pluginId); + }, + [onAddPlugin], + ); + const handleManagePlugin = useCallback( + (pluginId: string) => { + onManagePlugin(pluginId); + }, + [onManagePlugin], + ); + + // 虚拟化:只渲染可视行,292 行市场下把每行 7 个 Tooltip 的开销从 ~2000 个降到 ~100 个。 + // 代价是浏览器 Cmd+F 只能命中已渲染行;该场景由应用内搜索框覆盖, + // 行数语义通过 role=list + aria-setsize/aria-posinset 暴露给辅助技术。 + const scrollRef = useRef(null); + const virtualizer = useVirtualizer({ + count: filtered.length, + getScrollElement: () => scrollRef.current, + estimateSize: (index) => { + const plugin = filtered[index]; + const containerWidth = scrollRef.current?.clientWidth || scrollRef.current?.offsetWidth; + return estimatePluginRowSize( + plugin, + catalog, + plugin ? expandedPluginIds.has(plugin.pluginId) : false, + containerWidth, + ); + }, + overscan: 8, + // index 可能是 -1:virtual-core 的 indexFromElement 在 data-index 缺失时只 console.warn 并返回 -1, + // 随后无条件调用 getItemKey,越界解引用会在 ref 回调内抛错卸载整个插件分区 + getItemKey: (index) => filtered[index]?.pluginId ?? String(index), + }); + const virtualItems = virtualizer.getVirtualItems(); + + // 筛选/排序条件变化后列表内容整体替换,保留旧 scrollOffset 会让虚拟化停在新结果集尾部 + // (calculateRange 用旧偏移对新 measurements 做二分查找,startIndex 被 clamp 到接近末尾)。 + // 直接写 DOM scrollTop:真实浏览器会派发 scroll 事件让 virtualizer 同步偏移, + // 而 virtualizer.scrollToOffset 底层走 scrollElement.scrollTo,jsdom 未实现该方法。 + // biome-ignore lint/correctness/useExhaustiveDependencies: 依赖表是刻意的触发器——只列筛选/排序输入,不含 filtered,否则启用插件导致 filtered 重算时列表会跳回顶部 + useEffect(() => { + const scroller = scrollRef.current; + if (scroller) { + scroller.scrollTop = 0; + } + }, [ + deferredSearchQuery, + marketplaceFilter, + statusFilter, + categoryFilter, + providerFilter, + sortMode, + sortDirection, + ]); + if (sources.length === 0) { return ( @@ -484,13 +526,6 @@ export default function BrowseMarketplaceTab({ ); } - function formatInstallCount(pluginId: string): string { - const count = installCounts[pluginId]; - return typeof count === "number" - ? numberFormatter.format(count) - : t("profileEditor.plugins.browse.installCountUnknown"); - } - function formatRefreshSuccessDescription( summaries: Awaited>, ): string { @@ -539,24 +574,6 @@ export default function BrowseMarketplaceTab({ } } - function toggleDetails(pluginId: string) { - setExpandedPluginIds((current) => { - const next = new Set(current); - if (next.has(pluginId)) { - next.delete(pluginId); - } else { - next.add(pluginId); - } - return next; - }); - } - - function handleDetailsKeyDown(event: KeyboardEvent, pluginId: string) { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - toggleDetails(pluginId); - } - return (
{/* 筛选栏 */} @@ -868,271 +885,100 @@ export default function BrowseMarketplaceTab({ sort: sortHintLabel, })}

-
- - {t("profileEditor.common.index")} - - + {/* 表头必须与行同处滚动容器内:否则滚动条宽度只从行网格里扣,两侧 grid 模板宽度不一致导致列错位。 + sticky 自带 bg-card 遮挡下方滚动的行;z-sticky 使用全局语义层级 token。 */} +
- - - - + + - {t("profileEditor.plugins.browse.columnInstallCount")} - {renderSortIcon("installCount")} - - - {t("profileEditor.common.actions")} -
- - {filtered.map((plugin, index) => { - const configured = enabledMap.has(plugin.pluginId); - const subTitle = [plugin.authorName, plugin.marketplaceId].filter(Boolean).join(" · "); - const details = [plugin.description, subTitle].filter(Boolean).join(" · "); - const expanded = expandedPluginIds.has(plugin.pluginId); - const canExpandDetails = details.length > DETAILS_COLLAPSE_THRESHOLD; - const detailsTooltip = expanded - ? t("profileEditor.plugins.browse.collapseDetailsTooltip") - : t("profileEditor.plugins.browse.expandDetailsTooltip"); - const rowLabel = plugin.pluginId; - const displayName = plugin.pluginId.split("@")[0]; - // 组成数据仅官方市场插件有(来自 catalog 缓存) - const components = catalog.entries[plugin.pluginId]?.components; - const componentBadges = components - ? COMPONENT_KINDS.map((kind) => ({ - ...kind, - count: components[kind.key].length, - })).filter((badge) => badge.count > 0) - : []; - const hasComponents = componentBadges.length > 0; - // 提供方归属(仅对官方市场插件做行内徽章区分) - const affiliation = getProviderAffiliation(plugin); + +
+ {t("profileEditor.common.actions")} +
- return ( -
- - {index + 1} - -
-
- {plugin.homepage ? ( - - ) : ( - {displayName} - )} - {plugin.isOfficial && - (affiliation === "anthropic" ? ( - - - ) : plugin.authorName ? ( - - {plugin.authorName} - - ) : ( - - - ))} - {plugin.category && ( - - {plugin.category} - - )} + {/* 表头在流内占位使 spacer 起点下移约 37px,virtualizer 的 scrollOffset 与 item 坐标系 + 因此有同等偏差。不设 scrollMargin:偏差只让 startIndex 晚 1 行以内,被 overscan: 8 完全吸收。 */} +
+ {virtualItems.map((virtualItem) => { + const plugin = filtered[virtualItem.index]; + if (!plugin) return null; + return ( +
+
- {details && ( -
- {canExpandDetails ? ( - - - - - - {detailsTooltip} - - - ) : ( -
- {details} -
- )} -
- )} - {hasComponents && ( -
-
- {componentBadges.map(({ key, icon: Icon, labelKey, count }) => ( - - - - - - - {t(labelKey)} - - - ))} - -
- {expanded && components && ( -
- {COMPONENT_KINDS.map(({ key, labelKey }) => - components[key].length > 0 ? ( -
- {t(labelKey)}:{" "} - {components[key].join(", ")} -
- ) : null, - )} -
- )} -
- )} -
-
- - {t("profileEditor.plugins.browse.columnInstallCount")}: - - {formatInstallCount(plugin.pluginId)} -
-
- {configured ? ( -
- - - -
- ) : ( - - )} -
-
- ); - })} + ); + })} +
+
)} diff --git a/src/components/profile-editor/EnabledPluginsEditor.tsx b/src/components/profile-editor/EnabledPluginsEditor.tsx index 0aa04f0..e3b334c 100644 --- a/src/components/profile-editor/EnabledPluginsEditor.tsx +++ b/src/components/profile-editor/EnabledPluginsEditor.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { useI18n } from "../../i18n"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import BrowseMarketplaceTab, { type AddMarketplaceInput } from "./BrowseMarketplaceTab"; @@ -77,13 +77,16 @@ function EnabledPluginsEditor({ [marketplaceSources], ); - function handleManagePlugin(pluginId: string) { + // useCallback 稳定引用:传进浏览列表行组件,memo 浅比较才不击穿 + const handleManagePlugin = useCallback((pluginId: string) => { setManageTarget((current) => ({ pluginId, requestId: (current?.requestId ?? 0) + 1, })); setActiveTab("enabled"); - } + }, []); + + const handleAddPlugin = useCallback((pluginId: string) => addPlugin(pluginId, true), [addPlugin]); function handleTabChange(value: string) { setActiveTab(value as "enabled" | "browse"); @@ -144,7 +147,7 @@ function EnabledPluginsEditor({ sources={marketplaceSources} plugins={plugins} active={activeTab === "browse"} - onAddPlugin={(pluginId) => addPlugin(pluginId, true)} + onAddPlugin={handleAddPlugin} onManagePlugin={handleManagePlugin} existingMarketplaceIds={existingMarketplaceIds} onAddMarketplace={onMarketplacesChange ? handleAddMarketplace : undefined} diff --git a/src/components/profile-editor/MarketplacePluginRow.tsx b/src/components/profile-editor/MarketplacePluginRow.tsx new file mode 100644 index 0000000..488bba9 --- /dev/null +++ b/src/components/profile-editor/MarketplacePluginRow.tsx @@ -0,0 +1,265 @@ +import { openUrl } from "@tauri-apps/plugin-opener"; +import { ChevronDown, CircleCheck, ExternalLink, Plus, Settings2 } from "lucide-react"; +import { type KeyboardEvent, memo } from "react"; +import { cn } from "@/lib/utils"; +import { useI18n } from "../../i18n"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; +import type { MarketplacePluginEntry } from "./marketplace-catalog"; +import { getProviderAffiliation } from "./marketplace-catalog"; +import { + COMPONENT_KINDS, + DETAILS_COLLAPSE_THRESHOLD, + getMarketplacePluginDetails, + hasMarketplacePluginComponents, +} from "./marketplace-plugin-row-utils"; +import type { PluginComponents } from "./plugin-install-counts"; + +interface MarketplacePluginRowProps { + plugin: MarketplacePluginEntry; + index: number; + /** 插件是否已写入 enabledPlugins(不论启用与否):决定展示「管理」还是「添加并启用」 */ + configured: boolean; + expanded: boolean; + installCount: number | null; + components: PluginComponents | undefined; + numberFormatter: Intl.NumberFormat; + onToggleDetails: (pluginId: string) => void; + onDetailsKeyDown: (event: KeyboardEvent, pluginId: string) => void; + onAddPlugin: (pluginId: string) => void; + onManagePlugin: (pluginId: string) => void; +} + +// 单行插件条目。独立 memo 组件:展开详情、启用插件等操作只改受影响行的 props, +// 其余行被浅比较拦截,避免整列表重渲染。 +function MarketplacePluginRow({ + plugin, + index, + configured, + expanded, + installCount, + components, + numberFormatter, + onToggleDetails, + onDetailsKeyDown, + onAddPlugin, + onManagePlugin, +}: MarketplacePluginRowProps) { + const { t } = useI18n(); + const details = getMarketplacePluginDetails(plugin); + const canExpandDetails = details.length > DETAILS_COLLAPSE_THRESHOLD; + const detailsTooltip = expanded + ? t("profileEditor.plugins.browse.collapseDetailsTooltip") + : t("profileEditor.plugins.browse.expandDetailsTooltip"); + const rowLabel = plugin.pluginId; + const displayName = plugin.pluginId.split("@")[0]; + // 组成数据仅官方市场插件有(来自 catalog 缓存) + const componentBadges = components + ? COMPONENT_KINDS.map((kind) => ({ + ...kind, + count: components[kind.key].length, + })).filter((badge) => badge.count > 0) + : []; + const hasComponents = hasMarketplacePluginComponents(components); + // 提供方归属(仅对官方市场插件做行内徽章区分) + const affiliation = getProviderAffiliation(plugin); + const installCountLabel = + installCount === null + ? t("profileEditor.plugins.browse.installCountUnknown") + : numberFormatter.format(installCount); + + return ( +
+ + {index + 1} + +
+
+ {plugin.homepage ? ( + + ) : ( + {displayName} + )} + {plugin.isOfficial && + (affiliation === "anthropic" ? ( + + + ) : plugin.authorName ? ( + + {plugin.authorName} + + ) : ( + + + ))} + {plugin.category && ( + + {plugin.category} + + )} +
+ {details && ( +
+ {canExpandDetails ? ( + + + + + + {detailsTooltip} + + + ) : ( +
+ {details} +
+ )} +
+ )} + {hasComponents && ( +
+
+ {componentBadges.map(({ key, icon: Icon, labelKey, count }) => ( + + + + + + + {t(labelKey)} + + + ))} + +
+ {expanded && components && ( +
+ {COMPONENT_KINDS.map(({ key, labelKey }) => + components[key].length > 0 ? ( +
+ {t(labelKey)}:{" "} + {components[key].join(", ")} +
+ ) : null, + )} +
+ )} +
+ )} +
+
+ + {t("profileEditor.plugins.browse.columnInstallCount")}: + + {installCountLabel} +
+
+ {configured ? ( +
+ + + +
+ ) : ( + + )} +
+
+ ); +} + +export default memo(MarketplacePluginRow); diff --git a/src/components/profile-editor/__tests__/BrowseMarketplaceTab.test.tsx b/src/components/profile-editor/__tests__/BrowseMarketplaceTab.test.tsx index d778ae5..beedc36 100644 --- a/src/components/profile-editor/__tests__/BrowseMarketplaceTab.test.tsx +++ b/src/components/profile-editor/__tests__/BrowseMarketplaceTab.test.tsx @@ -25,6 +25,69 @@ vi.mock("@tauri-apps/api/core", () => ({ const originalFetch = globalThis.fetch; const fetchMock = vi.fn(); +// jsdom 无布局引擎,元素测量恒为 0;@tanstack/virtual-core 既用 offsetHeight 量滚动视口(getRect), +// 也用它量每一行(measureElement)。若对所有元素返回同一个值,行高就等于视口高度, +// 虚拟化跑在退化几何上(约 1 行可见),窗口化断言会恒成立。故按元素分派: +// 滚动容器给视口高度,虚拟化行 wrapper(带 data-index)给真实量级的行高。 +const SCROLL_VIEWPORT_HEIGHT = 400; +const STUBBED_ROW_HEIGHT = 120; +const originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"); +const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"); +const originalScrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop"); + +function stubElementMeasurements() { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + get(this: HTMLElement) { + if (this.hasAttribute("data-index")) return STUBBED_ROW_HEIGHT; + if (this.dataset.slot === "browse-scroll") return SCROLL_VIEWPORT_HEIGHT; + return 0; + }, + }); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + configurable: true, + get: () => 800, + }); + // jsdom 写 scrollTop 不会派发 scroll 事件,而真实浏览器会——virtualizer 只在 scroll 回调里 + // 同步内部偏移(observeElementOffset)。补齐该行为,让滚动路径在测试里走完整链路。 + // 注意 scrollTop 定义在 Element.prototype 上,不在 HTMLElement.prototype。 + if (originalScrollTop?.get && originalScrollTop.set) { + const { get, set } = originalScrollTop; + Object.defineProperty(Element.prototype, "scrollTop", { + configurable: true, + get(this: Element) { + return get.call(this); + }, + set(this: Element, value: number) { + set.call(this, value); + this.dispatchEvent(new Event("scroll")); + }, + }); + } +} + +function restoreElementMeasurements() { + if (originalOffsetHeight) { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", originalOffsetHeight); + } else { + Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight"); + } + if (originalOffsetWidth) { + Object.defineProperty(HTMLElement.prototype, "offsetWidth", originalOffsetWidth); + } else { + Reflect.deleteProperty(HTMLElement.prototype, "offsetWidth"); + } + if (originalScrollTop) { + Object.defineProperty(Element.prototype, "scrollTop", originalScrollTop); + } +} + +// 已渲染虚拟行的 data-index 升序列表 +function renderedRowIndexes(container: HTMLElement): number[] { + return Array.from(container.querySelectorAll("[data-index]"), (node) => + Number(node.getAttribute("data-index")), + ).sort((a, b) => a - b); +} const SOURCES = [ { @@ -37,6 +100,7 @@ const SOURCES = [ ]; beforeEach(() => { + stubElementMeasurements(); fetchMock.mockReset(); invokeMock.mockReset(); invokeMock.mockImplementation(async (command) => { @@ -66,6 +130,7 @@ afterEach(() => { writable: true, configurable: true, }); + restoreElementMeasurements(); }); function renderTab(props?: { @@ -766,4 +831,130 @@ describe("BrowseMarketplaceTab", () => { expect(await screen.findByText("加载失败的来源")).toBeInTheDocument(); expect(screen.getByText("claude-plugins-official")).toBeInTheDocument(); }); + + it("大数据量下虚拟化只渲染可视行", async () => { + // 模拟大型市场(280+ 插件量级):400px 视口 / 120px 行高约 4 行可见,加 overscan 8 也远小于 300 + const plugins = Array.from({ length: 300 }, (_, i) => ({ + name: `plugin-${String(i).padStart(3, "0")}`, + })); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ plugins }), + } as unknown as Response); + const { container } = renderTab(); + await waitFor(() => { + expect(renderedRowIndexes(container).length).toBeGreaterThan(0); + }); + const indexes = renderedRowIndexes(container); + // 未滚动,窗口应从头开始且连续 + expect(indexes[0]).toBe(0); + expect(indexes).toEqual(indexes.map((_, offset) => offset)); + // 窗口远小于全量,且尾部索引不会跑到列表后半段 + expect(indexes.length).toBeLessThan(60); + expect(indexes[indexes.length - 1]).toBeLessThan(150); + // 行序号与表头显示总数仍是全量 + expect(screen.getByText(/共 300 个插件/)).toBeInTheDocument(); + }); + + it("搜索过滤后只渲染命中的行", async () => { + // 筛选结果需远多于一屏,否则命中 calculateRange 的 measurements.length <= lanes 短路, + // 直接返回整个列表,测不到窗口化 + const plugins = Array.from({ length: 300 }, (_, i) => ({ + name: `plugin-${String(i).padStart(3, "0")}`, + })); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ plugins }), + } as unknown as Response); + const { container } = renderTab(); + const input = await screen.findByLabelText(/搜索/); + await waitFor(() => { + expect(renderedRowIndexes(container).length).toBeGreaterThan(0); + }); + // plugin-1 命中 plugin-100 ~ plugin-199,共 100 条 + fireEvent.change(input, { target: { value: "plugin-1" } }); + await waitFor(() => { + expect(screen.getByText(/显示 1-100/)).toBeInTheDocument(); + }); + const indexes = renderedRowIndexes(container); + expect(indexes.length).toBeLessThan(30); + expect(indexes[0]).toBe(0); + expect(container.querySelector("[data-slot='browse-row']")).toHaveTextContent("plugin-100"); + }); + + it("已配置但已禁用的插件仍显示管理入口", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ plugins: [{ name: "alpha" }] }), + } as unknown as Response); + const onManagePlugin = vi.fn(); + renderTab({ + plugins: [ + { + id: "plugin:alpha@claude-plugins-official", + pluginId: "alpha@claude-plugins-official", + // 已写入 enabledPlugins 但处于禁用态:行动作取决于「是否已配置」,不是「是否启用」 + enabled: false, + committed: true, + }, + ], + onManagePlugin, + }); + expect(await screen.findByText("已配置")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /添加并启用/ })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "管理" })); + expect(onManagePlugin).toHaveBeenCalledWith("alpha@claude-plugins-official"); + }); + + it("筛选条件变化后滚动位置回到列表顶部", async () => { + const plugins = Array.from({ length: 300 }, (_, i) => ({ + name: `plugin-${String(i).padStart(3, "0")}`, + })); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ plugins }), + } as unknown as Response); + const { container } = renderTab(); + const input = await screen.findByLabelText(/搜索/); + await waitFor(() => { + expect(renderedRowIndexes(container).length).toBeGreaterThan(0); + }); + const scroller = container.querySelector("[data-slot='browse-scroll']"); + if (!scroller) throw new Error("missing browse scroll container"); + + // 滚到列表中后段(打桩的 scrollTop setter 会派发 scroll,让 virtualizer 同步偏移) + await act(async () => { + scroller.scrollTop = 22_000; + }); + await waitFor(() => { + expect(renderedRowIndexes(container)[0]).toBeGreaterThan(100); + }); + + fireEvent.change(input, { target: { value: "plugin-1" } }); + await waitFor(() => { + expect(screen.getByText(/显示 1-100/)).toBeInTheDocument(); + }); + // 收窄结果集后若保留旧偏移,窗口会停在新结果集尾部 + expect(scroller.scrollTop).toBe(0); + await waitFor(() => { + expect(renderedRowIndexes(container)[0]).toBe(0); + }); + expect(container.querySelector("[data-slot='browse-row']")).toHaveTextContent("plugin-100"); + }); + + it("滚动容器与表头保持列表滚动样式契约", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ plugins: [{ name: "alpha" }] }), + } as unknown as Response); + const { container } = renderTab(); + await screen.findByText("alpha"); + const scroller = container.querySelector("[data-slot='browse-scroll']"); + expect(scroller).toHaveClass("max-h-[480px]", "overflow-y-auto", "overscroll-contain"); + // 表头必须在滚动容器内并 sticky,否则滚动条宽度只从行网格里扣,列会错位 + const header = container.querySelector("[data-slot='browse-header']"); + expect(scroller).toContainElement(header as HTMLElement); + expect(header).toHaveClass("sticky", "top-0", "z-sticky"); + expect(header).not.toHaveClass("z-10"); + }); }); diff --git a/src/components/profile-editor/__tests__/EnabledPluginsEditor.test.tsx b/src/components/profile-editor/__tests__/EnabledPluginsEditor.test.tsx index 0fe3d02..0a033c2 100644 --- a/src/components/profile-editor/__tests__/EnabledPluginsEditor.test.tsx +++ b/src/components/profile-editor/__tests__/EnabledPluginsEditor.test.tsx @@ -12,8 +12,27 @@ vi.mock("@tauri-apps/plugin-opener", () => ({ const originalFetch = globalThis.fetch; const fetchMock = vi.fn(); +// @tanstack/virtual-core 既用 offsetHeight 量滚动视口(getRect)也用它量每一行(measureElement), +// jsdom 恒为 0 会让 outerSize=0 导致 range 为 null(浏览 Tab 渲染 0 行)。按元素分派打桩, +// 避免行高等于视口高度这种退化几何。与 BrowseMarketplaceTab.test.tsx 保持一致。 +const SCROLL_VIEWPORT_HEIGHT = 400; +const STUBBED_ROW_HEIGHT = 120; +const originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"); +const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"); beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + get(this: HTMLElement) { + if (this.hasAttribute("data-index")) return STUBBED_ROW_HEIGHT; + if (this.dataset.slot === "browse-scroll") return SCROLL_VIEWPORT_HEIGHT; + return 0; + }, + }); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + configurable: true, + get: () => 800, + }); fetchMock.mockReset(); localStorage.clear(); Object.defineProperty(globalThis, "fetch", { @@ -29,6 +48,16 @@ afterEach(() => { writable: true, configurable: true, }); + if (originalOffsetHeight) { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", originalOffsetHeight); + } else { + Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight"); + } + if (originalOffsetWidth) { + Object.defineProperty(HTMLElement.prototype, "offsetWidth", originalOffsetWidth); + } else { + Reflect.deleteProperty(HTMLElement.prototype, "offsetWidth"); + } }); const SOURCES: MarketplaceSourceInput[] = [ diff --git a/src/components/profile-editor/__tests__/marketplace-plugin-row-utils.test.ts b/src/components/profile-editor/__tests__/marketplace-plugin-row-utils.test.ts new file mode 100644 index 0000000..037669e --- /dev/null +++ b/src/components/profile-editor/__tests__/marketplace-plugin-row-utils.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import type { MarketplacePluginEntry } from "../marketplace-catalog"; +import { + estimatePluginRowSize, + hasMarketplacePluginComponents, +} from "../marketplace-plugin-row-utils"; +import { emptyPluginCatalog, type PluginComponents } from "../plugin-install-counts"; + +const PLUGIN: MarketplacePluginEntry = { + pluginId: "alpha@claude-plugins-official", + marketplaceId: "claude-plugins-official", + description: "", + category: "", + authorName: "", + sourceType: "github", + homepage: "", + isOfficial: true, +}; + +function emptyComponents(): PluginComponents { + return { + commands: [], + agents: [], + skills: [], + hooks: [], + mcpServers: [], + lspServers: [], + }; +} + +describe("marketplace-plugin-row-utils", () => { + it("空 components 不额外估算组成行", () => { + const catalog = emptyPluginCatalog(); + catalog.entries[PLUGIN.pluginId] = { + installCount: null, + components: emptyComponents(), + }; + + expect(hasMarketplacePluginComponents(catalog.entries[PLUGIN.pluginId].components)).toBe(false); + expect(estimatePluginRowSize(PLUGIN, catalog)).toBe( + estimatePluginRowSize(PLUGIN, emptyPluginCatalog()), + ); + }); + + it("有组成且展开时把徽章与组件明细纳入估算", () => { + const catalog = emptyPluginCatalog(); + catalog.entries[PLUGIN.pluginId] = { + installCount: null, + components: { + ...emptyComponents(), + commands: ["format-code"], + skills: ["review-code", "explain-code"], + }, + }; + + const collapsed = estimatePluginRowSize(PLUGIN, catalog); + const expanded = estimatePluginRowSize(PLUGIN, catalog, true); + + expect(hasMarketplacePluginComponents(catalog.entries[PLUGIN.pluginId].components)).toBe(true); + expect(expanded).toBeGreaterThan(collapsed); + + const longPlugin = { ...PLUGIN, description: "a".repeat(300) }; + expect(estimatePluginRowSize(longPlugin, catalog, true)).toBeGreaterThan( + estimatePluginRowSize(longPlugin, catalog, false), + ); + }); + + it("没有布局宽度时按默认桌面布局估算徽章行", () => { + const catalog = emptyPluginCatalog(); + catalog.entries[PLUGIN.pluginId] = { + installCount: null, + components: { + commands: ["command"], + agents: ["agent"], + skills: ["skill"], + hooks: ["hook"], + mcpServers: ["mcp"], + lspServers: ["lsp"], + }, + }; + + expect(estimatePluginRowSize(PLUGIN, catalog)).toBe( + estimatePluginRowSize(PLUGIN, catalog, false, 800), + ); + }); + + it("按列表宽度增加窄列中的详情折行估算", () => { + const catalog = emptyPluginCatalog(); + const plugin = { ...PLUGIN, description: "a".repeat(100) }; + + const wide = estimatePluginRowSize(plugin, catalog, false, 800); + const narrow = estimatePluginRowSize(plugin, catalog, false, 240); + + expect(narrow).toBeGreaterThan(wide); + }); +}); diff --git a/src/components/profile-editor/marketplace-catalog.ts b/src/components/profile-editor/marketplace-catalog.ts index f85d4f1..a9ec7f5 100644 --- a/src/components/profile-editor/marketplace-catalog.ts +++ b/src/components/profile-editor/marketplace-catalog.ts @@ -23,6 +23,14 @@ export interface MarketplaceFetchInput { export const MARKETPLACE_CATALOG_CACHE_KEY = "code-manager-marketplace-plugin-cache:v1"; const CACHE_VERSION = 1; +// catalog 缓存里 Anthropic 第一方插件的作者名 +export const ANTHROPIC_AUTHOR = "Anthropic"; + +// 按作者归属把官方市场插件分为 Anthropic 第一方与合作伙伴;空作者视为合作伙伴 +export function getProviderAffiliation(plugin: MarketplacePluginEntry): "anthropic" | "partner" { + return plugin.authorName === ANTHROPIC_AUTHOR ? "anthropic" : "partner"; +} + interface CacheV1 { version: 1; byMarketplace: Record; diff --git a/src/components/profile-editor/marketplace-plugin-row-utils.ts b/src/components/profile-editor/marketplace-plugin-row-utils.ts new file mode 100644 index 0000000..2818cec --- /dev/null +++ b/src/components/profile-editor/marketplace-plugin-row-utils.ts @@ -0,0 +1,145 @@ +import { Bot, Braces, Plug, Sparkles, SquareTerminal, Webhook } from "lucide-react"; +import type { TranslationKey } from "../../i18n"; +import type { MarketplacePluginEntry } from "./marketplace-catalog"; +import type { PluginCatalog, PluginComponents } from "./plugin-install-counts"; + +export const DETAILS_COLLAPSE_THRESHOLD = 150; + +// 组成类别的展示顺序、图标与 i18n 文案 key +export const COMPONENT_KINDS = [ + { + key: "commands", + icon: SquareTerminal, + labelKey: "profileEditor.plugins.browse.componentCommands", + }, + { key: "agents", icon: Bot, labelKey: "profileEditor.plugins.browse.componentAgents" }, + { key: "skills", icon: Sparkles, labelKey: "profileEditor.plugins.browse.componentSkills" }, + { key: "hooks", icon: Webhook, labelKey: "profileEditor.plugins.browse.componentHooks" }, + { key: "mcpServers", icon: Plug, labelKey: "profileEditor.plugins.browse.componentMcpServers" }, + { key: "lspServers", icon: Braces, labelKey: "profileEditor.plugins.browse.componentLspServers" }, +] as const satisfies ReadonlyArray<{ + key: keyof PluginComponents; + icon: typeof Bot; + labelKey: TranslationKey; +}>; + +// 统一行组件和行高估算使用的详情文本,避免折行依据与实际文案分叉 +export function getMarketplacePluginDetails(plugin: MarketplacePluginEntry): string { + const subTitle = [plugin.authorName, plugin.marketplaceId].filter(Boolean).join(" · "); + return [plugin.description, subTitle].filter(Boolean).join(" · "); +} + +// catalog entry 即使没有任何组成,也会带一个空 components 对象;只有存在非空类别时才占行高 +export function hasMarketplacePluginComponents( + components: PluginComponents | undefined, +): components is PluginComponents { + return components !== undefined && COMPONENT_KINDS.some(({ key }) => components[key].length > 0); +} + +const ROW_BASE_SIZE = 45; +const ROW_DETAILS_GAP = 6; +const ROW_DETAILS_LINE_SIZE = 20; +const ROW_DETAILS_CHAR_WIDTH = 7; +const ROW_DETAILS_MAX_LINES = 3; +const ROW_COMPONENTS_GAP = 6; +const ROW_COMPONENT_LINE_SIZE = 20; +const ROW_COMPONENT_LINE_GAP = 4; +const ROW_COMPONENT_DETAIL_GAP = 6; +const ROW_COMPONENT_BADGE_WIDTH = 28; +const ROW_COMPONENT_BADGE_GAP = 12; +// 首次渲染还没有真实宽度时按默认桌面布局估算;6 个类别加展开按钮最多 7 项 +const ROW_COMPONENTS_PER_LINE_FALLBACK = COMPONENT_KINDS.length + 1; +const DEFAULT_ROW_CONTENT_WIDTH = 504; +const MIN_ROW_CONTENT_WIDTH = 160; + +function getRowContentWidth(containerWidth: number | undefined): number { + if (!containerWidth || containerWidth <= 0) return DEFAULT_ROW_CONTENT_WIDTH; + + const viewportWidth = typeof window === "undefined" ? containerWidth : window.innerWidth; + if (viewportWidth <= 640) { + return Math.max(MIN_ROW_CONTENT_WIDTH, containerWidth - 72); + } + + const actionWidth = Math.min(Math.max(viewportWidth * 0.16, 152), 190); + // 对应行 grid 的 px-3.5、索引列、安装数列、操作列和三个 gap + return Math.max(MIN_ROW_CONTENT_WIDTH, containerWidth - 28 - 32 - 36 - 104 - actionWidth); +} + +function getCharsPerLine(containerWidth: number | undefined): number { + return Math.max(20, Math.floor(getRowContentWidth(containerWidth) / ROW_DETAILS_CHAR_WIDTH)); +} + +function estimateLineCount(textLength: number, charsPerLine: number, maxLines?: number): number { + const lines = Math.max(1, Math.ceil(textLength / charsPerLine)); + return maxLines === undefined ? lines : Math.min(maxLines, lines); +} + +function estimateComponentBadgeLines( + components: PluginComponents, + contentWidth: number | undefined, +): number { + const badgeCount = COMPONENT_KINDS.filter(({ key }) => components[key].length > 0).length + 1; + const width = getRowContentWidth(contentWidth); + const badgesPerLine = Math.max( + 1, + Math.floor( + (width + ROW_COMPONENT_BADGE_GAP) / (ROW_COMPONENT_BADGE_WIDTH + ROW_COMPONENT_BADGE_GAP), + ), + ); + // 没有真实布局宽度时沿用当前桌面布局的默认基线 + const effectivePerLine = + contentWidth === undefined ? ROW_COMPONENTS_PER_LINE_FALLBACK : badgesPerLine; + return Math.max(1, Math.ceil(badgeCount / effectivePerLine)); +} + +function estimateComponentDetailLines(components: PluginComponents, charsPerLine: number): number { + return COMPONENT_KINDS.reduce((total, { key }) => { + const values = components[key]; + if (values.length === 0) return total; + const textLength = key.length + 2 + values.join(", ").length; + return total + estimateLineCount(textLength, charsPerLine); + }, 0); +} + +// 行高先按当前内容估算,再由 virtualizer.measureElement 用真实 DOM 高度校准 +export function estimatePluginRowSize( + plugin: MarketplacePluginEntry | undefined, + catalog: PluginCatalog, + expanded = false, + containerWidth?: number, +): number { + if (!plugin) return ROW_BASE_SIZE; + + const charsPerLine = getCharsPerLine(containerWidth); + let size = ROW_BASE_SIZE; + const details = getMarketplacePluginDetails(plugin); + if (details) { + const lines = estimateLineCount( + details.length, + charsPerLine, + details.length > DETAILS_COLLAPSE_THRESHOLD && !expanded ? ROW_DETAILS_MAX_LINES : undefined, + ); + size += ROW_DETAILS_GAP + lines * ROW_DETAILS_LINE_SIZE; + } + + const components = catalog.entries[plugin.pluginId]?.components; + if (hasMarketplacePluginComponents(components)) { + const badgeLines = estimateComponentBadgeLines(components, containerWidth); + size += + ROW_COMPONENTS_GAP + + badgeLines * ROW_COMPONENT_LINE_SIZE + + Math.max(0, badgeLines - 1) * ROW_COMPONENT_LINE_GAP; + + if (expanded) { + const detailLines = estimateComponentDetailLines(components, charsPerLine); + if (detailLines > 0) { + size += + ROW_COMPONENT_DETAIL_GAP + + detailLines * ROW_DETAILS_LINE_SIZE + + Math.max(0, detailLines - 1) * ROW_COMPONENT_LINE_GAP; + } + } + } + + return size; +} diff --git a/src/components/profile-editor/useEnabledPluginsState.ts b/src/components/profile-editor/useEnabledPluginsState.ts index 0d880d3..8c0b091 100644 --- a/src/components/profile-editor/useEnabledPluginsState.ts +++ b/src/components/profile-editor/useEnabledPluginsState.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { PluginDraft } from "./editor-utils"; import { readObject } from "./editor-utils"; @@ -93,20 +93,27 @@ export function useEnabledPluginsState({ if (!recordsEqual(next, sourceEntries)) onChange(next); }, [onChange, plugins, preservedEntries, sourceEntries]); - const addPlugin = useCallback( - (pluginId: string, enabled: boolean): boolean => { - // 同步检查当前 plugins 状态,避免在 setPlugins updater 内读取外部变量的竞态问题 - if (plugins.some((plugin) => plugin.pluginId === pluginId)) { - return false; - } - setPlugins((current) => [ - ...current, - { id: `plugin:${pluginId}`, pluginId, enabled, committed: true }, - ]); - return true; - }, - [plugins], - ); + // ref 镜像 plugins:addPlugin 不依赖 [plugins],引用全程稳定,供 memo 行组件与父级 useCallback 复用。 + // 在 effect 内同步而非渲染阶段赋值:并发渲染下被丢弃的渲染会残留未提交状态, + // 让 addPlugin 对已提交 UI 里并不存在的插件返回 false,「添加并启用」变成静默 no-op。 + const pluginsRef = useRef(plugins); + useEffect(() => { + pluginsRef.current = plugins; + }, [plugins]); + + const addPlugin = useCallback((pluginId: string, enabled: boolean): boolean => { + // 同步检查已提交状态,让调用方能立刻拿到「是否新增」的结论 + if (pluginsRef.current.some((plugin) => plugin.pluginId === pluginId)) { + return false; + } + // updater 内再判一次:current 才是权威值,保证并发下不会重复追加 + setPlugins((current) => + current.some((plugin) => plugin.pluginId === pluginId) + ? current + : [...current, { id: `plugin:${pluginId}`, pluginId, enabled, committed: true }], + ); + return true; + }, []); const togglePlugin = useCallback((pluginId: string) => { setPlugins((current) => diff --git a/src/i18n.ts b/src/i18n.ts index b9a3cdf..d33d4c2 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -374,6 +374,8 @@ const translations = { "profiles.editor.fields.provider": "供应商", "profiles.editor.fields.authToken": "认证密钥", "profiles.editor.fields.authTokenEnv": "ANTHROPIC_AUTH_TOKEN", + "profiles.editor.fields.authApiKey": "API 密钥", + "profiles.editor.fields.authApiKeyEnv": "ANTHROPIC_API_KEY", "profiles.editor.fields.baseUrl": "API 地址", "profiles.editor.fields.baseUrlEnv": "ANTHROPIC_BASE_URL", "profiles.editor.placeholders.name": "例如:OpenRouter-日常开发", @@ -2086,6 +2088,8 @@ const translations = { "profiles.editor.fields.provider": "Provider", "profiles.editor.fields.authToken": "Auth Token", "profiles.editor.fields.authTokenEnv": "ANTHROPIC_AUTH_TOKEN", + "profiles.editor.fields.authApiKey": "API Key", + "profiles.editor.fields.authApiKeyEnv": "ANTHROPIC_API_KEY", "profiles.editor.fields.baseUrl": "API Base URL", "profiles.editor.fields.baseUrlEnv": "ANTHROPIC_BASE_URL", "profiles.editor.placeholders.name": "e.g. OpenRouter-Daily dev", diff --git a/src/index.css b/src/index.css index d0e386c..e5315bd 100644 --- a/src/index.css +++ b/src/index.css @@ -139,6 +139,7 @@ --shadow-panel: 0 1px 2px oklch(0.2 0.04 255 / 10%), 0 12px 34px oklch(0.44 0.12 245 / 8%); --shadow-floating: 0 18px 45px oklch(0.18 0.04 255 / 18%), 0 4px 14px oklch(0.44 0.12 245 / 9%); --shadow-toolbar: 0 1px 0 oklch(0.2 0.04 255 / 10%), 0 10px 22px oklch(0.44 0.12 245 / 6%); + --z-index-sticky: 10; } @layer base {