diff --git a/src-tauri/src/agents.rs b/src-tauri/src/agents.rs index 35d95c8..cba22b7 100644 --- a/src-tauri/src/agents.rs +++ b/src-tauri/src/agents.rs @@ -4,6 +4,7 @@ mod backups; mod commands; mod configuration; mod deepseek_harness; +mod deepseek_desktop; mod discovery; mod launch; mod native_oauth; @@ -18,6 +19,7 @@ pub(crate) use backups::*; pub(crate) use commands::*; pub(crate) use configuration::*; pub(crate) use deepseek_harness::*; +pub(crate) use deepseek_desktop::*; pub(crate) use discovery::*; pub(crate) use launch::*; pub(crate) use native_oauth::*; diff --git a/src-tauri/src/agents/deepseek_desktop.rs b/src-tauri/src/agents/deepseek_desktop.rs new file mode 100644 index 0000000..53a4a89 --- /dev/null +++ b/src-tauri/src/agents/deepseek_desktop.rs @@ -0,0 +1,71 @@ +use super::*; + +pub(crate) fn find_deepseek_harness_desktop_application(home: &Path) -> Option { + #[cfg(target_os = "windows")] + { + let local = agent_configuration_environment("LOCALAPPDATA") + .unwrap_or_else(|| home.join("AppData/Local")); + let roots = ["ProgramFiles", "ProgramFiles(x86)"] + .into_iter() + .filter_map(agent_configuration_environment) + .collect::>(); + // Keep fixture homes isolated from applications installed on the test host. + #[cfg(test)] + let registrations = Vec::new(); + #[cfg(not(test))] + let registrations = collect_windows_desktop_registrations( + "DeepSeek Harness.exe", + windows_display_name_matches_deepseek_harness, + ); + find_windows_deepseek_harness_desktop(&local, &roots, ®istrations) + } + #[cfg(not(target_os = "windows"))] + { + let _ = home; + None + } +} + +#[cfg(target_os = "windows")] +pub(crate) fn windows_display_name_matches_deepseek_harness(name: &str) -> bool { + let name = name.trim().to_ascii_lowercase(); + name.strip_prefix("deepseek harness") + .is_some_and(|rest| rest.is_empty() || rest.starts_with([' ', '('])) +} + +#[cfg(target_os = "windows")] +pub(crate) fn find_windows_deepseek_harness_desktop( + local: &Path, + program_files: &[PathBuf], + registrations: &[WindowsZcodeRegistration], +) -> Option { + let registered = registrations.iter().filter_map(|entry| { + parse_windows_desktop_registration(entry.kind, &entry.value, "DeepSeek Harness.exe") + }); + let defaults = [ + local.join("Programs/DeepSeek Harness/DeepSeek Harness.exe"), + local.join("DeepSeek Harness/DeepSeek Harness.exe"), + ]; + registered + .chain(defaults) + .chain( + program_files + .iter() + .map(|root| root.join("DeepSeek Harness/DeepSeek Harness.exe")), + ) + .find(|path| path.is_file()) +} + +pub(crate) fn read_deepseek_harness_desktop_version(application: &Path) -> Option { + #[cfg(target_os = "windows")] + { + // Electron's PE ProductVersion drops prerelease suffixes; package.json retains them. + read_codex_asar_version(&application.parent()?.join("resources/app.asar")) + .or_else(|| read_windows_executable_version(application)) + } + #[cfg(not(target_os = "windows"))] + { + let _ = application; + None + } +} diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index 9707c5a..f5cd1f6 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -1015,6 +1015,9 @@ pub(crate) fn inspect_agent_config( let opencode_desktop_application = (client == AgentClient::OpenCode) .then(|| find_opencode_desktop_application(home)) .flatten(); + let harness_desktop_application = (client == AgentClient::DeepSeekHarness) + .then(|| find_deepseek_harness_desktop_application(home)) + .flatten(); let app_version = match client { AgentClient::ClaudeDesktop => read_claude_desktop_version(home), AgentClient::Codex => codex_app_installation @@ -1023,13 +1026,21 @@ pub(crate) fn inspect_agent_config( AgentClient::OpenCode => opencode_desktop_application .as_deref() .and_then(read_opencode_desktop_version), - AgentClient::DeepSeekHarness => read_deepseek_harness_profile_version(home), + AgentClient::DeepSeekHarness => harness_desktop_application + .as_deref() + .and_then(read_deepseek_harness_desktop_version), AgentClient::ZCode => executable.as_deref().and_then(read_zcode_app_version), AgentClient::WorkBuddy => executable.as_deref().and_then(read_workbuddy_app_version), _ => None, }; - let version = cli_version.clone().or_else(|| app_version.clone()); - let app_installed = codex_app_installation.is_some() || opencode_desktop_application.is_some(); + let version = cli_version.clone().or_else(|| app_version.clone()).or_else(|| { + (client == AgentClient::DeepSeekHarness) + .then(|| read_deepseek_harness_profile_version(home)) + .flatten() + }); + let app_installed = codex_app_installation.is_some() + || opencode_desktop_application.is_some() + || harness_desktop_application.is_some(); let installed = agent_installation_detected( client, version.as_deref(), @@ -1190,6 +1201,13 @@ pub(crate) fn agent_launch_targets( detail: format!("{} web", path_to_string(executable)), }); } + if app_installed { + targets.push(AgentLaunchTarget { + id: "app".to_string(), + label: "DeepSeek Harness Desktop".to_string(), + detail: "DeepSeek Harness Desktop".to_string(), + }); + } } _ => { if let Some(executable) = executable { diff --git a/src-tauri/src/agents/launch.rs b/src-tauri/src/agents/launch.rs index 7704ff5..b9a7197 100644 --- a/src-tauri/src/agents/launch.rs +++ b/src-tauri/src/agents/launch.rs @@ -202,6 +202,11 @@ pub(crate) fn launch_agent( } (AgentClient::Codex, "app") => launch_codex_desktop(&home), (AgentClient::OpenCode, "app") => launch_opencode_desktop(&home), + (AgentClient::DeepSeekHarness, "app") => { + let executable = find_deepseek_harness_desktop_application(&home) + .ok_or_else(|| "DeepSeek Harness Desktop application not found".to_string())?; + launch_desktop_agent(&executable, "DeepSeek Harness Desktop") + } (AgentClient::ClaudeDesktop | AgentClient::ZCode | AgentClient::WorkBuddy, "cli") => { Err(format!("{} does not support CLI launch mode", client.name())) } diff --git a/src-tauri/src/agents/windows_probe.rs b/src-tauri/src/agents/windows_probe.rs index 71306f6..69ead02 100644 --- a/src-tauri/src/agents/windows_probe.rs +++ b/src-tauri/src/agents/windows_probe.rs @@ -238,7 +238,7 @@ pub(crate) fn collect_windows_zcode_registrations() -> Vec bool) -> Vec { +pub(crate) fn collect_windows_desktop_registrations(executable: &str, matches_name: fn(&str) -> bool) -> Vec { let mut registrations = Vec::new(); for root in current_version_roots() { if let Some(path) = RegistryKey::open( diff --git a/src-tauri/src/tests.rs b/src-tauri/src/tests.rs index 73a6bff..2fce440 100644 --- a/src-tauri/src/tests.rs +++ b/src-tauri/src/tests.rs @@ -2,6 +2,7 @@ use super::*; mod agent_configuration; mod deepseek_harness_catalog; +mod deepseek_desktop; mod agent_paths; mod agent_state; mod agent_transactions; diff --git a/src-tauri/src/tests/deepseek_desktop.rs b/src-tauri/src/tests/deepseek_desktop.rs new file mode 100644 index 0000000..6794a8c --- /dev/null +++ b/src-tauri/src/tests/deepseek_desktop.rs @@ -0,0 +1,105 @@ +#[cfg(target_os = "windows")] +use super::support::agent_test_home; +use super::*; + +#[test] +fn deepseek_desktop_launch_targets_are_independent_of_cli_and_version() { + let client = AgentClient::DeepSeekHarness; + assert!(agent_installation_detected(client, None, false, true)); + let desktop = agent_launch_targets(client, None, None, true); + assert_eq!(desktop.len(), 1); + assert_eq!(desktop[0].id, "app"); + let both = agent_launch_targets(client, Some(Path::new("dsh")), None, true); + assert_eq!( + both.iter() + .map(|target| target.id.as_str()) + .collect::>(), + ["cli", "app"] + ); + // An installed profile alone must not create a Desktop launch target. + assert!(agent_launch_targets(client, None, Some("0.2.0"), false).is_empty()); + assert!(!agent_installation_detected(client, None, false, false)); +} + +#[cfg(target_os = "windows")] +#[test] +fn deepseek_desktop_windows_finds_custom_and_default_installations() { + let home = agent_test_home("deepseek-desktop-discovery"); + let local = home.join("local"); + let custom = home.join("custom/DeepSeek Harness.exe"); + let default = local.join("Programs/DeepSeek Harness/DeepSeek Harness.exe"); + fs::create_dir_all(custom.parent().unwrap()).unwrap(); + fs::create_dir_all(default.parent().unwrap()).unwrap(); + fs::write(&custom, []).unwrap(); + for (kind, value) in [ + ("directory", path_to_string(custom.parent().unwrap())), + ("icon", format!("\"{}\",0", path_to_string(&custom))), + ( + "uninstaller", + format!( + "\"{}\" /S", + path_to_string( + &custom + .parent() + .unwrap() + .join("Uninstall DeepSeek Harness.exe") + ) + ), + ), + ] { + let entries = [WindowsZcodeRegistration { kind, value }]; + assert_eq!( + find_windows_deepseek_harness_desktop(&local, &[], &entries), + Some(custom.clone()) + ); + } + fs::remove_file(&custom).unwrap(); + let stale = [WindowsZcodeRegistration { + kind: "executable", + value: path_to_string(&custom), + }]; + assert_eq!( + find_windows_deepseek_harness_desktop(&local, &[], &stale), + None + ); + fs::write(&default, []).unwrap(); + assert_eq!( + find_windows_deepseek_harness_desktop(&local, &[], &stale), + Some(default) + ); + fs::remove_dir_all(home).unwrap(); +} + +#[cfg(target_os = "windows")] +#[test] +fn deepseek_desktop_windows_registration_name_is_specific() { + for name in [ + "DeepSeek Harness", + "DeepSeek Harness 0.2.0-rc.2", + "deepseek harness (user)", + ] { + assert!(windows_display_name_matches_deepseek_harness(name)); + } + for name in [ + "DeepSeek", + "DeepSeek HarnessHelper", + "Other DeepSeek Harness", + ] { + assert!(!windows_display_name_matches_deepseek_harness(name)); + } +} + +#[cfg(target_os = "windows")] +#[test] +#[ignore = "Requires DeepSeek Harness Desktop installed on the host"] +fn deepseek_desktop_installed_host_probe() { + let registrations = collect_windows_desktop_registrations( + "DeepSeek Harness.exe", + windows_display_name_matches_deepseek_harness, + ); + let local = PathBuf::from(env::var_os("LOCALAPPDATA").unwrap()); + let app = find_windows_deepseek_harness_desktop(&local, &[], ®istrations) + .expect("installed Desktop should be detected"); + let version = read_deepseek_harness_desktop_version(&app).expect("Desktop version"); + println!("DeepSeek Harness Desktop: {} ({version})", app.display()); +} diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 50c08d5..deebada 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -1083,13 +1083,15 @@ export const jaOverrides = { 'agents.description.kimiCode': 'CPA の OpenAI Chat Completions API と動的モデルカタログを使用', 'agents.description.grokBuild': 'CPA の OpenAI Chat Completions API とカスタムモデルカタログを使用', 'agents.description.pi': 'CLIProxyAPI プロバイダーを使用して Pi CLI のモデルを動的に読み込みます', + 'agents.clients.switch': 'クライアントを切り替え', + 'agents.clients.switchHint': 'クライアントを検索して選択します。選択中のクライアントはショートカット一覧に表示されます。', 'agents.clients.manage': 'クライアントを管理', - 'agents.clients.description': '一覧に表示するクライアントを選択します。表示のみを変更し、クライアントの設定は変更しません。', + 'agents.clients.description': 'ショートカットに優先表示するクライアントを選択します。選択中のクライアントは常に表示され、その他は「クライアントを切り替え」から選べます。', 'agents.clients.search': 'クライアントを検索', 'agents.clients.shown': '{count} / {total} 件を表示', 'agents.clients.selected': '{count} 件のクライアントを選択中', 'agents.clients.automatic': '自動表示に戻す', - 'agents.clients.automaticHint': '既定では、インストール済みまたは設定済みのクライアントを表示します。', + 'agents.clients.automaticHint': '既定では、インストール済みまたは設定済みのクライアントを優先表示します。', 'agents.clients.minimum': '少なくとも 1 件のクライアントを表示してください。', 'agents.clients.noResults': '一致するクライアントが見つかりません', 'agents.list.detecting': '検出中', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 2413c48..22c76e6 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1083,13 +1083,15 @@ export const en: Record = { 'agents.description.kimiCode': 'Uses the CPA OpenAI Chat Completions API and dynamic model catalog', 'agents.description.grokBuild': 'Uses the CPA OpenAI Chat Completions API and custom model catalog', 'agents.description.pi': 'Loads models dynamically in Pi through the CLIProxyAPI provider', + 'agents.clients.switch': 'Switch Client', + 'agents.clients.switchHint': 'Search and select a client. Your current selection stays in the shortcut list.', 'agents.clients.manage': 'Manage Clients', - 'agents.clients.description': 'Choose which clients appear in the list. This only changes visibility, not client settings.', + 'agents.clients.description': 'Choose which clients take priority in the shortcuts. Your current selection stays visible; use Switch Client to access any other client.', 'agents.clients.search': 'Search clients', 'agents.clients.shown': 'Showing {count} / {total}', 'agents.clients.selected': '{count} clients selected', 'agents.clients.automatic': 'Restore automatic display', - 'agents.clients.automaticHint': 'Installed or configured clients are shown by default.', + 'agents.clients.automaticHint': 'Installed or configured clients take priority by default.', 'agents.clients.minimum': 'Keep at least one client visible.', 'agents.clients.noResults': 'No matching clients found', 'agents.list.detecting': 'Detecting', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index c5597ba..0482713 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -1082,13 +1082,15 @@ export const zhCN = { 'agents.description.kimiCode': '使用 CPA 的 OpenAI Chat Completions 接口和动态模型目录', 'agents.description.grokBuild': '使用 CPA 的 OpenAI Chat Completions 接口和自定义模型目录', 'agents.description.pi': '使用 Pi CLI 和 CLIProxyAPI 提供方动态加载模型', + 'agents.clients.switch': '切换客户端', + 'agents.clients.switchHint': '搜索并选择客户端,当前选择会显示在快捷列表中。', 'agents.clients.manage': '管理客户端', - 'agents.clients.description': '选择要显示在列表中的客户端,仅调整显示,不修改客户端配置。', + 'agents.clients.description': '选择快捷列表优先展示的客户端。当前选择始终保留,其他客户端可通过“切换客户端”访问。', 'agents.clients.search': '搜索客户端', 'agents.clients.shown': '显示 {count} / {total}', 'agents.clients.selected': '已选择 {count} 个客户端', 'agents.clients.automatic': '恢复自动显示', - 'agents.clients.automaticHint': '默认显示已安装或已有配置的客户端。', + 'agents.clients.automaticHint': '默认优先展示已安装或已有配置的客户端。', 'agents.clients.minimum': '至少保留一个客户端。', 'agents.clients.noResults': '未找到匹配的客户端', 'agents.list.detecting': '正在检测', diff --git a/src/pages/AgentClientList.css b/src/pages/AgentClientList.css index 058320a..611e46e 100644 --- a/src/pages/AgentClientList.css +++ b/src/pages/AgentClientList.css @@ -3,12 +3,23 @@ contain: size; } -.agents-page .agent-list-items { +.agents-page .agent-client-list .agent-list-items { gap: 4px; + flex: 1 1 auto; + grid-auto-flow: row; + grid-auto-columns: auto; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: none; + grid-auto-rows: 56px; + min-height: 56px; + overflow: hidden; + scrollbar-gutter: auto; + padding: 0; } -.agents-page .agent-list-items button { +.agents-page .agent-client-list .agent-list-items button { min-height: 56px; + height: 56px; padding: 8px; gap: 8px; } @@ -29,16 +40,46 @@ } .agent-client-list-footer { - display: flex; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; flex: none; align-items: center; justify-content: space-between; - flex-wrap: wrap; gap: 8px; border-top: 1px solid var(--border-primary); padding-top: 10px; } +.agent-client-switch { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + grid-column: 1 / -1; +} + +.agent-client-list-footer > .compact-button { + width: auto; +} + +.agent-client-manager button.agent-client-option { + background: transparent; + color: inherit; + text-align: left; +} + +.agent-client-manager button.agent-client-option:hover, +.agent-client-manager button.agent-client-option[aria-pressed="true"] { + border-color: var(--ui-accent); + background: var(--ui-accent-soft); +} + +.agent-client-manager button.agent-client-option > svg { + flex: none; + margin-left: auto; +} + .agent-client-list-footer small, .agent-client-manager small, .agent-client-manager p, @@ -221,7 +262,25 @@ } @container agent-workspace (max-width: 760px) { - .agents-page:not(.agents-page-embedded) .agent-client-list { + .agents-page:not(.agents-page-embedded) .agent-client-list, + .app-shell:has(.agents-page:not(.agents-page-embedded)) .agent-client-list { contain: none; + height: 270px; + max-height: 270px; + } +} + +/* These three compact controls fit side by side at ordinary desktop widths. */ +@container agent-panel (min-width: 620px) and (max-width: 760px) { + .agent-claude-desktop-mapping-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .agent-claude-code-runtime-settings { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.2fr); + } + + .agent-claude-code-disable-compact { + grid-column: auto; } } diff --git a/src/pages/AgentClientList.tsx b/src/pages/AgentClientList.tsx index 4ca31c3..a997a40 100644 --- a/src/pages/AgentClientList.tsx +++ b/src/pages/AgentClientList.tsx @@ -1,5 +1,5 @@ -import { useEffect, useId, useRef, useState, type ReactNode } from 'react'; -import { RefreshCw, Search, SlidersHorizontal, X } from 'lucide-react'; +import { useEffect, useLayoutEffect, useId, useRef, useState, type ReactNode } from 'react'; +import { Check, RefreshCw, Search, SlidersHorizontal, X } from 'lucide-react'; import { MessageNotice } from '../appNotice'; import { useDialogFocusTrap } from '../components/useDialogFocusTrap'; import { useI18n } from '../i18n'; @@ -41,22 +41,39 @@ export function AgentClientList({ const { t } = useI18n(); const [visibleIds, setVisibleIds] = useState(() => readVisibleClients(clients)); const [managing, setManaging] = useState(false); + const [picking, setPicking] = useState(false); + const [capacity, setCapacity] = useState(1); + const listRef = useRef(null); + const close = () => { setManaging(false); setPicking(false); }; const [draftIds, setDraftIds] = useState(null); const [query, setQuery] = useState(''); // Keep the current client reachable even when detection fails or it was uninstalled. const automaticIds = clients.filter((client) => client.detected || client.id === selected) .map((client) => client.id); const visibleClients = clients.filter((client) => (visibleIds ?? automaticIds).includes(client.id)); + const shortcuts = visibleClients.slice(0, capacity); + const current = visibleClients.find((client) => client.id === selected); + if (current && !shortcuts.includes(current)) shortcuts[shortcuts.length - 1] = current; const draftSelection = draftIds ?? automaticIds; const searchRef = useRef(null); const titleId = useId(); const descriptionId = useId(); const dialogRef = useDialogFocusTrap({ - active: managing, - onEscape: () => setManaging(false), + active: managing || picking, + onEscape: close, initialFocusRef: searchRef, }); + useLayoutEffect(() => { + const list = listRef.current; + if (!list) return; + const resize = () => setCapacity(Math.max(1, Math.min(6, Math.floor((list.clientHeight + 4) / 60)))); + resize(); + const observer = new ResizeObserver(resize); + observer.observe(list); + return () => observer.disconnect(); + }, []); + useEffect(() => { if (!busy && visibleIds && !visibleIds.includes(selected) && visibleIds.length) { onSelect(visibleIds[0]); @@ -64,8 +81,19 @@ export function AgentClientList({ }, [busy, onSelect, selected, visibleIds]); useEffect(() => { - if (managing) dialogRef.current?.showModal(); - }, [dialogRef, managing]); + if (managing || picking) dialogRef.current?.showModal(); + }, [dialogRef, managing, picking]); + + const pick = (id: Id) => { + // A manually hidden client can be brought back directly from the switcher. + if (visibleIds && !visibleIds.includes(id)) { + const next = [...visibleIds, id]; + setVisibleIds(next); + try { window.localStorage.setItem(VISIBLE_CLIENTS_KEY, JSON.stringify(next)); } catch { /* Session only. */ } + } + onSelect(id); + close(); + }; const save = () => { if (!draftSelection.length) return; @@ -95,8 +123,8 @@ export function AgentClientList({ {error ? : null} -
- {visibleClients.map((client) => )}
- {t('agents.clients.shown', { count: visibleClients.length, total: clients.length })} + + {t('agents.clients.shown', { count: shortcuts.length, total: clients.length })}
- {managing ? { event.preventDefault(); setManaging(false); }}> + {managing || picking ? { event.preventDefault(); close(); }}>
-

{t('agents.clients.manage')}

-
-

{t('agents.clients.description')}

+

{t(picking ? 'agents.clients.switchHint' : 'agents.clients.description')}

- {matches.map((client) =>
-
+ {managing ? <>
@@ -148,7 +185,7 @@ export function AgentClientList({
- + : null}
: null} ; } diff --git a/src/pages/AgentControls.tsx b/src/pages/AgentControls.tsx index a8a16ad..17359a7 100644 --- a/src/pages/AgentControls.tsx +++ b/src/pages/AgentControls.tsx @@ -41,12 +41,12 @@ export function AgentRunControls({ return
{t('agents.run.title')}
- {dualTargets ? <>{launchButton(cli, 'cli')}{launchButton(app, 'app')} - : harness?.running ? : launchButton(targets[0] ?? null)} + : dualTargets ? launchButton(cli, 'cli') : launchButton(targets[0] ?? null)} + {dualTargets ? launchButton(app, 'app') : null} {desktop ?
diff --git a/src/pages/PluginInstallDialog.tsx b/src/pages/PluginInstallDialog.tsx index 6078c3c..6f6ce30 100644 --- a/src/pages/PluginInstallDialog.tsx +++ b/src/pages/PluginInstallDialog.tsx @@ -1,12 +1,12 @@ import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { invoke } from '@tauri-apps/api/core'; -import { AlertTriangle, ExternalLink, X } from 'lucide-react'; +import { ExternalLink, X } from 'lucide-react'; import { useDialogFocusTrap } from '../components/useDialogFocusTrap'; import { useI18n } from '../i18n'; import { pluginText } from '../i18n/plugins'; import { pluginStoreApi, type PluginStoreEntry, type PluginStoreInstallResult } from '../services/plugins'; -import { buildRepositoryURL, getPluginConfirmToken, isOfficialPlugin } from '../services/pluginResources'; +import { buildRepositoryURL } from '../services/pluginResources'; import { isValidManualReleaseTag, supportsPluginVersionSelection } from '../services/pluginReleaseVersions'; export function PluginInstallDialog({ entry, onClose, onInstalled }: { @@ -17,8 +17,6 @@ export function PluginInstallDialog({ entry, onClose, onInstalled }: { const { t, locale } = useI18n(); const pt = (key: Parameters[0]) => pluginText(key, locale); const [version, setVersion] = useState(''); - const [step, setStep] = useState(0); - const [typed, setTyped] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const pending = useRef(false); @@ -26,14 +24,12 @@ export function PluginInstallDialog({ entry, onClose, onInstalled }: { useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); // The dialog remains open throughout installation, including on errors. const ref = useDialogFocusTrap({ onEscape: busy ? undefined : onClose, preventEscape: busy }); - const thirdParty = !isOfficialPlugin(entry); - const token = getPluginConfirmToken(entry); const canSelectVersion = supportsPluginVersionSelection(entry.installType); const validVersion = !version.trim() || isValidManualReleaseTag(version); const repository = buildRepositoryURL(entry.repository); const action = entry.installed ? pt('update') : pt('install'); const install = async () => { - if (pending.current || !validVersion || (thirdParty && (step < 2 || typed.trim() !== token))) return; + if (pending.current || !validVersion) return; pending.current = true; setBusy(true); setError(''); try { const result = await pluginStoreApi.install(entry.id, { sourceId: entry.sourceId, version: canSelectVersion ? version.trim() : undefined }); @@ -46,13 +42,11 @@ export function PluginInstallDialog({ entry, onClose, onInstalled }: {

{action} · {entry.name || entry.id}

-
{pt('source')}
{entry.sourceName || entry.sourceId}{entry.sourceUrl}
{pt('repository')}
{entry.repository || '—'}{repository && }
- {step === 0 && <>

{pt('installHint')}

{canSelectVersion && }} - {thirdParty && step >= 1 &&

} - {thirdParty && step === 2 && } +
{pt('source')}
{entry.sourceName || entry.sourceId}{entry.sourceUrl}
{pt('repository')}
{entry.repository || '—'}{repository && }
+ {canSelectVersion && } {error &&

{error}

}
-
{thirdParty && step < 2 ? : }
+
, document.body); } diff --git a/src/pages/PluginsPage.css b/src/pages/PluginsPage.css index 96406d9..3277fd5 100644 --- a/src/pages/PluginsPage.css +++ b/src/pages/PluginsPage.css @@ -1,17 +1,19 @@ .plugins-page { display: flex; flex-direction: column; gap: 20px; min-width: 0; max-width: 1500px; margin: 0 auto; padding: 8px 0 32px; } .plugins-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; } -.plugins-heading h1 { margin: 8px 0; font-size: 28px; letter-spacing: -.6px; } +.plugins-page .plugins-heading > button { width: auto; flex: none; } +.plugins-heading h1 { margin: 0 0 6px; font-size: 24px; letter-spacing: -.4px; } .plugins-heading p { margin: 0; color: var(--text-tertiary); line-height: 1.7; } -.plugins-eyebrow { display: flex; gap: 8px; align-items: center; color: var(--primary-color); font-size: 12px; font-weight: 650; } -.plugin-summary { display: flex; flex-wrap: wrap; gap: 16px 24px; align-items: center; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 10px; padding: 18px 20px; } +.plugin-summary { display: flex; flex-wrap: wrap; gap: 12px 24px; align-items: center; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; padding: 14px 18px; } .plugin-summary > span { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-tertiary); overflow-wrap: anywhere; min-width: 0; } .plugin-summary > span:last-child { margin-left: auto; } .plugin-global-toggle { display: flex; align-items: center; gap: 10px; font-size: 14px; font-weight: 600; } .plugin-global-toggle input { width: 18px; height: 18px; accent-color: var(--primary-color); } .plugin-banner { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 18px; margin: 0; border-radius: 8px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 13px; line-height: 1.65; overflow-wrap: anywhere; } -.plugin-tabs { display: flex; gap: 24px; border-bottom: 1px solid var(--border-color); } -.plugin-tabs button { border: 0; border-bottom: 2px solid transparent; padding: 12px 2px; border-radius: 0; background: transparent; color: var(--text-tertiary); font-size: 14px; } -.plugin-tabs button.active { border-color: var(--primary-color); color: var(--primary-color); font-weight: 650; } +.plugin-navigation { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 20px; } +.plugin-navigation > .plugin-toolbar { flex: 1 1 260px; justify-content: flex-end; } +.plugin-tabs { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-tertiary); } +.plugin-tabs button { border: 1px solid transparent; padding: 7px 14px; border-radius: 7px; background: transparent; color: var(--text-tertiary); font-size: 13px; } +.plugin-tabs button.active { border-color: var(--border-color); background: var(--bg-card); color: var(--primary-color); font-weight: 650; box-shadow: 0 1px 2px rgb(0 0 0 / 4%); } .plugin-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } .plugin-search { display: flex; align-items: center; gap: 9px; max-width: 440px; min-width: 180px; flex: 1; padding: 0 12px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-card); color: var(--text-tertiary); } .plugin-search input { border: 0; background: transparent; color: var(--text-primary); width: 100%; min-width: 0; padding: 11px 0; font: inherit; font-size: 13px; outline: none; } @@ -19,19 +21,24 @@ .plugin-toolbar select { border: 1px solid var(--border-color); border-radius: 8px; color: var(--text-primary); background: var(--bg-card); padding: 10px 12px; } .plugin-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 330px), 1fr)); gap: 16px; } .plugin-card { min-width: 0; display: flex; flex-direction: column; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; padding: 20px; gap: 12px; } -.plugin-card-title { display: flex; align-items: center; gap: 12px; min-width: 0; flex-wrap: wrap; } -.plugin-card-title > div { flex: 1; min-width: 100px; } +.plugin-card-title { display: grid; grid-template-columns: 40px minmax(0, 1fr) fit-content(38%); align-items: center; gap: 8px 12px; min-width: 0; } +.plugin-card-title > div { min-width: 0; } +.plugin-card-title > .plugin-badge { justify-self: end; white-space: normal; overflow-wrap: anywhere; line-height: 1.5; } .plugin-card-title h2 { font-size: 15px; margin: 0 0 5px; overflow-wrap: anywhere; } .plugin-card-title code { color: var(--text-tertiary); font-size: 11px; overflow-wrap: anywhere; } -.plugin-icon { display: grid; place-items: center; width: 44px; height: 44px; flex-shrink: 0; border-radius: 10px; color: var(--primary-color); background: var(--ui-accent-soft); } +.plugin-icon { display: grid; place-items: center; width: 40px; height: 40px; flex-shrink: 0; border-radius: 10px; color: var(--primary-color); background: var(--ui-accent-soft); } .plugin-badge { display: inline-flex; padding: 4px 8px; border-radius: 5px; background: var(--bg-tertiary); color: var(--text-tertiary); font-size: 11px; white-space: nowrap; } .plugin-badge.active { color: var(--success-badge-text); background: var(--success-badge-bg); } .plugin-badge.warning { color: var(--amber-text); background: var(--theme-fff7e9); } .plugin-meta, .plugin-path { color: var(--text-tertiary); font-size: 12px; margin: 0; line-height: 1.7; overflow-wrap: anywhere; } .plugin-path { font-family: ui-monospace, monospace; font-size: 11px; } +.plugin-local-card .plugin-path { padding: 8px 10px; border-radius: 6px; background: var(--bg-secondary); } +.plugin-local-card .plugin-card-description { padding: 10px 12px; border-radius: 8px; background: var(--bg-secondary); color: var(--text-tertiary); font-size: 12px; } +.plugin-local-card .plugin-card-title h2:last-child { margin-bottom: 0; } .plugin-card-description { font-size: 13px; line-height: 1.7; color: var(--text-secondary); margin: 0; overflow-wrap: anywhere; } .plugin-card-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding-top: 12px; margin-top: auto; border-top: 1px solid var(--border-color); } .plugin-card-actions button { font-size: 12px; min-height: 34px; } +.plugins-page .plugin-card-actions > button { width: auto; } .plugin-card-actions .primary-button { margin-left: auto; } .plugin-delete { margin-left: auto; color: var(--danger-color); } .plugin-menus { display: flex; flex-wrap: wrap; gap: 8px; } @@ -49,18 +56,25 @@ .plugin-field textarea { resize: vertical; font-family: ui-monospace, monospace; font-weight: normal; line-height: 1.7; } .plugin-field small { font-size: 12px; color: var(--text-tertiary); font-weight: normal; line-height: 1.7; } .plugin-field code { overflow-wrap: anywhere; padding: 10px; border-radius: 5px; background: var(--bg-tertiary); } -.config-dialog.plugin-install-dialog { width: min(620px, 100%); padding: 0; overflow: auto; max-height: calc(100dvh - 40px); color: var(--text-primary); } -.plugin-install-dialog .config-dialog-heading { padding: 20px; border-bottom: 1px solid var(--border-color); } -.plugin-install-dialog h2 { font-size: 17px; overflow-wrap: anywhere; } -.plugin-install-body { display: grid; gap: 20px; padding: 20px; font-size: 13px; line-height: 1.7; } +.config-dialog.plugin-install-dialog { display: flex; flex-direction: column; gap: 0; width: min(620px, 100%); height: auto; min-width: 0; padding: 0; overflow: hidden; max-height: calc(100dvh - 40px); color: var(--text-primary); } +.plugin-install-dialog .config-dialog-heading { flex-shrink: 0; padding: 20px; border-bottom: 1px solid var(--border-color); } +.plugin-install-dialog .config-dialog-heading h2 { min-width: 0; font-size: 17px; white-space: normal; overflow-wrap: anywhere; } +.plugin-install-dialog .config-dialog-heading button { flex-shrink: 0; } +.plugin-install-body { display: grid; gap: 20px; min-height: 0; overflow-y: auto; padding: 20px; font-size: 13px; line-height: 1.7; } .plugin-install-body p { margin: 0; } -.plugin-install-origin { display: grid; grid-template-columns: auto 1fr; gap: 10px 16px; margin: 0; font-size: 12px; } +.plugin-install-origin { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px 16px; margin: 0; font-size: 12px; } .plugin-install-origin dt { color: var(--text-tertiary); } -.plugin-install-origin dd { margin: 0; overflow-wrap: anywhere; } +.plugin-install-origin dd { min-width: 0; margin: 0; overflow-wrap: anywhere; } +.plugin-install-repository { display: flex; align-items: flex-start; gap: 8px; } +.plugin-install-repository span { min-width: 0; } +.plugin-install-repository button { flex-shrink: 0; } .plugin-install-origin small { display: block; color: var(--text-tertiary); } .plugin-warning { display: flex; gap: 10px; padding: 16px; background: var(--bg-error-light); border-radius: 8px; color: var(--error-color); } .plugin-warning svg { flex-shrink: 0; } -.plugin-dialog-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 18px 20px; border-top: 1px solid var(--border-color); } +.plugin-dialog-actions { display: flex; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; gap: 10px; padding: 18px 20px; border-top: 1px solid var(--border-color); } +.plugins-page .primary-button, .plugins-page .secondary-button, .plugin-install-dialog .primary-button, .plugin-install-dialog .secondary-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; } +.plugins-page button svg { flex-shrink: 0; } .plugin-resource { display: flex; flex-direction: column; gap: 16px; } .plugin-resource iframe { width: 100%; min-height: 65vh; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-card); } -@media (max-width: 700px) { .plugins-heading { align-items: flex-start; gap: 12px; } .plugins-heading h1 { font-size: 24px; } .plugin-summary { padding: 14px; gap: 14px; } .plugin-summary > span { width: 100%; } .plugin-summary > span:last-child { margin-left: 0; } .plugin-card, .plugin-settings { padding: 16px; } .plugin-banner { flex-wrap: wrap; } .plugin-tabs { gap: 20px; } } +@media (max-width: 700px) { .plugins-heading { align-items: flex-start; gap: 12px; } .plugins-heading h1 { font-size: 24px; } .plugin-summary { padding: 14px; gap: 14px; } .plugin-summary > span { width: 100%; } .plugin-summary > span:last-child { margin-left: 0; } .plugin-card, .plugin-settings { padding: 16px; } .plugin-banner { flex-wrap: wrap; } .plugin-navigation > .plugin-toolbar { flex-basis: 100%; } .plugin-search { max-width: none; } } +@media (max-width: 420px) { .plugin-card-title { grid-template-columns: 40px minmax(0, 1fr); } .plugin-card-title > .plugin-badge { grid-column: 2; justify-self: start; } .plugin-tabs button { padding-inline: 10px; } } diff --git a/src/pages/PluginsPage.tsx b/src/pages/PluginsPage.tsx index 6f0f5b7..0027358 100644 --- a/src/pages/PluginsPage.tsx +++ b/src/pages/PluginsPage.tsx @@ -128,30 +128,33 @@ export function PluginsPage() { const locked = busy || loading || Boolean(configPlugin || installEntry || oauthPlugin); return
-

{pt('title')}

{pt('description')}

+

{pt('title')}

{pt('description')}

{error &&
{error}
} {supported === false ?

{pt('unsupported')}

: supported === null ?
{loading ? t('common.loading') : pt('retry')}
: <>
{pt('installed')} {installedCount}{configOnlyCount > 0 && <> · {pt('configOnly')} {configOnlyCount}}
{data && !data.pluginsEnabled &&

{pt('disabledHint')}

} {restartRequired &&
{pt('restart')}
} {resource ?
{resource.entry.label}