mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
feat(apps): add tool uninstall with a confirmed plan and manual commands (#8165)
* feat: add uninstall support for tools, including UI and backend integration * feat: add manual uninstall support with UI integration and localization * fix(usage): honor Codex thread model settings (#8176) * fix(usage): stop treating empty OpenRouter credits as expired (#8175) * fix(settings): use confirmed directories for unrelated autosaves (#8023) * fix(settings): keep directory drafts out of autosaves * fix(settings): reconcile directory display after autosave * feat: enhance tool uninstall process with new commands and UI updates - Added localized strings for manual uninstall hints and commands in Japanese, Traditional Chinese, and Simplified Chinese. - Introduced a new API method `prepareToolUninstall` to generate uninstall plans. - Created a `ToolUninstallConfirmDialog` component to confirm uninstallation and display relevant commands. - Implemented uninstall command methods for various package managers including npm, pnpm, bun, and Homebrew. - Updated tests to cover new uninstall functionality and ensure proper command visibility and error handling. * fix(installer): don't recreate a deleted desktop shortcut on MSI upgrade A major upgrade removes the old product and installs the new one from scratch, and the desktop shortcut component was unconditional, so every update put the shortcut back even after the user had deleted it. Look for the existing shortcut during AppSearch (before the old version is removed) and only install the desktop shortcut on a fresh install or when it is still present on an upgrade. * fix(deeplink): stop losing links on cold start and tighten import checks Deep links that arrived before the frontend was listening were dropped: on Windows/Linux the plugin emits the startup URL before our handler is registered, and Tauri events fired before the JS listener exists (cold start, window rebuilt in lightweight mode) go nowhere. - Keep the parsed link (or parse error) in a backend pending slot with a monotonic id and emit `deeplink-pending`. The dialog peeks on mount and on every notification, and acks by id only after the link is shown, so an interrupted read never loses it and a stale ack never clears a newer link. - Read `get_current()` after registering the URL handler so the startup URL is handled on Windows/Linux. - Drop our own `RunEvent::Opened` handling on macOS; the deep-link plugin already emits it, which made links open twice. - Process pending links serially in the dialog (rerun on notifications that arrive mid-processing) so a late read of an older link can't replace a newer one, including during config merge. The effect no longer depends on `t`, so the startup language sync can't interrupt it. - Reject `configUrl` up front instead of failing after the user confirms. - Accept `mcode` for prompts and `pi`/`mcode` for MCP apps. - Warn on the confirm dialog when the provider will be switched to right away (`enabled=true`), and refresh the tray menu after a provider import. * feat(deeplink): enable imported usage scripts by default A provider link that carries a usage script now enables usage query unless it says `usageEnabled=false`. Site owners attach the script so users see their balance; making them switch it on again after import was an extra step with little safety benefit: the script runs in the QuickJS sandbox, sends a single request, and deep-link scripts have no template type, so that request must be same-origin HTTPS with the base URL. Links without a script still import usage fields disabled. The confirmation badge follows the same rule as the backend, and the script body is still shown in full before the user imports. * fix(deeplink): keep a Codex link's provider config instead of rebuilding a fixed template A Codex provider link could carry a full config.toml, but import only pulled the key, endpoint and model out of it and rebuilt a fixed template, silently dropping the provider's auth mode, headers, websocket support and model catalog URL. When a link carries a config, keep the parts that belong to the provider, using the same field lists the live projection writes on switch: - from the selected [model_providers.*] table: connection and auth fields (wire_api, requires_openai_auth, http_headers, query_params, supports_websockets, retries/timeouts, capabilities, ...); the route is always renamed to `custom` - model_catalog_url only on the same origin as the endpoint, since Codex sends the provider's credentials when fetching it - top-level key and provider-exclusive fields (model, reasoning effort, web_search, context window, subagent model) Global settings ([features], [tools], MCP, approval_policy, notify, local file paths) are not imported. Links whose provider table makes Codex run a command or read local credentials (auth, aws, gateway_oauth, env_http_headers) are rejected; env_key is dropped in favor of the link's own key, and an env_key-only link is rejected. URL params still take priority, and a table's bearer token is reset to the final key. Links without a config keep the old template byte for byte; a new golden snapshot locks the shape of config-carrying imports. The confirm dialog notes that only provider settings are imported, and the manual documents the rules (and that configUrl is rejected). Refs #7438, #7942, #5510 * fix(proxy): bump the Claude Code haiku alias to claude-haiku-5-5 Routing mode writes stable role aliases into settings.json, and Claude Code looks the alias up in its own model table to decide how to send thinking. claude-haiku-4-5 is listed there as a model whose thinking can be turned off and that lacks adaptive thinking, so haiku-tier requests went out with thinking disabled and upstreams that require thinking rejected them. claude-haiku-5-5 rejects disabled thinking and uses adaptive thinking, matching what the sonnet and opus aliases already send to the same upstreams. The proxy still maps by the "haiku" role keyword, so routing is unaffected; the new alias is written the next time the contract is applied. Fixes #8118 * fix(grokbuild): point session_summary at the active provider and keep a link's model profile Session titles: when `models.session_summary` is unset, Grok Build (verified on 1.0.50) generates titles with the built-in `grok-4.6` model but sends the request to the default model table's endpoint, so third-party upstreams reject it and title generation fails. Switching never wrote this key, so fixing the row templates alone would not reach live. The Grok switch patch now makes session_summary follow the provider when it is unset or names a table CC Switch wrote (the target, the write record, or a table retired in this switch): it points at the target table, or is removed when switching to the official account. A user's own choice (a built-in model or their own table) is left alone. Deep links: a Grok Build link carrying a config.toml no longer gets rebuilt into a fixed template that dropped the profile's display name, description, API backend and context window. The table named by `[models] default` is kept through an allowlist of Grok 1.0.50 profile fields; the endpoint, key and model come from the link parameters. Links whose table uses auth_provider, env_http_headers or mtls_cert_dir are rejected, and env_key is still dropped. Only one model table is imported, since a provider owns one table in live. Links without a config keep the old template byte for byte. Fixes #7003 Refs #6457 * chore(presets): point OpenModel sign-up links at the cc-switch referral URL Replace the campaign page link with https://www.openmodel.ai?ref=cc-switch in every OpenModel preset's apiKeyUrl (Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw, Hermes, Pi; MiniMax Code reuses the Pi preset) and in both links of the four README sponsor rows. websiteUrl stays on the plain homepage, and the "cc-switch" promo code already in the READMEs is unchanged. * chore(presets): show YYLX as 鱼鱼连线 in Chinese Give the YYLX presets a nameKey so the picker shows the site's Chinese name, 鱼鱼连线 (魚魚連線 in zh-TW), while en and ja keep "YYLX". All nine preset files get the key; MiniMax Code inherits it from the Pi preset. The preset name and the inner settingsConfig.name stay "YYLX": the former keys the search alias table and the latter is the provider name written into client configs. * feat(deeplink): let links declare the API format, import Pi providers, and leave activation to the user - Add a single `apiFormat` parameter (anthropic / openai_chat / openai_responses / gemini_native) that maps to each app's own field: meta.apiFormat for Claude Code, Codex and Grok Build (non-native formats need local routing), `npm` for OpenCode, `api` for OpenClaw and Pi, `api_mode` for Hermes. Unsupported combinations are rejected while parsing; links without it keep the previous defaults byte for byte. - Support Pi provider links. They write a models.json node and must carry a model. - The confirmation dialog now has a switch for "switch after import" (providers), "add to the app config" (additive apps, on by default) and "enable after import" (prompts). The link's `enabled` only sets its initial state. - Turning the switch off really only saves the row: a new `ProviderService::add_without_switching` skips the first-provider path in `add` that would otherwise write the client config. The deep-link golden rows now have is_current false accordingly. - Reject link values that additive apps would expand: Pi keys starting with `!` and `$NAME` / `${NAME}`, OpenCode `{env:}` / `{file:}`, and `${...}` for OpenClaw and Hermes. These would run a command or send local secrets and files to the link's endpoint. Checked when parsing, when merging `config`, and when building the provider. - Update the deep-link manual in zh/en/ja. * docs(deeplink): add a Chinese integration guide for site owners * feat(providers): upload a custom provider icon The icon picker gets an "Upload Image" button (PNG, JPG, WebP or SVG, up to 2 MB and 4096 px). The image is redrawn on a canvas into a 96x96 PNG and stored as a data URI in the existing `icon` column, so it travels with the row through cloud sync, duplication and profiles. No new column, no files on disk, nothing keyed by provider id (ids are shared across apps). Opaque square images fill the icon box; anything else is padded to the size of a built-in transparent logo. Uploading only changes the form; nothing is written until Save. The original file never reaches the database: SVG is loaded through <img> (no scripts, no external requests) and rasterized, and re-encoding drops EXIF. Custom icons are only ever rendered with <img>; remote URLs are never loaded as icons. Validation at every way in: - Saving a provider or a universal provider, and syncing a universal provider to its apps, fully decodes the PNG with the `png` crate (already in the tree via `image`) under a 1 MiB decode limit and caps it at 64 KiB and 256x256. Other strings still pass as icon names so old rows stay editable. - SQL import and cloud sync bypass saving, so the staging database drops invalid custom icons from both `providers` and the universal provider JSON before it replaces the main one. The rest of the backup imports normally. - Rendering checks the length and reads the PNG signature, IHDR length and dimensions from the header before showing an image, and falls back to initials if it fails to decode. Browsers sniff image formats, so without the signature check a disguised GIF could slip past the size limit. Also: - Uploads still being processed are dropped once the user removes the icon, picks a built-in one, uploads again or closes the picker. - Aggregation mode no longer derives model key prefixes from a custom icon; it falls back to the provider name. - The deep-link `icon` parameter only accepts built-in icon names; other values are dropped instead of failing the link, so links cannot put a data URI straight into the database. Refs #8117 * fix(usage): stop treating an empty Novita balance as an expired plan Novita marked the key invalid whenever availableBalance was zero or negative, so the card and tray showed "plan expired" for a key that still works. Keep a parsed balance valid and let the UI show the existing "balance used up" line instead, the same as OpenRouter in #8175. A missing or malformed availableBalance now returns an error instead of being read as a zero balance. * feat(deeplink): let provider links bring their own icon via iconUrl A provider link can now carry `iconUrl`, an https address of the site's own logo. The confirmation dialog downloads it as soon as it opens and shows it at the top; importing stores it as the card's custom icon. No extra confirmation step. Any failure is silent: the card falls back to the built-in `icon` name, or to the name's initials. The image goes through the same path as a local upload: the backend returns a data URI, the frontend redraws it on a canvas into a 96x96 PNG, and the provider save validation checks it again. The address itself is never stored or rendered, and the import command never downloads. The download (`deeplink/icon_fetch.rs`): - Parsing drops addresses that are not https, use an IP or a non-default port, carry credentials or exceed 2048 characters; the rest of the link still imports. - Every resolved address must be public, and the connection is pinned to them so a second DNS answer cannot point it elsewhere. Redirects are followed by hand, up to 3, and each hop is checked again. - No system proxy, no cookies, no Referer, 5 s overall timeout, body read as a stream and capped at 256 KB. - Content-Type must be PNG, JPEG, WebP or SVG and match the file's magic bytes; bitmap dimensions are read from the header and capped at 4096. - 198.18.0.0/15 is allowed on purpose: Clash and Surge fake-ip mode resolve every domain into it. The trade-off is written next to `is_public_ipv4`. - Errors are logged without the URL, which may carry query tokens. Closes #8117 * docs(deeplink): rework the link generator and document iconUrl deplink.html becomes a generator for site owners instead of a test page: a sidebar layout with light and dark themes, tabs for provider, MCP, prompt and Skill links, per-app `apiFormat` choices, usage script templates, and examples that follow the v4.0.10 rules (Pi providers, the activation toggle, `apiFormat`). iconUrl: - The generator gets an `iconUrl` field and checks it with the same shape rules as the app (https, a domain, default port, no credentials), so a link is never generated with an address CC Switch would drop. Built-in `icon` names are checked against the same whitelist as the parser. - The parser marks `icon` and `iconUrl` values that would be dropped. - New example and FAQ entry for a site's own icon. - The integration guide adds `iconUrl` to the Node.js sample and a debugging step pointing at the log line. The zh and ja manuals list `iconUrl` and say what `icon` accepts. * feat(usage): add a Sub2API usage query template Sub2API sites expose GET /v1/usage authenticated with the API key, so the template only needs the provider's key and base URL, like the general one. The script accepts a base URL with or without /v1, shows the plan name, and reports used/total only when the quota has a limit, so unlimited subscriptions (remaining -1) no longer show a negative balance. The modal's two hand-written template-type unions are replaced with the shared TemplateType so new templates are not silently dropped on save. * docs(usage): document the Sub2API template and add it to the link generator The user manual (zh/en/ja) gains a Sub2API template section, and the link generator and the site owner guide gain a Sub2API example. All of them use the same script as the in-app template. * docs(deeplink): show site owners how to wire the import button The guide explained how to build a link but not how to put it on a page. Add a section on fetching the link from the backend when the button is clicked (so the key never sits in the page source), the button markup, and what to show when CC Switch is not installed. Add a fast path at the top and fold the sections most sites can skip. The link generator folds the Base64 tool and the MCP, prompt and Skill examples, opens them when reached from the sidebar or an anchor, and gains an FAQ entry pointing to the new section. * docs: point the deep link generator to ccswitch.io and link the site owner guide The manual linked the generator at farion1231.github.io, but GitHub Pages was never enabled for the repository, so the link returned 404. The page is now hosted on the website at ccswitch.io/deplink.html; the manual (zh/en/ja), the four READMEs and the site owner guide point there. The generator inlines its favicon and logo so the same file works both in the repository and on the website. Manual 5.3 gains a pointer to the site owner guide, and its usage steps list the Skill repository type. * feat(i18n): lead the connection mode help with each mode's strengths Direct now says CC Switch stays out of the request path for the most native experience, Routing lists hot-switching providers, failover and format conversion, and Aggregation says models from several providers can be switched seamlessly inside the client. The no-failover caveat for Aggregation stays in its dedicated notes. * feat(usage): show first-token time in stats and estimate it without routing The routing service has always recorded first-token time, but the usage page only surfaced it in a hover tip and the request detail panel. Provider and model stats gain a First token column with the median over successful streaming requests through the routing service. The median keeps an occasional slow request (queueing, cold start) from pulling the number up. The request log table gains a First token column after Speed. Without routing, first-token time is now derived from session logs and shown with ≈: - Claude Code: when a reply starts with a thinking block, the block's write time minus thinkingDurationMs (timed from content_block_start to content_block_stop) gives the moment output began. - Codex: newer rollouts record started_at_ms on item_completed; the earliest reasoning or agent message item of a request marks its start. Replies that open with text or a tool call yield no estimate. A real log replay derived 9,586 Claude Code and 10,406 recent Codex values, with medians of 1.95s and 2.69s (routed Codex: 2.6s). The estimate is stored in first_token_ms, so exact and estimated values are now told apart by data_source instead of a NULL first token. The estimated speed still divides by the whole duration, since only some rows have a first token. Rows imported before this change are not backfilled. * docs(deeplink): note that New API and Sub2API ship an import button Site owners running New API or Sub2API already have an "Import to CC Switch" button in the key list. Say so up front, and point out that New API's link carries no usage script, so users see no balance after import. * fix(codex): move sessions from the retired cc-switch-official bucket back to openai From v3.17 to v3.20, using the official account through local routing wrote model_provider = "cc-switch-official", so those sessions were recorded under that provider id. v4.0.0 stopped writing the table and removes a leftover one, but Codex resumes a thread with the provider it was recorded under, so opening those sessions now fails with "Model provider `cc-switch-official` not found" (#7948). No earlier migration covered this id: both existing ones predate it. On startup, sessions recorded under cc-switch-official are moved to the built-in openai bucket, in the state DB and in each rollout's session_meta line (compressed rollouts included). config.toml is not touched. openai is built in, so it always resolves. These sessions used the official login and their table was named "OpenAI", so Codex already treated them as OpenAI (remote compaction, web search); moving them changes the bucket, not the behavior. The target is openai regardless of the unified session history setting. The migration runs before the unify migration, so with the setting on they continue into custom through the normal path and are recorded in its restore ledger. Moving them straight to custom would leave them there after the setting is turned off, where custom becomes the dormant table pointing at the local proxy. The migration is skipped without recording completion when config.toml or a profile still selects cc-switch-official (a hand-restored table in use), or when a state DB cannot be locked (WSL paths from Windows): rewriting only the rollouts would not make the sessions resumable. Completion is recorded per Codex config directory. Backups go to the existing session group migration backup folder, whose description no longer says third-party. Refs #7948 * docs(deeplink): add English and Japanese site owner guides Translate the site owner integration guide into English and Japanese, quoting UI labels from the app's own locale files and using each language's Sub2API template fallback message. The three versions link to each other. Manual 5.3 (en/ja) and the English, German and Japanese READMEs now point to the guide in their own language. * fix(usage): left-align every usage table column and rebalance request log widths Numeric columns were right-aligned while text columns were left-aligned, so headers and cells followed two rules on one page. All columns in the request log, provider, model and pricing tables now align to the start; the row actions column in the pricing table stays at the end. Also give first/last cells the same 8px inset as their headers, and lower the provider/model share of the request log from 40% to 32% so the spare width reaches the numeric columns instead of the text ones. * feat(providers): keep uploaded icons in a named "My Icons" library Uploading an icon used to attach the image to the one provider being edited, shown as a lone "Custom icon" strip above the built-in grid. It had no name, could not be searched and had to be uploaded again for every provider that wanted it. The icon picker now has two groups, filtered by the same search box: - My Icons: the first tile uploads an image. After it is resized, a dialog asks for a name (the file name by default); "Save and Use" stores it in the library and selects it. Library icons show their names, can be picked by any provider in any app, and can be renamed or deleted from the buttons that appear on hover. Uploading an image that is already in the library reuses the existing entry. A current custom icon that is not in the library (for example one from an import link) shows as "Current icon" and can be saved, named after the provider. - Built-in Icons, as before. Clicking an icon now selects it and closes the picker, so the "Done" button is gone; "Clear icon" next to the search box removes the icon. The library lives in the settings table under `custom_icons`, which is already cloud-synced. Providers still store the image itself, not a library id, so renaming or deleting a library entry never changes a provider that uses it, and a provider shows its icon on a device whose library differs. Adding to the library decodes the PNG fully like saving a provider does, caps the library at 100 icons and names at 64 characters. SQL import and cloud sync drop library entries whose image is invalid, alongside the existing provider and universal provider cleanup; unknown fields on the remaining entries are kept. The naming dialog stops its submit event from propagating: React events bubble through portals, so without it saving a name would also submit the provider form the picker is rendered in. The user manual's icon section (2.3, zh/en/ja) describes the new flow. * docs(manual): describe My Icons in the add-provider icon section Section 2.1 still said the icon area lets you pick a preset icon and customize its color. There has been no color control for a while (the color comes from the built-in icon's metadata), and icons can now be uploaded to My Icons. Point 2.1 at the full description in 2.3, and drop "and color" from the icon row of the 2.3 field table. * chore(release): v4.0.10 * docs(release): add v4.0.10 release notes * fix(ui): panel flicker (#8201) * fix(ui): prevent full-page panel flicker * fix(workspace): cancel pending note reads before deletion * feat: add uninstall support for tools, including UI and backend integration * feat: add manual uninstall support with UI integration and localization * feat: enhance tool uninstall process with new commands and UI updates - Added localized strings for manual uninstall hints and commands in Japanese, Traditional Chinese, and Simplified Chinese. - Introduced a new API method `prepareToolUninstall` to generate uninstall plans. - Created a `ToolUninstallConfirmDialog` component to confirm uninstallation and display relevant commands. - Implemented uninstall command methods for various package managers including npm, pnpm, bun, and Homebrew. - Updated tests to cover new uninstall functionality and ensure proper command visibility and error handling. * feat: enhance npm uninstall command for Windows with path compatibility and add related tests * feat: update uninstall command for Windows to ensure path compatibility in tests * fix(uninstall): resolve WSL installs in the version check's shell With no WSL shell preference, uninstall ran `sh -c`, which skips .bashrc/.zshrc/$ENV, while version detection runs `$SHELL` with -lic, falling back to -lc and -c. Tools initialised there (nvm and the like) were shown as installed but could not be uninstalled, or the probe picked another copy on PATH. Share the auto-mode flag order with the version check. The read-only probe retries the flags in the same order and reports the flag that found the tool; removal runs with exactly that flag and never retries. uninstall runs the very process the re-derived plan describes. --------- Co-authored-by: Hulk <happyhls@gmail.com> Co-authored-by: Jason <farion1231@gmail.com> Co-authored-by: NestorRay <63111566+NestorRay@users.noreply.github.com>
This commit is contained in:
16 files changed
+2965
-50
No files matched your search
@@ -13,6 +13,9 @@ use tauri::AppHandle;
|
||||
use tauri::State;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
#[path = "tool_uninstall.rs"]
|
||||
mod tool_uninstall;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
@@ -247,18 +250,66 @@ impl ToolLifecycleCoordinator {
|
||||
static TOOL_LIFECYCLE: Lazy<ToolLifecycleCoordinator> =
|
||||
Lazy::new(ToolLifecycleCoordinator::default);
|
||||
|
||||
/// Display-only plan. Execution regenerates it under the lifecycle lock and rejects changes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ToolUninstallPlan {
|
||||
tool: String,
|
||||
path: String,
|
||||
real_path: String,
|
||||
command: String,
|
||||
native: bool,
|
||||
wsl_distro: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn prepare_tool_uninstall(
|
||||
tool: String,
|
||||
wsl_shell_by_tool: Option<HashMap<String, WslShellPreferenceInput>>,
|
||||
) -> Result<ToolUninstallPlan, String> {
|
||||
if !VALID_TOOLS.contains(&tool.as_str()) {
|
||||
return Err(format!("Unsupported tool action target: {tool}"));
|
||||
}
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let pref = wsl_shell_by_tool
|
||||
.as_ref()
|
||||
.and_then(|prefs| prefs.get(&tool));
|
||||
tool_uninstall::plan(&tool, pref)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("uninstall plan task join error: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn run_tool_lifecycle_action(
|
||||
tools: Vec<String>,
|
||||
action: String,
|
||||
wsl_shell_by_tool: Option<HashMap<String, WslShellPreferenceInput>>,
|
||||
uninstall_plan: Option<ToolUninstallPlan>,
|
||||
) -> Result<(), String> {
|
||||
let action = ToolLifecycleAction::from_str(&action)?;
|
||||
let requested = normalize_requested_tools(&tools);
|
||||
if requested.is_empty() {
|
||||
return Err("No supported tools selected".to_string());
|
||||
}
|
||||
|
||||
if action == "uninstall" {
|
||||
let plan = uninstall_plan.ok_or("Preview the uninstall plan before confirming.")?;
|
||||
if requested.len() != 1 || requested[0] != plan.tool {
|
||||
return Err("The uninstall plan does not match the selected tool.".into());
|
||||
}
|
||||
return TOOL_LIFECYCLE
|
||||
.run(requested, move |tools| {
|
||||
for tool in tools {
|
||||
let pref = wsl_shell_by_tool
|
||||
.as_ref()
|
||||
.and_then(|prefs| prefs.get(*tool));
|
||||
tool_uninstall::uninstall(tool, pref, &plan)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
}
|
||||
let action = ToolLifecycleAction::from_str(&action)?;
|
||||
|
||||
let label = match action {
|
||||
ToolLifecycleAction::Install => "tool_install",
|
||||
ToolLifecycleAction::Update => "tool_update",
|
||||
@@ -1377,6 +1428,29 @@ fn version_probe_payload(tool: &str) -> String {
|
||||
format!("echo {VERSION_PROBE_SENTINEL}; {tool} --version")
|
||||
}
|
||||
|
||||
/// WSL 未指定 shell 时依次尝试的 flag:交互式初始化(nvm 等写在 rc 里)优先。
|
||||
/// 卸载探测(`tool_uninstall`)按同一顺序选 flag,否则两边可能命中不同的安装。
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const WSL_AUTO_SHELL_FLAGS: [&str; 3] = ["-lic", "-lc", "-c"];
|
||||
|
||||
/// WSL 未指定 shell 时的版本检测命令(由外层 `sh -c` 执行):在 `$SHELL` 里按
|
||||
/// `WSL_AUTO_SHELL_FLAGS` 依次重试,或只用调用方已校验过的 `flag`。
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn wsl_auto_shell_command(payload: &str, flag: Option<&str>) -> String {
|
||||
let flags = flag.map_or(WSL_AUTO_SHELL_FLAGS.to_vec(), |flag| vec![flag]);
|
||||
let last = flags.len() - 1;
|
||||
flags
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, flag)| {
|
||||
// 只有最后一次尝试保留 stderr,供报错展示
|
||||
let quiet = if i < last { " 2>/dev/null" } else { "" };
|
||||
format!("\"${{SHELL:-sh}}\" {flag} '{payload}'{quiet}")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" || ")
|
||||
}
|
||||
|
||||
/// 取哨兵**最后一次**出现之后的输出。
|
||||
///
|
||||
/// 取最后一次:兜底链 `-lic || -lc || -c` 失败重试时会多次打印哨兵。
|
||||
@@ -1647,18 +1721,12 @@ fn try_get_version_wsl(
|
||||
|
||||
(shell.to_string(), flag, version_probe_payload(tool))
|
||||
} else {
|
||||
let payload = version_probe_payload(tool);
|
||||
let cmd = if let Some(flag) = force_shell_flag {
|
||||
if let Some(flag) = force_shell_flag {
|
||||
if !is_valid_shell_flag(flag) {
|
||||
return ShellProbe::NotFound(format!("[WSL:{distro}] invalid shell flag: {flag}"));
|
||||
}
|
||||
format!("\"${{SHELL:-sh}}\" {flag} '{payload}'")
|
||||
} else {
|
||||
// 兜底:自动尝试 -lic, -lc, -c
|
||||
format!(
|
||||
"\"${{SHELL:-sh}}\" -lic '{payload}' 2>/dev/null || \"${{SHELL:-sh}}\" -lc '{payload}' 2>/dev/null || \"${{SHELL:-sh}}\" -c '{payload}'"
|
||||
)
|
||||
};
|
||||
}
|
||||
let cmd = wsl_auto_shell_command(&version_probe_payload(tool), force_shell_flag);
|
||||
|
||||
("sh".to_string(), "-c", cmd)
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1711,6 +1711,7 @@ pub fn run() {
|
||||
commands::launch_session_terminal,
|
||||
commands::get_tool_versions,
|
||||
commands::run_tool_lifecycle_action,
|
||||
commands::prepare_tool_uninstall,
|
||||
commands::probe_tool_installations,
|
||||
commands::list_tool_installations,
|
||||
// Provider terminal
|
||||
|
||||
@@ -84,10 +84,6 @@ export function DeepLinkImportDialog() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activateAfterImport, setActivateAfterImport] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (request) setActivateAfterImport(initialActivation(request));
|
||||
}, [request]);
|
||||
|
||||
// 链接带 iconUrl 时,确认框一打开就下载图标并直接预览,不再单独询问;
|
||||
// 导入时等这次下载结束(最多几秒),拿到的图标随请求交给后端,后端保存时再完整校验
|
||||
const [linkIcon, setLinkIcon] = useState<string>();
|
||||
@@ -168,6 +164,8 @@ export function DeepLinkImportDialog() {
|
||||
}
|
||||
}
|
||||
if (disposed) return false;
|
||||
// 与请求一起初始化,避免弹窗先显示旧开关状态,再由 effect 更新。
|
||||
setActivateAfterImport(initialActivation(next));
|
||||
setRequest(next);
|
||||
setIsOpen(true);
|
||||
return true;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { providersApi } from "@/lib/api/providers";
|
||||
@@ -49,6 +50,8 @@ import { APP_DISPLAY_NAME, AppGlyph } from "@/components/shell/AppGlyph";
|
||||
import { ToolUpgradeConfirmDialog } from "@/components/settings/ToolUpgradeConfirmDialog";
|
||||
import { ToolInstallRow } from "@/components/settings/ToolInstallRow";
|
||||
import { ToolErrorMessage } from "@/components/settings/ToolErrorMessage";
|
||||
import { ToolUninstallConfirmDialog } from "./ToolUninstallConfirmDialog";
|
||||
import { ManualUninstallDialog } from "./ManualUninstallDialog";
|
||||
import {
|
||||
ENV_BADGE_CONFIG,
|
||||
ONE_CLICK_INSTALL_COMMANDS,
|
||||
@@ -98,7 +101,7 @@ function formatCheckedAt(timestamp: number, locale: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 「应用」页(v7):每个应用一行 = 安装状态 / 版本 + 安装 / 升级 + 在侧栏显示的开关。
|
||||
* 「应用」页(v7):每个应用一行 = 安装状态 / 版本 + 安装 / 升级 / 卸载 + 在侧栏显示的开关。
|
||||
* 隐藏一个应用只影响界面(侧栏、MCP / Skills 的列、提示词和会话的下拉),配置照常同步。
|
||||
*/
|
||||
export function AppsPage() {
|
||||
@@ -106,6 +109,10 @@ export function AppsPage() {
|
||||
const tools = useToolManagement();
|
||||
const { settings, updateSettings, autoSaveSettings } = useSettings();
|
||||
const [showCommands, setShowCommands] = useState(false);
|
||||
const [showUninstallCommands, setShowUninstallCommands] = useState(false);
|
||||
const [pendingUninstall, setPendingUninstall] = useState<ToolName | null>(
|
||||
null,
|
||||
);
|
||||
const [expanded, setExpanded] = useState<Partial<Record<ToolName, boolean>>>(
|
||||
{},
|
||||
);
|
||||
@@ -242,6 +249,12 @@ export function AppsPage() {
|
||||
<DropdownMenuItem onSelect={() => setShowCommands(true)}>
|
||||
{t("appsPage.manualCommands")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-red-500 focus:text-red-500 dark:text-red-400 dark:focus:text-red-400"
|
||||
onSelect={() => setShowUninstallCommands(true)}
|
||||
>
|
||||
{t("settings.manualUninstallCommands")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
@@ -258,7 +271,7 @@ export function AppsPage() {
|
||||
<span className="w-[168px] text-end">
|
||||
{t("appsPage.columnVersion")}
|
||||
</span>
|
||||
<span className="w-[104px]" />
|
||||
<span className="w-[200px]" />
|
||||
<span className="flex w-[56px] items-center justify-end gap-0.5">
|
||||
{t("appsPage.columnVisible")}
|
||||
<HelpTip title={t("appsPage.columnVisible")} align="end">
|
||||
@@ -297,6 +310,7 @@ export function AppsPage() {
|
||||
key={row.tool}
|
||||
tool={row.tool}
|
||||
tools={tools}
|
||||
onUninstall={() => setPendingUninstall(row.tool)}
|
||||
expanded={expanded[row.tool] ?? false}
|
||||
onToggleExpanded={() =>
|
||||
setExpanded((prev) => ({
|
||||
@@ -345,6 +359,30 @@ export function AppsPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ManualUninstallDialog
|
||||
open={showUninstallCommands}
|
||||
onOpenChange={setShowUninstallCommands}
|
||||
toolVersionByName={tools.toolVersionByName}
|
||||
/>
|
||||
|
||||
{pendingUninstall && (
|
||||
<ToolUninstallConfirmDialog
|
||||
tool={pendingUninstall}
|
||||
wslShellByTool={tools.wslShellByTool}
|
||||
onConfirm={(uninstallPlan) => {
|
||||
void tools.handleRunToolAction([pendingUninstall], "uninstall", {
|
||||
uninstallPlan,
|
||||
});
|
||||
setPendingUninstall(null);
|
||||
}}
|
||||
onCancel={() => setPendingUninstall(null)}
|
||||
onManualUninstall={() => {
|
||||
setPendingUninstall(null);
|
||||
setShowUninstallCommands(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ToolUpgradeConfirmDialog
|
||||
isOpen={tools.pendingUpgrade !== null}
|
||||
plans={tools.pendingUpgrade?.plans ?? []}
|
||||
@@ -388,7 +426,9 @@ function AppRow({
|
||||
)}
|
||||
</div>
|
||||
<div className="w-[168px] shrink-0 text-end">{version}</div>
|
||||
<div className="flex w-[104px] shrink-0 justify-end">{action}</div>
|
||||
<div className="flex w-[200px] shrink-0 flex-wrap justify-end gap-2">
|
||||
{action}
|
||||
</div>
|
||||
<div className="flex w-[56px] shrink-0 justify-end">{visibility}</div>
|
||||
</div>
|
||||
{footer && <div className="ms-9 mt-2">{footer}</div>}
|
||||
@@ -407,12 +447,14 @@ function Pill({ children }: { children: React.ReactNode }) {
|
||||
function ToolRow({
|
||||
tool,
|
||||
tools,
|
||||
onUninstall,
|
||||
expanded,
|
||||
onToggleExpanded,
|
||||
visibility,
|
||||
}: {
|
||||
tool: ToolName;
|
||||
tools: ReturnType<typeof useToolManagement>;
|
||||
onUninstall: () => void;
|
||||
expanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
visibility: React.ReactNode;
|
||||
@@ -434,8 +476,10 @@ function ToolRow({
|
||||
const isOutdated = isUpdateAvailable(info?.version, info?.latest_version);
|
||||
const broken = Boolean(info?.installed_but_broken);
|
||||
const isBusy = tools.busyTools.has(tool);
|
||||
const action: ToolLifecycleAction | null =
|
||||
tools.busyTools.get(tool) ??
|
||||
const busyAction = tools.busyTools.get(tool);
|
||||
const canUninstall = Boolean(info?.version || broken || defaultInstall);
|
||||
const action: Exclude<ToolLifecycleAction, "uninstall"> | null =
|
||||
(busyAction !== "uninstall" ? busyAction : undefined) ??
|
||||
(isVersionLoading || broken
|
||||
? null
|
||||
: !info?.version
|
||||
@@ -487,7 +531,7 @@ function ToolRow({
|
||||
disabled={isVersionLoading || isBusy}
|
||||
aria-busy={isBusy}
|
||||
>
|
||||
{isBusy ? (
|
||||
{isBusy && busyAction !== "uninstall" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : action === "install" ? (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
@@ -614,7 +658,28 @@ function ToolRow({
|
||||
}
|
||||
meta={meta}
|
||||
version={version}
|
||||
action={actionButton}
|
||||
action={
|
||||
<>
|
||||
{actionButton}
|
||||
{(canUninstall || busyAction === "uninstall") && (
|
||||
<Button
|
||||
variant="neutral"
|
||||
size="compact"
|
||||
className="border-red-200 text-danger-text hover:border-red-300 hover:bg-danger-soft dark:border-red-400/40 dark:hover:border-red-400/60"
|
||||
onClick={onUninstall}
|
||||
disabled={isVersionLoading || isBusy}
|
||||
aria-busy={busyAction === "uninstall"}
|
||||
>
|
||||
{busyAction === "uninstall" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.toolUninstall")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
visibility={visibility}
|
||||
footer={footer}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
import { isLinux, isMac, isWindows } from "@/lib/platform";
|
||||
import { toast } from "@/lib/toast";
|
||||
import {
|
||||
TOOL_NAMES,
|
||||
toolDisplayName,
|
||||
type ToolName,
|
||||
type ToolVersion,
|
||||
} from "./useToolManagement";
|
||||
import {
|
||||
UNINSTALL_METHODS,
|
||||
uninstallCommandsFor,
|
||||
type UninstallEnvironment,
|
||||
type UninstallMethod,
|
||||
} from "./toolUninstallCommands";
|
||||
|
||||
export function ManualUninstallDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
toolVersionByName,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
toolVersionByName: ReadonlyMap<string, ToolVersion>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [method, setMethod] = useState<UninstallMethod>("npm");
|
||||
// Keep detection as an internal value so choosing the displayed environment
|
||||
// still registers as a manual override, even before detection finishes.
|
||||
const [environmentChoice, setEnvironmentChoice] = useState<
|
||||
UninstallEnvironment | "auto"
|
||||
>("auto");
|
||||
// Reuse the app page's per-tool detection, including the configured WSL distro.
|
||||
// Until a result is available, the host platform supplies the native fallback.
|
||||
const hostEnvironment: UninstallEnvironment | undefined = isWindows()
|
||||
? "windows"
|
||||
: isMac()
|
||||
? "macos"
|
||||
: isLinux()
|
||||
? "linux"
|
||||
: undefined;
|
||||
const environmentForTool = (
|
||||
tool: ToolName,
|
||||
): UninstallEnvironment | undefined => {
|
||||
if (environmentChoice !== "auto") return environmentChoice;
|
||||
const detected = toolVersionByName.get(tool)?.env_type;
|
||||
if (detected === "wsl") return "linux";
|
||||
return detected && detected !== "unknown" ? detected : hostEnvironment;
|
||||
};
|
||||
const environmentLabelForTool = (tool: ToolName) => {
|
||||
const info = toolVersionByName.get(tool);
|
||||
if (environmentChoice === "auto" && info?.env_type === "wsl") {
|
||||
return info.wsl_distro ? `WSL · ${info.wsl_distro}` : "WSL";
|
||||
}
|
||||
const environment = environmentForTool(tool);
|
||||
return environment
|
||||
? { windows: "Windows", macos: "macOS", linux: "Linux" }[environment]
|
||||
: t("common.unknown");
|
||||
};
|
||||
const environmentLabels = [
|
||||
...new Set(TOOL_NAMES.map(environmentLabelForTool)),
|
||||
];
|
||||
const methods = (Object.keys(UNINSTALL_METHODS) as UninstallMethod[]).filter(
|
||||
(value) => uninstallCommandsFor(value, environmentForTool).length > 0,
|
||||
);
|
||||
// A refreshed detection can remove the selected method (e.g. WSL -> Windows).
|
||||
const selectedMethod = methods.includes(method) ? method : "npm";
|
||||
|
||||
const copyCommand = async (command: string) => {
|
||||
try {
|
||||
await copyText(command);
|
||||
toast.success(t("settings.uninstallCommandCopied"), {
|
||||
closeButton: true,
|
||||
});
|
||||
} catch {
|
||||
toast.error(t("settings.uninstallCommandCopyFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("settings.manualUninstallCommands")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("settings.manualUninstallHint")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 space-y-3 overflow-y-auto px-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label
|
||||
htmlFor="manual-uninstall-environment"
|
||||
className="shrink-0 text-body text-fg-2"
|
||||
>
|
||||
{t("settings.manualUninstallEnvironment")}
|
||||
</label>
|
||||
<Select
|
||||
value={environmentChoice}
|
||||
onValueChange={(value) =>
|
||||
setEnvironmentChoice(value as UninstallEnvironment)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="manual-uninstall-environment"
|
||||
className="w-[200px]"
|
||||
>
|
||||
<SelectValue>
|
||||
{environmentChoice === "auto"
|
||||
? environmentLabels.join(" / ")
|
||||
: undefined}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="windows">Windows</SelectItem>
|
||||
<SelectItem value="macos">macOS</SelectItem>
|
||||
<SelectItem value="linux">Linux / WSL</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label
|
||||
htmlFor="manual-uninstall-method"
|
||||
className="shrink-0 text-body text-fg-2"
|
||||
>
|
||||
{t("settings.manualUninstallMethod")}
|
||||
</label>
|
||||
<Select
|
||||
value={selectedMethod}
|
||||
onValueChange={(value) => setMethod(value as UninstallMethod)}
|
||||
>
|
||||
<SelectTrigger id="manual-uninstall-method" className="w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{methods.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{UNINSTALL_METHODS[value]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-caption text-fg-2">
|
||||
{t("settings.manualUninstallEnvironmentHint")}
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
{uninstallCommandsFor(selectedMethod, environmentForTool).map(
|
||||
({ tool, command, variant }) => (
|
||||
<li key={command} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-strong text-fg-1">
|
||||
{toolDisplayName(tool)}
|
||||
{variant && ` (${variant})`}
|
||||
{environmentLabels.length > 1 && (
|
||||
<span className="ml-2 text-caption font-normal text-fg-2">
|
||||
{environmentLabelForTool(tool)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
variant="neutral"
|
||||
size="compact"
|
||||
aria-label={t("settings.copyToolUninstallCommand", {
|
||||
tool: toolDisplayName(tool),
|
||||
})}
|
||||
onClick={() => void copyCommand(command)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("common.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
{tool === "openclaw" && (
|
||||
<p className="text-caption text-fg-2">
|
||||
{t("settings.manualUninstallOpenClawHint")}
|
||||
</p>
|
||||
)}
|
||||
{tool === "openclaw" && selectedMethod === "bun" && (
|
||||
<p className="text-caption text-fg-2">
|
||||
{t("settings.manualUninstallBunLauncherHint")}
|
||||
</p>
|
||||
)}
|
||||
<pre className="overflow-x-auto rounded-control border border-border bg-subtle px-3 py-2.5 font-mono text-caption">
|
||||
<code>{command}</code>
|
||||
</pre>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
<p className="text-caption text-fg-2">
|
||||
{t("settings.manualUninstallOtherMethods")}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="neutral"
|
||||
size="regular"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import type { ToolUninstallPlan } from "@/lib/api/settings";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
toolDisplayName,
|
||||
type ToolName,
|
||||
type WslShellPreference,
|
||||
} from "./useToolManagement";
|
||||
|
||||
export function ToolUninstallConfirmDialog({
|
||||
tool,
|
||||
wslShellByTool,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
onManualUninstall,
|
||||
}: {
|
||||
tool: ToolName;
|
||||
wslShellByTool: Record<string, WslShellPreference>;
|
||||
onConfirm: (plan: ToolUninstallPlan) => void;
|
||||
onCancel: () => void;
|
||||
onManualUninstall: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [plan, setPlan] = useState<ToolUninstallPlan | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setPlan(null);
|
||||
setError(null);
|
||||
void settingsApi.prepareToolUninstall(tool, wslShellByTool).then(
|
||||
(result) => {
|
||||
if (!cancelled) setPlan(result);
|
||||
},
|
||||
(reason) => {
|
||||
if (!cancelled) setError(extractErrorMessage(reason));
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [tool, wslShellByTool]);
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onCancel()}>
|
||||
<DialogContent className="max-w-2xl" zIndex="alert">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("settings.toolUninstallConfirmTitle", {
|
||||
tool: toolDisplayName(tool),
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("settings.toolUninstallConfirmHint")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 space-y-3 overflow-y-auto px-6">
|
||||
{error !== null ? (
|
||||
<div role="alert" className="space-y-2 text-body">
|
||||
<p>{t("settings.toolUninstallManualRequired")}</p>
|
||||
{error && <p className="break-words text-fg-2">{error}</p>}
|
||||
</div>
|
||||
) : plan ? (
|
||||
<>
|
||||
{plan.wsl_distro && (
|
||||
<p className="text-body">WSL: {plan.wsl_distro}</p>
|
||||
)}
|
||||
<dl className="space-y-2 text-body">
|
||||
<dt className="text-fg-2">{t("settings.toolUninstallPath")}</dt>
|
||||
<dd className="break-all font-mono">{plan.path}</dd>
|
||||
<dt className="text-fg-2">
|
||||
{t("settings.toolUpgradeWillRun")}
|
||||
</dt>
|
||||
<dd>
|
||||
<pre className="overflow-x-auto rounded-control border border-border bg-subtle px-3 py-2.5 font-mono text-caption">
|
||||
<code>{plan.command}</code>
|
||||
</pre>
|
||||
</dd>
|
||||
</dl>
|
||||
{plan.native && (
|
||||
<p className="text-caption text-fg-2">
|
||||
{t("settings.toolUninstallNativeHint")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p
|
||||
role="status"
|
||||
className="flex items-center gap-2 text-body text-fg-2"
|
||||
>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("settings.toolUninstallPreparing")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="neutral" onClick={onCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
{error !== null && (
|
||||
<Button variant="neutral" onClick={onManualUninstall}>
|
||||
{t("settings.manualUninstallCommands")}
|
||||
</Button>
|
||||
)}
|
||||
{plan && error === null && (
|
||||
<Button variant="destructive" onClick={() => onConfirm(plan)}>
|
||||
{t("settings.toolUninstall")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ToolName } from "./useToolManagement";
|
||||
|
||||
// Audited against official installation docs, 2026-10-12.
|
||||
// An npm package does not imply support for every other package manager.
|
||||
export const UNINSTALL_METHODS = {
|
||||
npm: "npm",
|
||||
pnpm: "pnpm",
|
||||
bun: "bun",
|
||||
"brew-formula": "Homebrew (formula)",
|
||||
"brew-cask": "Homebrew (cask)",
|
||||
hermes: "Hermes",
|
||||
};
|
||||
|
||||
export type UninstallMethod = keyof typeof UNINSTALL_METHODS;
|
||||
export type UninstallEnvironment = "windows" | "macos" | "linux";
|
||||
|
||||
type UninstallCommand = {
|
||||
tool: ToolName;
|
||||
command: string;
|
||||
environments?: UninstallEnvironment[];
|
||||
variant?: string;
|
||||
};
|
||||
|
||||
const COMMANDS: Record<UninstallMethod, UninstallCommand[]> = {
|
||||
npm: [
|
||||
{ tool: "claude", command: "npm uninstall -g @anthropic-ai/claude-code" },
|
||||
{ tool: "codex", command: "npm uninstall -g @openai/codex" },
|
||||
{ tool: "gemini", command: "npm uninstall -g @google/gemini-cli" },
|
||||
{ tool: "grok", command: "npm uninstall -g @xai-official/grok" },
|
||||
{ tool: "opencode", command: "npm uninstall -g opencode-ai" },
|
||||
{ tool: "openclaw", command: "npm uninstall -g openclaw" },
|
||||
{ tool: "pi", command: "npm uninstall -g @earendil-works/pi-coding-agent" },
|
||||
{ tool: "mcode", command: "npm uninstall -g @minimax-ai/code" },
|
||||
],
|
||||
pnpm: [
|
||||
{ tool: "opencode", command: "pnpm remove -g opencode-ai" },
|
||||
{ tool: "openclaw", command: "pnpm remove -g openclaw" },
|
||||
],
|
||||
bun: [
|
||||
{
|
||||
tool: "opencode",
|
||||
command: "bun remove -g opencode-ai",
|
||||
environments: ["macos", "linux"],
|
||||
},
|
||||
{ tool: "openclaw", command: "bun remove -g openclaw" },
|
||||
],
|
||||
"brew-formula": [
|
||||
{
|
||||
tool: "gemini",
|
||||
command: "brew uninstall --formula gemini-cli",
|
||||
environments: ["macos", "linux"],
|
||||
},
|
||||
{
|
||||
tool: "opencode",
|
||||
command: "brew uninstall --formula anomalyco/tap/opencode",
|
||||
environments: ["macos", "linux"],
|
||||
variant: "anomalyco/tap",
|
||||
},
|
||||
{
|
||||
tool: "opencode",
|
||||
command: "brew uninstall --formula opencode",
|
||||
environments: ["macos", "linux"],
|
||||
variant: "homebrew/core",
|
||||
},
|
||||
],
|
||||
"brew-cask": [
|
||||
{
|
||||
tool: "claude",
|
||||
command: "brew uninstall --cask claude-code",
|
||||
environments: ["macos"],
|
||||
variant: "claude-code",
|
||||
},
|
||||
{
|
||||
tool: "claude",
|
||||
command: "brew uninstall --cask claude-code@latest",
|
||||
environments: ["macos"],
|
||||
variant: "claude-code@latest",
|
||||
},
|
||||
{
|
||||
tool: "codex",
|
||||
command: "brew uninstall --cask codex",
|
||||
environments: ["macos"],
|
||||
},
|
||||
],
|
||||
hermes: [{ tool: "hermes", command: "hermes uninstall --yes" }],
|
||||
};
|
||||
|
||||
export function uninstallCommandsFor(
|
||||
method: UninstallMethod,
|
||||
environmentForTool: (tool: ToolName) => UninstallEnvironment | undefined,
|
||||
) {
|
||||
return COMMANDS[method].filter(({ tool, environments }) => {
|
||||
const environment = environmentForTool(tool);
|
||||
return (
|
||||
!environments ||
|
||||
(environment !== undefined && environments.includes(environment))
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { settingsApi } from "@/lib/api";
|
||||
import type {
|
||||
ToolInstallation,
|
||||
ToolInstallationReport,
|
||||
ToolUninstallPlan,
|
||||
} from "@/lib/api/settings";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
@@ -18,7 +19,7 @@ import { isWindows } from "@/lib/platform";
|
||||
import { isUpdateAvailable } from "@/lib/version";
|
||||
|
||||
/**
|
||||
* 「应用」页的安装 / 升级逻辑(原来在设置 → 关于)。任务、确认队列和版本结果放在模块级的
|
||||
* 「应用」页的安装 / 升级 / 卸载逻辑。任务、确认队列和版本结果放在模块级的
|
||||
* store 里:离开页面也不中断,回来立刻恢复进度。
|
||||
*/
|
||||
|
||||
@@ -46,7 +47,7 @@ export const TOOL_NAMES = [
|
||||
"mcode",
|
||||
] as const;
|
||||
export type ToolName = (typeof TOOL_NAMES)[number];
|
||||
export type ToolLifecycleAction = "install" | "update";
|
||||
export type ToolLifecycleAction = "install" | "update" | "uninstall";
|
||||
|
||||
interface PendingUpgrade {
|
||||
toolNames: ToolName[];
|
||||
@@ -604,6 +605,7 @@ export function useToolManagement() {
|
||||
toolNames: ToolName[],
|
||||
action: ToolLifecycleAction,
|
||||
wslOverrides: Record<string, WslShellPreference>,
|
||||
uninstallPlan?: ToolUninstallPlan,
|
||||
) => {
|
||||
const isBatch = toolNames.length > 1;
|
||||
|
||||
@@ -625,17 +627,50 @@ export function useToolManagement() {
|
||||
const previousVersion = previousTool?.version ?? null;
|
||||
const previousLatestVersion = previousTool?.latest_version ?? null;
|
||||
|
||||
await settingsApi.runToolLifecycleAction(
|
||||
[toolName],
|
||||
action,
|
||||
wslOverrides,
|
||||
);
|
||||
if (action === "uninstall") {
|
||||
await settingsApi.runToolLifecycleAction(
|
||||
[toolName],
|
||||
action,
|
||||
wslOverrides,
|
||||
uninstallPlan,
|
||||
);
|
||||
} else {
|
||||
await settingsApi.runToolLifecycleAction(
|
||||
[toolName],
|
||||
action,
|
||||
wslOverrides,
|
||||
);
|
||||
}
|
||||
if (action === "uninstall") {
|
||||
const nextReports = { ...toolManagementState.installReports };
|
||||
delete nextReports[toolName];
|
||||
updateToolManagementState({ installReports: nextReports });
|
||||
setToolDiagnostics((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[toolName];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
// 静默执行真正结束后刷新该工具版本,卡片立即反映结果。
|
||||
const refreshed = await refreshToolVersions(
|
||||
[toolName],
|
||||
wslOverrides,
|
||||
);
|
||||
const tool = refreshed.find((t) => t.name === toolName);
|
||||
if (action === "uninstall") {
|
||||
await diagnoseToolSilently(toolName);
|
||||
if (!tool) {
|
||||
throw new Error(t("settings.toolUninstallCheckFailed"));
|
||||
}
|
||||
if (tool.version || tool.installed_but_broken) {
|
||||
toast.info(t("settings.toolUninstallRemaining"), {
|
||||
description: TOOL_DISPLAY_NAMES[toolName],
|
||||
closeButton: true,
|
||||
});
|
||||
}
|
||||
succeeded += 1;
|
||||
return;
|
||||
}
|
||||
if (tool?.version) {
|
||||
const latestVersion =
|
||||
tool.latest_version ?? previousLatestVersion;
|
||||
@@ -706,7 +741,9 @@ export function useToolManagement() {
|
||||
const actionLabel =
|
||||
action === "install"
|
||||
? t("settings.toolInstall")
|
||||
: t("settings.toolUpdate");
|
||||
: action === "uninstall"
|
||||
? t("settings.toolUninstall")
|
||||
: t("settings.toolUpdate");
|
||||
|
||||
if (failures.length === 0) {
|
||||
if (succeeded > 0) {
|
||||
@@ -752,10 +789,17 @@ export function useToolManagement() {
|
||||
},
|
||||
);
|
||||
} else if (succeeded === 0) {
|
||||
toast.error(t("settings.toolActionFailed"), {
|
||||
description: failureDescription || undefined,
|
||||
closeButton: true,
|
||||
});
|
||||
toast.error(
|
||||
t(
|
||||
action === "uninstall"
|
||||
? "settings.toolUninstallFailed"
|
||||
: "settings.toolActionFailed",
|
||||
),
|
||||
{
|
||||
description: failureDescription || undefined,
|
||||
closeButton: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// 部分成功:用 warning 汇总成败数量,详情列出失败的工具。
|
||||
toast.warning(
|
||||
@@ -782,7 +826,7 @@ export function useToolManagement() {
|
||||
async (
|
||||
requestedTools: ToolName[],
|
||||
action: ToolLifecycleAction,
|
||||
options?: { fromBatchEntry?: boolean },
|
||||
options?: { fromBatchEntry?: boolean; uninstallPlan?: ToolUninstallPlan },
|
||||
) => {
|
||||
const toolNames = requestedTools.filter(
|
||||
(name) => !toolManagementState.busyTools.has(name),
|
||||
@@ -809,9 +853,14 @@ export function useToolManagement() {
|
||||
updateToolManagementState({ batchAction: action });
|
||||
}
|
||||
try {
|
||||
if (action === "install") {
|
||||
if (action === "install" || action === "uninstall") {
|
||||
toolsToRelease = [];
|
||||
await executeRun(toolNames, action, wslOverrides);
|
||||
await executeRun(
|
||||
toolNames,
|
||||
action,
|
||||
wslOverrides,
|
||||
options?.uninstallPlan,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let reports: ToolInstallationReport[];
|
||||
|
||||
@@ -934,6 +934,17 @@
|
||||
"starPrompt": "If CC Switch has helped you, please star us on GitHub — it means a lot to us",
|
||||
"starOnGithub": "Star on GitHub",
|
||||
"manualInstallCommands": "Manual Install Commands",
|
||||
"manualUninstallCommands": "Manual uninstall commands",
|
||||
"manualUninstallHint": "Each app’s environment is detected automatically by default, and you can select a different environment manually. Select the original installation method, then copy the command for the app you want to remove and run it in a terminal. Click “Check for updates” afterwards to refresh its status.",
|
||||
"manualUninstallMethod": "Installation method",
|
||||
"manualUninstallBunLauncherHint": "Bun can leave an OpenClaw launcher behind. Inspect its “OpenClaw Bun launcher” header and package path before removing that file; keep the shared bin directory.",
|
||||
"manualUninstallOpenClawHint": "First run openclaw uninstall --service --yes --non-interactive successfully to remove the Gateway service while keeping configuration and workspaces. Then remove the CLI with the command below.",
|
||||
"manualUninstallEnvironment": "Environment",
|
||||
"manualUninstallEnvironmentHint": "Use the same Node environment and package manager as the original installation. Only add --prefix for a verified global npm prefix. Run commands for WSL apps inside the corresponding distribution.",
|
||||
"manualUninstallOtherMethods": "Only methods verified against official installation instructions are listed. Commands retain configuration and session data. For script-managed installations, downloads or other methods, use the original installer’s instructions; do not substitute a global npm command. For Pi installed by script, rerun its installer and select “Uninstall Pi”.",
|
||||
"copyToolUninstallCommand": "Copy the uninstall command for {{tool}}",
|
||||
"uninstallCommandCopied": "Uninstall command copied",
|
||||
"uninstallCommandCopyFailed": "Copy failed. Please select and copy the command manually.",
|
||||
"oneClickInstallHint": "Install or upgrade Claude Code / Codex / Gemini CLI / Grok Build / OpenCode / OpenClaw / Hermes / Pi / MiniMax Code",
|
||||
"localEnvCheck": "Local environment check",
|
||||
"updateAllTools": "Update All ({{count}})",
|
||||
@@ -941,13 +952,23 @@
|
||||
"latestVersion": "Latest Version",
|
||||
"updateAvailableShort": "Update",
|
||||
"toolInstall": "Install",
|
||||
"toolUninstall": "Uninstall",
|
||||
"toolUninstallConfirmTitle": "Uninstall {{tool}}?",
|
||||
"toolUninstallConfirmHint": "This removes the active installation and keeps configuration and session data. Other installations, if any, will remain.",
|
||||
"toolUninstallPreparing": "Checking the installation and uninstalling…",
|
||||
"toolUninstallManualRequired": "Cannot uninstall automatically. Please uninstall this app manually.",
|
||||
"toolUninstallPath": "Installation to remove",
|
||||
"toolUninstallNativeHint": "Only the selected executable and its launcher will be removed. Older versions left by the installer will remain.",
|
||||
"toolUninstallFailed": "Uninstall failed",
|
||||
"toolUninstallRemaining": "The active installation was removed; other installations remain",
|
||||
"toolUninstallCheckFailed": "Could not verify the installation status after uninstalling. Click “Check for updates” to try again.",
|
||||
"toolUpdate": "Update",
|
||||
"toolReady": "Ready",
|
||||
"toolActionDone": "{{action}} completed for {{count}} tool(s)",
|
||||
"toolActionPartial": "{{action}} succeeded for {{succeeded}}, failed for {{failed}}",
|
||||
"toolActionFailed": "Install/update command failed",
|
||||
"toolActionInProgress": "Installation or update still in progress",
|
||||
"toolActionInProgressDetail": "An installation or update for {{tool}} is already being handled in the background. Please wait for it to finish; there is no need to start it again.",
|
||||
"toolActionInProgress": "An app operation is still running",
|
||||
"toolActionInProgressDetail": "{{tool}} already has an installation, update or uninstall running in the background. Please wait for it to finish.",
|
||||
"toolNotRunnable": "Installed but not runnable (no version detected)",
|
||||
"toolActionVersionUnchangedTitle": "Version did not change",
|
||||
"toolActionVersionUnchanged": "Still on {{version}} (latest: {{latest}}). The updater may have reported success without applying the update.",
|
||||
|
||||
@@ -934,6 +934,17 @@
|
||||
"starPrompt": "CC Switch がお役に立てたなら、GitHub でスターをお願いします。私たちにとって大きな励みになります",
|
||||
"starOnGithub": "GitHub でスター",
|
||||
"manualInstallCommands": "手動インストールコマンド",
|
||||
"manualUninstallCommands": "手動アンインストールコマンド",
|
||||
"manualUninstallHint": "各アプリの実行環境は既定で自動検出されます。手動で別の環境に切り替えることもできます。インストール時に使用した方法を選び、削除したいアプリのコマンドをコピーしてターミナルで実行してください。完了後に「更新を確認」をクリックすると状態が更新されます。",
|
||||
"manualUninstallMethod": "インストール方法",
|
||||
"manualUninstallBunLauncherHint": "Bun では OpenClaw ランチャーが残る場合があります。「OpenClaw Bun launcher」の記述とパッケージパスを確認してから、そのファイルのみ削除し、共有 bin ディレクトリは保持してください。",
|
||||
"manualUninstallOpenClawHint": "先に openclaw uninstall --service --yes --non-interactive を正常に完了させ、設定とワークスペースを保持したまま Gateway サービスを削除してください。その後、下のコマンドで CLI を削除します。",
|
||||
"manualUninstallEnvironment": "実行環境",
|
||||
"manualUninstallEnvironmentHint": "インストール時と同じ Node 環境とパッケージマネージャーを使用してください。--prefix は確認済みの npm グローバルインストール先にのみ指定します。WSL のアプリは対応するディストリビューション内で実行してください。",
|
||||
"manualUninstallOtherMethods": "公式のインストール手順で確認できた方法のみ表示します。設定とセッションデータは保持されます。スクリプト管理のインストールや手動ダウンロードなどは元のインストーラーの手順に従い、グローバル npm コマンドで代用しないでください。スクリプトでインストールした Pi は、インストーラーを再実行して「Uninstall Pi」を選択してください。",
|
||||
"copyToolUninstallCommand": "{{tool}} のアンインストールコマンドをコピー",
|
||||
"uninstallCommandCopied": "アンインストールコマンドをコピーしました",
|
||||
"uninstallCommandCopyFailed": "コピーに失敗しました。コマンドを選択して手動でコピーしてください。",
|
||||
"oneClickInstallHint": "Claude Code / Codex / Gemini CLI / Grok Build / OpenCode / OpenClaw / Hermes / Pi / MiniMax Code をインストールまたは更新",
|
||||
"localEnvCheck": "ローカル環境チェック",
|
||||
"updateAllTools": "すべて更新({{count}})",
|
||||
@@ -941,13 +952,23 @@
|
||||
"latestVersion": "最新バージョン",
|
||||
"updateAvailableShort": "更新あり",
|
||||
"toolInstall": "インストール",
|
||||
"toolUninstall": "アンインストール",
|
||||
"toolUninstallConfirmTitle": "{{tool}} をアンインストールしますか?",
|
||||
"toolUninstallConfirmHint": "現在使用中のインストールを削除し、設定とセッションデータは保持します。ほかの場所にもインストールされている場合、それらは保持されます。",
|
||||
"toolUninstallPreparing": "インストールを確認し、アンインストールしています…",
|
||||
"toolUninstallManualRequired": "自動でアンインストールできません。このアプリを手動でアンインストールしてください。",
|
||||
"toolUninstallPath": "削除するインストールのパス",
|
||||
"toolUninstallNativeHint": "選択した実行ファイルと起動用ファイルのみを削除します。インストーラーが保存した他の旧バージョンは残ります。",
|
||||
"toolUninstallFailed": "アンインストールに失敗しました",
|
||||
"toolUninstallRemaining": "現在のインストールを削除しましたが、ほかのインストールが残っています",
|
||||
"toolUninstallCheckFailed": "アンインストール後の状態を確認できませんでした。「更新を確認」をクリックして再試行してください。",
|
||||
"toolUpdate": "更新",
|
||||
"toolReady": "準備完了",
|
||||
"toolActionDone": "{{count}} 個のツールの{{action}}が完了しました",
|
||||
"toolActionPartial": "{{succeeded}} 個成功、{{failed}} 個失敗({{action}})",
|
||||
"toolActionFailed": "インストール/更新コマンドの実行に失敗しました",
|
||||
"toolActionInProgress": "インストールまたは更新はまだ進行中です",
|
||||
"toolActionInProgressDetail": "{{tool}} のインストールまたは更新はバックグラウンドで処理中です。完了までお待ちください。再度実行する必要はありません。",
|
||||
"toolActionInProgress": "アプリの操作が実行中です",
|
||||
"toolActionInProgressDetail": "{{tool}} のインストール、更新、またはアンインストールがバックグラウンドで実行中です。完了するまでお待ちください。",
|
||||
"toolNotRunnable": "インストール済みですが実行できません(バージョン未検出)",
|
||||
"toolActionVersionUnchangedTitle": "バージョンは変わりませんでした",
|
||||
"toolActionVersionUnchanged": "現在も {{version}}(最新: {{latest}})です。アップデーターが成功を報告しても実際には更新されていない可能性があります。",
|
||||
|
||||
@@ -934,6 +934,17 @@
|
||||
"starPrompt": "如果 CC Switch 幫到了你,請在 GitHub 點個 Star,這對我們意義重大",
|
||||
"starOnGithub": "去點 Star",
|
||||
"manualInstallCommands": "手動安裝指令",
|
||||
"manualUninstallCommands": "手動解除安裝指令",
|
||||
"manualUninstallHint": "預設自動偵測各應用程式的執行環境,也可手動切換。請選擇安裝時使用的方式,再複製所需應用程式的指令到終端機執行。完成後點選「檢查更新」重新整理安裝狀態。",
|
||||
"manualUninstallMethod": "安裝方式",
|
||||
"manualUninstallBunLauncherHint": "Bun 可能留下 OpenClaw 啟動器。請先核對檔案中的「OpenClaw Bun launcher」標記及套件路徑,再刪除該檔案,保留共用 bin 目錄。",
|
||||
"manualUninstallOpenClawHint": "請先成功執行 openclaw uninstall --service --yes --non-interactive,僅解除安裝 Gateway 服務並保留設定和工作區,再執行下方指令移除 CLI。",
|
||||
"manualUninstallEnvironment": "執行環境",
|
||||
"manualUninstallEnvironmentHint": "請使用安裝時的 Node 環境和套件管理器,僅在確認全域 npm 安裝前綴後加入 --prefix。WSL 應用程式請在對應發行版內執行。",
|
||||
"manualUninstallOtherMethods": "僅列出已依官方安裝說明核實的方式。以上指令保留設定和工作階段資料。指令碼管理的安裝、手動下載或其他方式請依原安裝程式說明解除安裝,不要套用全域 npm 指令。透過指令碼安裝的 Pi,請重新執行其安裝程式並選擇「Uninstall Pi」。",
|
||||
"copyToolUninstallCommand": "複製 {{tool}} 的解除安裝指令",
|
||||
"uninstallCommandCopied": "解除安裝指令已複製",
|
||||
"uninstallCommandCopyFailed": "複製失敗,請手動選取並複製指令。",
|
||||
"oneClickInstall": "一鍵安裝",
|
||||
"oneClickInstallHint": "安裝或更新 Claude Code / Codex / Gemini CLI / Grok Build / OpenCode / OpenClaw / Hermes / Pi / MiniMax Code",
|
||||
"localEnvCheck": "本地環境檢查",
|
||||
@@ -942,13 +953,23 @@
|
||||
"latestVersion": "最新版本",
|
||||
"updateAvailableShort": "可更新",
|
||||
"toolInstall": "安裝",
|
||||
"toolUninstall": "解除安裝",
|
||||
"toolUninstallConfirmTitle": "解除安裝 {{tool}}?",
|
||||
"toolUninstallConfirmHint": "將解除安裝目前使用的安裝,保留設定和工作階段資料。如果存在多處安裝,其他安裝將保留。",
|
||||
"toolUninstallPreparing": "正在檢查安裝並解除安裝…",
|
||||
"toolUninstallManualRequired": "無法自動解除安裝,請手動解除安裝此應用程式。",
|
||||
"toolUninstallPath": "將解除安裝的路徑",
|
||||
"toolUninstallNativeHint": "僅刪除目前選取的執行檔及其啟動入口,安裝程式保留的其他舊版本仍會保留。",
|
||||
"toolUninstallFailed": "解除安裝失敗",
|
||||
"toolUninstallRemaining": "目前的安裝已移除,仍偵測到其他安裝",
|
||||
"toolUninstallCheckFailed": "解除安裝後無法確認安裝狀態,請點選「檢查更新」重試。",
|
||||
"toolUpdate": "更新",
|
||||
"toolReady": "已就緒",
|
||||
"toolActionDone": "{{count}} 個工具{{action}}完成",
|
||||
"toolActionPartial": "{{succeeded}} 個{{action}}成功,{{failed}} 個失敗",
|
||||
"toolActionFailed": "安裝/更新指令執行失敗",
|
||||
"toolActionInProgress": "安裝或更新工作仍在進行中",
|
||||
"toolActionInProgressDetail": "{{tool}} 已有安裝或更新工作正在背景處理,請等待完成,無需重複操作。",
|
||||
"toolActionInProgress": "應用程式操作仍在進行中",
|
||||
"toolActionInProgressDetail": "{{tool}} 已有安裝、升級或解除安裝作業正在背景處理,請等待完成,無需重複操作。",
|
||||
"toolNotRunnable": "已安裝但無法執行(未偵測到版本)",
|
||||
"toolActionVersionUnchangedTitle": "版本未變更",
|
||||
"toolActionVersionUnchanged": "仍為 {{version}}(最新版本:{{latest}})。上游更新程式可能回報成功,但實際上並未套用更新。",
|
||||
|
||||
@@ -934,6 +934,17 @@
|
||||
"starPrompt": "如果 CC Switch 帮到了你,请在 GitHub 点个 Star,这对我们意义重大",
|
||||
"starOnGithub": "去点 Star",
|
||||
"manualInstallCommands": "手动安装命令",
|
||||
"manualUninstallCommands": "手动卸载命令",
|
||||
"manualUninstallHint": "默认自动侦测各应用的运行环境,也可手动切换。请选择安装时使用的方式,再复制所需应用的命令到终端执行。完成后点击“检查更新”刷新安装状态。",
|
||||
"manualUninstallMethod": "安装方式",
|
||||
"manualUninstallBunLauncherHint": "Bun 可能留下 OpenClaw 启动器。请先核对文件中的“OpenClaw Bun launcher”标记及包路径,再删除该文件,保留共享 bin 目录。",
|
||||
"manualUninstallOpenClawHint": "请先成功执行 openclaw uninstall --service --yes --non-interactive,仅卸载 Gateway 服务并保留配置和工作区,再执行下方命令移除 CLI。",
|
||||
"manualUninstallEnvironment": "运行环境",
|
||||
"manualUninstallEnvironmentHint": "请使用安装时的 Node 环境和包管理器,仅在确认全局 npm 安装前缀后添加 --prefix。WSL 应用请在对应发行版内执行。",
|
||||
"manualUninstallOtherMethods": "仅列出已按官方安装说明核实的方式。以上命令保留配置和会话数据。脚本管理的安装、手动下载或其他方式请按原安装器说明卸载,不要套用全局 npm 命令。通过脚本安装的 Pi,请重新运行其安装器并选择“Uninstall Pi”。",
|
||||
"copyToolUninstallCommand": "复制 {{tool}} 的卸载命令",
|
||||
"uninstallCommandCopied": "卸载命令已复制",
|
||||
"uninstallCommandCopyFailed": "复制失败,请手动选择并复制命令。",
|
||||
"oneClickInstallHint": "安装或升级 Claude Code / Codex / Gemini CLI / Grok Build / OpenCode / OpenClaw / Hermes / Pi / MiniMax Code",
|
||||
"localEnvCheck": "本地环境检查",
|
||||
"updateAllTools": "全部升级({{count}})",
|
||||
@@ -941,13 +952,23 @@
|
||||
"latestVersion": "最新版本",
|
||||
"updateAvailableShort": "可升级",
|
||||
"toolInstall": "安装",
|
||||
"toolUninstall": "卸载",
|
||||
"toolUninstallConfirmTitle": "卸载 {{tool}}?",
|
||||
"toolUninstallConfirmHint": "将卸载当前使用的安装,保留配置和会话数据。如果存在多处安装,其他安装将保留。",
|
||||
"toolUninstallPreparing": "正在检查安装并卸载…",
|
||||
"toolUninstallManualRequired": "无法自动卸载,请手动卸载此应用。",
|
||||
"toolUninstallPath": "将卸载的安装路径",
|
||||
"toolUninstallNativeHint": "仅删除当前选中的可执行文件及其启动入口,其他旧版本仍会保留。",
|
||||
"toolUninstallFailed": "卸载失败",
|
||||
"toolUninstallRemaining": "当前安装已卸载,仍检测到其他安装",
|
||||
"toolUninstallCheckFailed": "卸载后无法确认安装状态,请点击“检查更新”重试。",
|
||||
"toolUpdate": "升级",
|
||||
"toolReady": "已就绪",
|
||||
"toolActionDone": "{{count}} 个工具{{action}}完成",
|
||||
"toolActionPartial": "{{succeeded}} 个{{action}}成功,{{failed}} 个失败",
|
||||
"toolActionFailed": "安装/升级命令执行失败",
|
||||
"toolActionInProgress": "安装或升级任务仍在进行中",
|
||||
"toolActionInProgressDetail": "{{tool}} 已有安装或升级任务正在后台处理,请等待完成,无需重复操作。",
|
||||
"toolActionInProgress": "应用操作仍在进行中",
|
||||
"toolActionInProgressDetail": "{{tool}} 已有安装、升级或卸载任务正在后台处理,请等待完成,无需重复操作。",
|
||||
"toolNotRunnable": "已安装但无法运行(未探测到版本)",
|
||||
"toolActionVersionUnchangedTitle": "版本没有变化",
|
||||
"toolActionVersionUnchanged": "仍是 {{version}}(最新:{{latest}})。上游升级器可能报告成功但没有实际更新。",
|
||||
|
||||
+23
-1
@@ -279,19 +279,32 @@ export const settingsApi = {
|
||||
|
||||
async runToolLifecycleAction(
|
||||
tools: string[],
|
||||
action: "install" | "update",
|
||||
action: "install" | "update" | "uninstall",
|
||||
wslShellByTool?: Record<
|
||||
string,
|
||||
{ wslShell?: string | null; wslShellFlag?: string | null }
|
||||
>,
|
||||
uninstallPlan?: ToolUninstallPlan,
|
||||
): Promise<void> {
|
||||
await invoke("run_tool_lifecycle_action", {
|
||||
tools,
|
||||
action,
|
||||
wslShellByTool,
|
||||
uninstallPlan,
|
||||
});
|
||||
},
|
||||
|
||||
/** Generate a read-only uninstall preview; execution checks that it is still current. */
|
||||
async prepareToolUninstall(
|
||||
tool: string,
|
||||
wslShellByTool?: Record<
|
||||
string,
|
||||
{ wslShell?: string | null; wslShellFlag?: string | null }
|
||||
>,
|
||||
): Promise<ToolUninstallPlan> {
|
||||
return await invoke("prepare_tool_uninstall", { tool, wslShellByTool });
|
||||
},
|
||||
|
||||
/** 探测各工具安装分布:枚举所有安装、标记冲突、生成锚定升级命令。
|
||||
* 诊断按钮、升级前确认、升级后补诊共用此命令,各取所需字段。 */
|
||||
async probeToolInstallations(
|
||||
@@ -332,6 +345,15 @@ export const settingsApi = {
|
||||
},
|
||||
};
|
||||
|
||||
export interface ToolUninstallPlan {
|
||||
tool: string;
|
||||
path: string;
|
||||
real_path: string;
|
||||
command: string;
|
||||
native: boolean;
|
||||
wsl_distro: string | null;
|
||||
}
|
||||
|
||||
/** 单处工具安装的诊断信息(多处安装冲突检测)。字段对应后端 ToolInstallation。 */
|
||||
export interface ToolInstallation {
|
||||
path: string;
|
||||
|
||||
@@ -7,9 +7,21 @@ import {
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { settingsApi, ToolInstallationReport } from "@/lib/api/settings";
|
||||
import type {
|
||||
settingsApi,
|
||||
ToolInstallationReport,
|
||||
ToolUninstallPlan,
|
||||
} from "@/lib/api/settings";
|
||||
|
||||
type ToolVersions = Awaited<ReturnType<typeof settingsApi.getToolVersions>>;
|
||||
|
||||
@@ -18,6 +30,8 @@ const mocks = vi.hoisted(() => ({
|
||||
probeToolInstallations: vi.fn(),
|
||||
listToolInstallations: vi.fn(),
|
||||
runToolLifecycleAction: vi.fn(),
|
||||
prepareToolUninstall: vi.fn(),
|
||||
copyText: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
@@ -38,6 +52,7 @@ vi.mock("@/hooks/useSettings", () => ({
|
||||
}),
|
||||
}));
|
||||
vi.mock("sonner", () => ({ toast: mocks }));
|
||||
vi.mock("@/lib/clipboard", () => ({ copyText: mocks.copyText }));
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
@@ -65,13 +80,26 @@ function report(
|
||||
};
|
||||
}
|
||||
|
||||
function uninstallPlan(tool: string): ToolUninstallPlan {
|
||||
return {
|
||||
tool,
|
||||
path: `C:/tools/${tool}.cmd`,
|
||||
real_path: `C:/tools/${tool}.cmd`,
|
||||
command: `npm uninstall -g --prefix C:/tools ${tool}`,
|
||||
native: false,
|
||||
wsl_distro: null,
|
||||
};
|
||||
}
|
||||
|
||||
const upgraded = new Set<string>();
|
||||
const outdated = new Set<string>();
|
||||
const missing = new Set<string>();
|
||||
|
||||
function card(name: string) {
|
||||
return within(
|
||||
screen.getByText(name).closest("[data-tool-row]") as HTMLElement,
|
||||
screen
|
||||
.getByText(name, { selector: "span" })
|
||||
.closest("[data-tool-row]") as HTMLElement,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,12 +142,36 @@ async function renderApps() {
|
||||
const updateAllButton = () =>
|
||||
screen.getByRole("button", { name: /settings\.updateAllTools/ });
|
||||
|
||||
// Radix Select scrolls the focused option; jsdom does not implement scrolling.
|
||||
const originalScrollIntoView = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
"scrollIntoView",
|
||||
);
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
afterAll(() => {
|
||||
if (originalScrollIntoView) {
|
||||
Object.defineProperty(
|
||||
HTMLElement.prototype,
|
||||
"scrollIntoView",
|
||||
originalScrollIntoView,
|
||||
);
|
||||
} else {
|
||||
Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
|
||||
}
|
||||
});
|
||||
|
||||
describe("AppsPage concurrent CLI upgrades", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
upgraded.clear();
|
||||
outdated.clear();
|
||||
missing.clear();
|
||||
mocks.copyText.mockReset().mockResolvedValue(undefined);
|
||||
outdated.add("claude").add("codex").add("gemini");
|
||||
mocks.getToolVersions
|
||||
.mockReset()
|
||||
@@ -148,6 +200,9 @@ describe("AppsPage concurrent CLI upgrades", () => {
|
||||
.mockImplementation(async (tools: string[]) =>
|
||||
tools.map((tool) => report(tool)),
|
||||
);
|
||||
mocks.prepareToolUninstall
|
||||
.mockReset()
|
||||
.mockImplementation(async (tool: string) => uninstallPlan(tool));
|
||||
mocks.runToolLifecycleAction
|
||||
.mockReset()
|
||||
.mockImplementation(async ([tool]: string[]) => {
|
||||
@@ -156,6 +211,670 @@ describe("AppsPage concurrent CLI upgrades", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("copies only the selected application's manual command and switches installation methods", async () => {
|
||||
const getVersions = mocks.getToolVersions.getMockImplementation()!;
|
||||
mocks.getToolVersions.mockImplementation(async (tools: string[]) =>
|
||||
((await getVersions(tools)) as ToolVersions).map((tool) => ({
|
||||
...tool,
|
||||
env_type: "macos",
|
||||
})),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
await renderApps();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "appsPage.moreActions" }),
|
||||
);
|
||||
await user.click(
|
||||
await screen.findByRole("menuitem", {
|
||||
name: "settings.manualUninstallCommands",
|
||||
}),
|
||||
);
|
||||
const dialog = within(screen.getByRole("dialog"));
|
||||
const codex = () => within(dialog.getByText("Codex").closest("li")!);
|
||||
expect(
|
||||
codex().getByText("npm uninstall -g @openai/codex"),
|
||||
).toBeInTheDocument();
|
||||
expect(dialog.queryByText("Hermes")).not.toBeInTheDocument();
|
||||
await user.click(codex().getByRole("button"));
|
||||
expect(mocks.copyText).toHaveBeenLastCalledWith(
|
||||
"npm uninstall -g @openai/codex",
|
||||
);
|
||||
|
||||
expect(dialog.getByText("macOS")).toBeInTheDocument();
|
||||
expect(dialog.getAllByRole("combobox")).toHaveLength(2);
|
||||
await user.click(
|
||||
dialog.getByRole("combobox", { name: "settings.manualUninstallMethod" }),
|
||||
);
|
||||
await user.click(
|
||||
await screen.findByRole("option", { name: "Homebrew (cask)" }),
|
||||
);
|
||||
expect(
|
||||
dialog.queryByText("npm uninstall -g @openai/codex"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
dialog.getByText("brew uninstall --cask claude-code"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
dialog.getByText("brew uninstall --cask claude-code@latest"),
|
||||
).toBeInTheDocument();
|
||||
await user.click(codex().getByRole("button"));
|
||||
expect(mocks.copyText).toHaveBeenLastCalledWith(
|
||||
"brew uninstall --cask codex",
|
||||
);
|
||||
expect(mocks.success).toHaveBeenCalledWith(
|
||||
"settings.uninstallCommandCopied",
|
||||
{ closeButton: true },
|
||||
);
|
||||
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
|
||||
await user.click(dialog.getByRole("button", { name: "common.close" }));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "appsPage.moreActions" }),
|
||||
);
|
||||
await user.click(
|
||||
await screen.findByRole("menuitem", { name: "appsPage.manualCommands" }),
|
||||
);
|
||||
expect(
|
||||
within(screen.getByRole("dialog")).getByRole("heading", {
|
||||
name: "settings.manualInstallCommands",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([["Windows NT 10.0"], ["X11; Linux x86_64"]])(
|
||||
"shows the official Hermes uninstall command for %s",
|
||||
async (userAgent) => {
|
||||
const platform = vi
|
||||
.spyOn(navigator, "userAgent", "get")
|
||||
.mockReturnValue(userAgent);
|
||||
try {
|
||||
const user = userEvent.setup();
|
||||
await renderApps();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "appsPage.moreActions" }),
|
||||
);
|
||||
await user.click(
|
||||
await screen.findByRole("menuitem", {
|
||||
name: "settings.manualUninstallCommands",
|
||||
}),
|
||||
);
|
||||
const dialog = within(screen.getByRole("dialog"));
|
||||
await user.click(
|
||||
dialog.getByRole("combobox", {
|
||||
name: "settings.manualUninstallMethod",
|
||||
}),
|
||||
);
|
||||
await user.click(await screen.findByRole("option", { name: "Hermes" }));
|
||||
expect(
|
||||
within(dialog.getByRole("list")).getByText("Hermes"),
|
||||
).toBeInTheDocument();
|
||||
expect(dialog.queryByText("Codex")).not.toBeInTheDocument();
|
||||
await user.click(
|
||||
dialog.getByRole("button", {
|
||||
name: "settings.copyToolUninstallCommand",
|
||||
}),
|
||||
);
|
||||
expect(mocks.copyText).toHaveBeenCalledWith("hermes uninstall --yes");
|
||||
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
platform.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps manual commands visible when copying fails", async () => {
|
||||
mocks.copyText.mockRejectedValueOnce(new Error("clipboard unavailable"));
|
||||
const user = userEvent.setup();
|
||||
await renderApps();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "appsPage.moreActions" }),
|
||||
);
|
||||
await user.click(
|
||||
await screen.findByRole("menuitem", {
|
||||
name: "settings.manualUninstallCommands",
|
||||
}),
|
||||
);
|
||||
const dialog = within(screen.getByRole("dialog"));
|
||||
const codex = within(dialog.getByText("Codex").closest("li")!);
|
||||
await user.click(codex.getByRole("button"));
|
||||
expect(mocks.error).toHaveBeenCalledWith(
|
||||
"settings.uninstallCommandCopyFailed",
|
||||
);
|
||||
expect(
|
||||
codex.getByText("npm uninstall -g @openai/codex"),
|
||||
).toBeInTheDocument();
|
||||
expect(mocks.success).not.toHaveBeenCalled();
|
||||
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("automatically detects Windows and only shows documented pnpm and Bun clients", async () => {
|
||||
const platform = vi
|
||||
.spyOn(navigator, "userAgent", "get")
|
||||
.mockReturnValue("Windows NT 10.0");
|
||||
try {
|
||||
const user = userEvent.setup();
|
||||
await renderApps();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "appsPage.moreActions" }),
|
||||
);
|
||||
await user.click(
|
||||
await screen.findByRole("menuitem", {
|
||||
name: "settings.manualUninstallCommands",
|
||||
}),
|
||||
);
|
||||
const dialog = within(screen.getByRole("dialog"));
|
||||
expect(dialog.getByText("Windows")).toBeInTheDocument();
|
||||
expect(dialog.getAllByRole("combobox")).toHaveLength(2);
|
||||
const chooseMethod = async (name: string) => {
|
||||
await user.click(
|
||||
dialog.getByRole("combobox", {
|
||||
name: "settings.manualUninstallMethod",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("option", { name: "Volta" }),
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(await screen.findByRole("option", { name }));
|
||||
};
|
||||
await user.click(
|
||||
dialog.getByRole("combobox", {
|
||||
name: "settings.manualUninstallMethod",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("option", { name: "Homebrew (cask)" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("option", { name: "Homebrew (formula)" }),
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(await screen.findByRole("option", { name: "pnpm" }));
|
||||
expect(dialog.getAllByRole("listitem")).toHaveLength(2);
|
||||
expect(
|
||||
dialog.getByText("pnpm remove -g opencode-ai"),
|
||||
).toBeInTheDocument();
|
||||
expect(dialog.getByText("pnpm remove -g openclaw")).toBeInTheDocument();
|
||||
expect(dialog.queryByText("Codex")).not.toBeInTheDocument();
|
||||
await chooseMethod("bun");
|
||||
expect(dialog.getAllByRole("listitem")).toHaveLength(1);
|
||||
expect(dialog.getByText("bun remove -g openclaw")).toBeInTheDocument();
|
||||
expect(
|
||||
dialog.queryByText("bun remove -g opencode-ai"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
dialog.getByText("settings.manualUninstallOpenClawHint"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
dialog.getByText("settings.manualUninstallBunLauncherHint"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
platform.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["updates the detected environments and per-client WSL commands", false],
|
||||
["preserves a manual environment selection during detection", true],
|
||||
] as const)("%s", async (_name, manualOverride) => {
|
||||
const platform = vi
|
||||
.spyOn(navigator, "userAgent", "get")
|
||||
.mockReturnValue("Windows NT 10.0");
|
||||
try {
|
||||
const pending = deferred<ToolVersions>();
|
||||
const getVersions = mocks.getToolVersions.getMockImplementation()!;
|
||||
const opencode = (await getVersions(["opencode"])) as ToolVersions;
|
||||
mocks.getToolVersions.mockImplementation((tools: string[]) =>
|
||||
tools.includes("opencode") ? pending.promise : getVersions(tools),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const { AppsPage } = await import("@/components/apps/AppsPage");
|
||||
mountApps(AppsPage);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "appsPage.moreActions" }),
|
||||
);
|
||||
await user.click(
|
||||
await screen.findByRole("menuitem", {
|
||||
name: "settings.manualUninstallCommands",
|
||||
}),
|
||||
);
|
||||
const dialog = within(screen.getByRole("dialog"));
|
||||
const environmentSelect = dialog.getByRole("combobox", {
|
||||
name: "settings.manualUninstallEnvironment",
|
||||
});
|
||||
expect(environmentSelect).toHaveTextContent("Windows");
|
||||
await user.click(
|
||||
dialog.getByRole("combobox", {
|
||||
name: "settings.manualUninstallMethod",
|
||||
}),
|
||||
);
|
||||
await user.click(await screen.findByRole("option", { name: "bun" }));
|
||||
expect(
|
||||
dialog.queryByText("bun remove -g opencode-ai"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const chooseEnvironment = async (name: string) => {
|
||||
await user.click(environmentSelect);
|
||||
expect(
|
||||
(await screen.findAllByRole("option")).map(
|
||||
(option) => option.textContent,
|
||||
),
|
||||
).toEqual(["Windows", "macOS", "Linux / WSL"]);
|
||||
await user.click(await screen.findByRole("option", { name }));
|
||||
};
|
||||
// Selecting the currently detected Windows environment explicitly makes
|
||||
// it an override; the pending WSL detection must not replace that choice.
|
||||
if (manualOverride) await chooseEnvironment("Windows");
|
||||
await act(async () =>
|
||||
pending.resolve(
|
||||
opencode.map((tool) => ({
|
||||
...tool,
|
||||
env_type: "wsl",
|
||||
wsl_distro: "Ubuntu",
|
||||
})),
|
||||
),
|
||||
);
|
||||
if (manualOverride) {
|
||||
expect(environmentSelect).toHaveTextContent(/^Windows$/);
|
||||
expect(
|
||||
dialog.queryByText("bun remove -g opencode-ai"),
|
||||
).not.toBeInTheDocument();
|
||||
} else {
|
||||
expect(environmentSelect).toHaveTextContent("Windows / WSL · Ubuntu");
|
||||
const opencodeRow = within(
|
||||
dialog.getByText("bun remove -g opencode-ai").closest("li")!,
|
||||
);
|
||||
expect(opencodeRow.getByText("WSL · Ubuntu")).toBeInTheDocument();
|
||||
const openclawRow = within(
|
||||
dialog.getByText("bun remove -g openclaw").closest("li")!,
|
||||
);
|
||||
expect(openclawRow.getByText("Windows")).toBeInTheDocument();
|
||||
expect(dialog.getAllByRole("combobox")).toHaveLength(2);
|
||||
|
||||
await user.click(
|
||||
dialog.getByRole("combobox", {
|
||||
name: "settings.manualUninstallMethod",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("option", { name: "Homebrew (cask)" }),
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(
|
||||
await screen.findByRole("option", { name: "Homebrew (formula)" }),
|
||||
);
|
||||
expect(
|
||||
dialog.getByText("brew uninstall --formula opencode"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
dialog.getByText("brew uninstall --formula anomalyco/tap/opencode"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
dialog.queryByText("brew uninstall --formula gemini-cli"),
|
||||
).not.toBeInTheDocument();
|
||||
}
|
||||
|
||||
await chooseEnvironment("macOS");
|
||||
await user.click(
|
||||
dialog.getByRole("combobox", {
|
||||
name: "settings.manualUninstallMethod",
|
||||
}),
|
||||
);
|
||||
await user.click(
|
||||
await screen.findByRole("option", { name: "Homebrew (cask)" }),
|
||||
);
|
||||
expect(
|
||||
dialog.getByText("brew uninstall --cask codex"),
|
||||
).toBeInTheDocument();
|
||||
expect(dialog.queryByText("WSL · Ubuntu")).not.toBeInTheDocument();
|
||||
|
||||
// A manual switch also drops methods unsupported in the new environment.
|
||||
await chooseEnvironment("Windows");
|
||||
expect(
|
||||
dialog.getByRole("combobox", {
|
||||
name: "settings.manualUninstallMethod",
|
||||
}),
|
||||
).toHaveTextContent("npm");
|
||||
expect(
|
||||
dialog.queryByText("brew uninstall --cask codex"),
|
||||
).not.toBeInTheDocument();
|
||||
await chooseEnvironment("Linux / WSL");
|
||||
await user.click(
|
||||
dialog.getByRole("combobox", {
|
||||
name: "settings.manualUninstallMethod",
|
||||
}),
|
||||
);
|
||||
await user.click(await screen.findByRole("option", { name: "bun" }));
|
||||
expect(dialog.getByText("bun remove -g opencode-ai")).toBeInTheDocument();
|
||||
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
platform.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("offers uninstall for installed tools and keeps missing tools installable", async () => {
|
||||
missing.add("codex");
|
||||
await renderApps();
|
||||
expect(
|
||||
card("Claude Code").getByRole("button", {
|
||||
name: "settings.toolUninstall",
|
||||
}),
|
||||
).toBeEnabled();
|
||||
expect(
|
||||
card("OpenCode").getByRole("button", { name: "settings.toolUninstall" }),
|
||||
).toBeEnabled();
|
||||
expect(
|
||||
card("Codex").queryByRole("button", { name: "settings.toolUninstall" }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
card("Codex").getByRole("button", { name: "settings.toolInstall" }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
it("shows the selected path and command while retaining the resolved target for uninstall validation", async () => {
|
||||
const pending = deferred<ToolUninstallPlan>();
|
||||
mocks.prepareToolUninstall.mockReturnValueOnce(pending.promise);
|
||||
await renderApps();
|
||||
fireEvent.click(
|
||||
card("Claude Code").getByRole("button", {
|
||||
name: "settings.toolUninstall",
|
||||
}),
|
||||
);
|
||||
const dialog = within(screen.getByRole("dialog"));
|
||||
expect(dialog.getByRole("status")).toHaveTextContent(
|
||||
"settings.toolUninstallPreparing",
|
||||
);
|
||||
expect(
|
||||
dialog.queryByRole("button", { name: "settings.toolUninstall" }),
|
||||
).toBeNull();
|
||||
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
|
||||
const plan = {
|
||||
...uninstallPlan("claude"),
|
||||
real_path: "C:/Users/me/.local/share/claude/versions/2.0.0",
|
||||
native: true,
|
||||
};
|
||||
await act(async () => pending.resolve(plan));
|
||||
expect(dialog.getByText(plan.path)).toBeInTheDocument();
|
||||
expect(dialog.queryByText(plan.real_path)).not.toBeInTheDocument();
|
||||
expect(dialog.getByText(plan.command)).toBeInTheDocument();
|
||||
expect(
|
||||
dialog.getByText("settings.toolUninstallNativeHint"),
|
||||
).toBeInTheDocument();
|
||||
expect(mocks.prepareToolUninstall).toHaveBeenCalledWith("claude", {});
|
||||
fireEvent.click(
|
||||
dialog.getByRole("button", { name: "settings.toolUninstall" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(mocks.runToolLifecycleAction).toHaveBeenCalledWith(
|
||||
["claude"],
|
||||
"uninstall",
|
||||
{},
|
||||
plan,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("offers only manual uninstall when a plan cannot be prepared", async () => {
|
||||
mocks.prepareToolUninstall.mockRejectedValueOnce(
|
||||
new Error("Unverified Homebrew owner: mise"),
|
||||
);
|
||||
await renderApps();
|
||||
fireEvent.click(
|
||||
card("Codex").getByRole("button", { name: "settings.toolUninstall" }),
|
||||
);
|
||||
const dialog = within(screen.getByRole("dialog"));
|
||||
expect(await dialog.findByRole("alert")).toHaveTextContent(
|
||||
"settings.toolUninstallManualRequired",
|
||||
);
|
||||
expect(
|
||||
dialog.getByText("Unverified Homebrew owner: mise"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
dialog.queryByRole("button", { name: "settings.toolUninstall" }),
|
||||
).toBeNull();
|
||||
fireEvent.click(
|
||||
dialog.getByRole("button", { name: "settings.manualUninstallCommands" }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "settings.manualUninstallCommands" }),
|
||||
).toBeInTheDocument();
|
||||
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards a cancelled preview instead of showing it for another tool", async () => {
|
||||
const first = deferred<ToolUninstallPlan>();
|
||||
mocks.prepareToolUninstall.mockReturnValueOnce(first.promise);
|
||||
await renderApps();
|
||||
fireEvent.click(
|
||||
card("Claude Code").getByRole("button", {
|
||||
name: "settings.toolUninstall",
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole("dialog")).getByRole("button", {
|
||||
name: "common.cancel",
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
card("Codex").getByRole("button", { name: "settings.toolUninstall" }),
|
||||
);
|
||||
const dialog = within(screen.getByRole("dialog"));
|
||||
expect(
|
||||
await dialog.findByText(uninstallPlan("codex").path),
|
||||
).toBeInTheDocument();
|
||||
await act(async () => first.resolve(uninstallPlan("claude")));
|
||||
expect(dialog.queryByText(uninstallPlan("claude").path)).toBeNull();
|
||||
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only offers valid Homebrew formula uninstall commands", async () => {
|
||||
const getVersions = mocks.getToolVersions.getMockImplementation()!;
|
||||
mocks.getToolVersions.mockImplementation(async (tools: string[]) =>
|
||||
((await getVersions(tools)) as ToolVersions).map((tool) => ({
|
||||
...tool,
|
||||
env_type: "linux",
|
||||
})),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
await renderApps();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "appsPage.moreActions" }),
|
||||
);
|
||||
await user.click(
|
||||
await screen.findByRole("menuitem", {
|
||||
name: "settings.manualUninstallCommands",
|
||||
}),
|
||||
);
|
||||
const dialog = within(screen.getByRole("dialog"));
|
||||
expect(dialog.getByText("Linux")).toBeInTheDocument();
|
||||
await user.click(
|
||||
dialog.getByRole("combobox", { name: "settings.manualUninstallMethod" }),
|
||||
);
|
||||
await user.click(
|
||||
await screen.findByRole("option", { name: "Homebrew (formula)" }),
|
||||
);
|
||||
expect(
|
||||
dialog.getByText("brew uninstall --formula gemini-cli"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
dialog.getByText("brew uninstall --formula opencode"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
dialog.getByText("brew uninstall --formula anomalyco/tap/opencode"),
|
||||
).toBeInTheDocument();
|
||||
expect(dialog.queryByText("Codex")).toBeNull();
|
||||
expect(dialog.queryByText("Claude Code")).toBeNull();
|
||||
expect(dialog.queryByText("Grok Build")).toBeNull();
|
||||
});
|
||||
|
||||
it("confirms uninstall, preserves the running task across navigation and refreshes to install", async () => {
|
||||
const running = deferred<void>();
|
||||
mocks.runToolLifecycleAction.mockImplementation(
|
||||
async ([tool]: string[]) => {
|
||||
await running.promise;
|
||||
missing.add(tool);
|
||||
},
|
||||
);
|
||||
const view = await renderApps();
|
||||
const uninstall = () =>
|
||||
card("Claude Code").getByRole("button", {
|
||||
name: "settings.toolUninstall",
|
||||
});
|
||||
fireEvent.click(uninstall());
|
||||
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
|
||||
fireEvent.click(
|
||||
within(screen.getByRole("dialog")).getByRole("button", {
|
||||
name: "common.cancel",
|
||||
}),
|
||||
);
|
||||
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
|
||||
fireEvent.click(uninstall());
|
||||
fireEvent.click(
|
||||
await within(screen.getByRole("dialog")).findByRole("button", {
|
||||
name: "settings.toolUninstall",
|
||||
}),
|
||||
);
|
||||
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.runToolLifecycleAction).toHaveBeenCalledWith(
|
||||
["claude"],
|
||||
"uninstall",
|
||||
{},
|
||||
uninstallPlan("claude"),
|
||||
);
|
||||
expect(uninstall()).toBeDisabled();
|
||||
expect(uninstall()).toHaveAttribute("aria-busy", "true");
|
||||
expect(updateButton("Claude Code")).toBeDisabled();
|
||||
expect(updateButton("Codex")).toBeEnabled();
|
||||
view.unmount();
|
||||
await renderApps();
|
||||
expect(uninstall()).toBeDisabled();
|
||||
expect(uninstall()).toHaveAttribute("aria-busy", "true");
|
||||
await act(async () => running.resolve());
|
||||
expect(
|
||||
card("Claude Code").queryByRole("button", {
|
||||
name: "settings.toolUninstall",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
card("Claude Code").getByRole("button", { name: "settings.toolInstall" }),
|
||||
).toBeEnabled();
|
||||
expect(
|
||||
card("Claude Code").getByText("common.notInstalled"),
|
||||
).toBeInTheDocument();
|
||||
expect(mocks.probeToolInstallations).toHaveBeenCalledWith(["claude"]);
|
||||
expect(mocks.success).toHaveBeenCalledOnce();
|
||||
expect(mocks.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps uninstall available for a broken installation", async () => {
|
||||
mocks.getToolVersions.mockImplementation(async (tools: string[]) =>
|
||||
tools.map((name) => ({
|
||||
name,
|
||||
version: null,
|
||||
latest_version: "2.0.0",
|
||||
error: "cannot run",
|
||||
installed_but_broken: true,
|
||||
env_type: "windows",
|
||||
wsl_distro: null,
|
||||
})),
|
||||
);
|
||||
await renderApps();
|
||||
expect(
|
||||
card("Claude Code").getByRole("button", {
|
||||
name: "settings.toolUninstall",
|
||||
}),
|
||||
).toBeEnabled();
|
||||
expect(
|
||||
card("Claude Code").queryByRole("button", {
|
||||
name: "settings.toolInstall",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("reports uninstall failure and unlocks the tool for retry", async () => {
|
||||
mocks.runToolLifecycleAction.mockRejectedValueOnce(
|
||||
new Error("permission denied"),
|
||||
);
|
||||
const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
await renderApps();
|
||||
fireEvent.click(
|
||||
card("Claude Code").getByRole("button", {
|
||||
name: "settings.toolUninstall",
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
await within(screen.getByRole("dialog")).findByRole("button", {
|
||||
name: "settings.toolUninstall",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(mocks.error).toHaveBeenCalledWith(
|
||||
"settings.toolUninstallFailed",
|
||||
{
|
||||
description: "permission denied",
|
||||
closeButton: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(
|
||||
card("Claude Code").getByRole("button", {
|
||||
name: "settings.toolUninstall",
|
||||
}),
|
||||
).toBeEnabled();
|
||||
expect(updateButton("Claude Code")).toBeEnabled();
|
||||
expect(mocks.success).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
errorLog.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("refreshes the remaining installation after uninstall instead of reporting a broken install", async () => {
|
||||
await renderApps();
|
||||
mocks.probeToolInstallations.mockResolvedValueOnce([
|
||||
report("claude", {
|
||||
installs: [
|
||||
{
|
||||
path: "C:/remaining/claude.exe",
|
||||
version: "2.0.0",
|
||||
runnable: true,
|
||||
error: null,
|
||||
source: "system",
|
||||
is_path_default: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
fireEvent.click(
|
||||
card("Claude Code").getByRole("button", {
|
||||
name: "settings.toolUninstall",
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
await within(screen.getByRole("dialog")).findByRole("button", {
|
||||
name: "settings.toolUninstall",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(mocks.info).toHaveBeenCalledWith(
|
||||
"settings.toolUninstallRemaining",
|
||||
{
|
||||
description: "Claude Code",
|
||||
closeButton: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(
|
||||
card("Claude Code").getByText("C:/remaining/claude.exe"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
card("Claude Code").getByRole("button", {
|
||||
name: "settings.toolUninstall",
|
||||
}),
|
||||
).toBeEnabled();
|
||||
expect(mocks.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets different tools preflight and submit together while blocking duplicate clicks", async () => {
|
||||
const claudeProbe = deferred<ToolInstallationReport[]>();
|
||||
const codexProbe = deferred<ToolInstallationReport[]>();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Profiler } from "react";
|
||||
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import i18n from "i18next";
|
||||
@@ -253,11 +254,50 @@ describe("DeepLinkImportDialog", () => {
|
||||
...extra,
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
resource: "provider",
|
||||
app: "opencode",
|
||||
},
|
||||
{
|
||||
resource: "prompt",
|
||||
app: "claude",
|
||||
content: btoa("# review"),
|
||||
enabled: true,
|
||||
},
|
||||
])(
|
||||
"shows the correct activation state on the first $resource render",
|
||||
async (link) => {
|
||||
const renderedStates: Array<string | null> = [];
|
||||
render(
|
||||
<Profiler
|
||||
id="deeplink-activation"
|
||||
onRender={() => {
|
||||
// Observe every committed render, before passive effects can fix a stale value.
|
||||
const toggle = screen.queryByRole("switch");
|
||||
if (toggle)
|
||||
renderedStates.push(toggle.getAttribute("aria-checked"));
|
||||
}}
|
||||
>
|
||||
<DeepLinkImportDialog />
|
||||
</Profiler>,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
act(() => deliverDeepLink(providerLink(link)));
|
||||
|
||||
await screen.findByRole("switch");
|
||||
expect(renderedStates.length).toBeGreaterThan(0);
|
||||
expect(new Set(renderedStates)).toEqual(new Set(["true"]));
|
||||
},
|
||||
);
|
||||
|
||||
it("starts from the link's enabled and lets the user turn switching off", async () => {
|
||||
const sent = captureImport();
|
||||
const user = userEvent.setup();
|
||||
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
||||
act(() => deliverDeepLink(providerLink({ app: "claude", enabled: true })));
|
||||
act(() =>
|
||||
deliverDeepLink(providerLink({ app: "claude", enabled: true })),
|
||||
);
|
||||
|
||||
const toggle = await screen.findByRole("switch");
|
||||
expect(toggle).toBeChecked();
|
||||
@@ -284,7 +324,9 @@ describe("DeepLinkImportDialog", () => {
|
||||
|
||||
const toggle = await screen.findByRole("switch");
|
||||
expect(toggle).not.toBeChecked();
|
||||
expect(screen.getByText("deeplink.switchAfterImport")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("deeplink.switchAfterImport"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(toggle);
|
||||
expect(
|
||||
|
||||
Reference in new issue
Block a user