From 32b47575b5565e3cc492b9be74a645fa2e60c85d Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Mon, 24 Aug 2026 21:47:30 +0800 Subject: [PATCH] feat: redesign version management and restore core after updates - Add a dedicated version management page - Improve app, core, and model catalog status displays - Preserve the core runtime state during updates - Automatically restart the core after successful, failed, or cancelled updates - Add tests for version management and core runtime restoration --- src-tauri/src/core_runtime.rs | 112 +++- src-tauri/src/tests/core_runtime.rs | 34 + src/App.tsx | 7 +- src/i18n/ja.ts | 20 + src/i18n/locales/en.ts | 20 + src/i18n/locales/zh-CN.ts | 20 + src/pages/Kernel.tsx | 981 ++-------------------------- src/pages/VersionManagementPage.tsx | 975 +++++++++++++++++++++++++++ src/styles.css | 666 ++++++++++--------- tests/versionManagement.test.ts | 19 + 10 files changed, 1609 insertions(+), 1245 deletions(-) create mode 100644 src/pages/VersionManagementPage.tsx create mode 100644 tests/versionManagement.test.ts diff --git a/src-tauri/src/core_runtime.rs b/src-tauri/src/core_runtime.rs index d26e275..3026dec 100644 --- a/src-tauri/src/core_runtime.rs +++ b/src-tauri/src/core_runtime.rs @@ -24,14 +24,22 @@ pub(crate) fn detect_bundled_core() -> Result, String> { #[tauri::command] pub(crate) fn install_bundled_core( + app: tauri::AppHandle, window: tauri::Window, state: tauri::State<'_, CoreDownloadState>, + process_state: tauri::State<'_, CoreProcessState>, + gui_config_state: tauri::State<'_, GuiConfigState>, ) -> Result { let (info, archive_path) = bundled_core_archive()? .ok_or_else(|| "当前发行包没有匹配此系统架构的内置内核".to_string())?; let token = CancellationToken::new(); state.start(token, Some(info.version.clone()))?; - let result = install_bundled_core_inner(&window, state.inner(), &info, &archive_path); + let result = install_core_with_runtime_restore( + &app, + process_state.inner(), + gui_config_state.inner(), + || install_bundled_core_inner(&window, state.inner(), &info, &archive_path), + ); if result.is_err() { let _ = cleanup_core_work_dirs(); } @@ -78,24 +86,40 @@ pub(crate) fn get_core_install_task(state: tauri::State<'_, CoreDownloadState>) #[tauri::command] pub(crate) async fn install_core_version( + app: tauri::AppHandle, window: tauri::Window, state: tauri::State<'_, CoreDownloadState>, + process_state: tauri::State<'_, CoreProcessState>, gui_config_state: tauri::State<'_, GuiConfigState>, version: Option, ) -> Result { let config = gui_config_state.snapshot()?; - let proxy_url = config.proxy_url; + let proxy_url = config.proxy_url.clone(); let token = CancellationToken::new(); state.start(token.clone(), version.clone())?; - let result = install_core_version_inner( - &window, - state.inner(), - token, - version, - &proxy_url, - config.prefer_gitcode_downloads, - ) - .await; + let (was_running, install_result) = + match pause_core_for_install(&app, process_state.inner(), &config) { + Ok(was_running) => ( + was_running, + install_core_version_inner( + &window, + state.inner(), + token, + version, + &proxy_url, + config.prefer_gitcode_downloads, + ) + .await, + ), + Err(error) => (false, Err(error)), + }; + let result = restore_core_after_install( + &app, + process_state.inner(), + &config, + was_running, + install_result, + ); if result.is_err() { let _ = cleanup_core_work_dirs(); } @@ -104,6 +128,72 @@ pub(crate) async fn install_core_version( result } +fn install_core_with_runtime_restore( + app: &tauri::AppHandle, + process_state: &CoreProcessState, + gui_config_state: &GuiConfigState, + install: F, +) -> Result +where + F: FnOnce() -> Result, +{ + let config = gui_config_state.snapshot()?; + let was_running = pause_core_for_install(app, process_state, &config)?; + let result = install(); + restore_core_after_install(app, process_state, &config, was_running, result) +} + +fn pause_core_for_install( + app: &tauri::AppHandle, + process_state: &CoreProcessState, + config: &GuiConfigFile, +) -> Result { + let was_running = current_core_status(Some(process_state), Some(config.port))?.running; + if was_running { + stop_core_process_inner(process_state)?; + emit_current_core_status(app, process_state, config.port); + } + Ok(was_running) +} + +fn restore_core_after_install( + app: &tauri::AppHandle, + process_state: &CoreProcessState, + config: &GuiConfigFile, + was_running: bool, + install_result: Result, +) -> Result { + let restart_result = if was_running { + start_core_process_inner(process_state, config) + } else { + Ok(()) + }; + emit_current_core_status(app, process_state, config.port); + combine_install_and_restart_results(install_result, restart_result) +} + +pub(crate) fn combine_install_and_restart_results( + install_result: Result, + restart_result: Result<(), String>, +) -> Result { + match (install_result, restart_result) { + (Ok(result), Ok(())) => Ok(result), + (Ok(_), Err(restart_error)) => { + Err(format!("内核已安装,但自动恢复运行失败: {restart_error}")) + } + (Err(install_error), Ok(())) => Err(install_error), + (Err(install_error), Err(restart_error)) => Err(format!( + "{install_error};自动恢复原内核运行状态也失败: {restart_error}" + )), + } +} + +fn emit_current_core_status(app: &tauri::AppHandle, process_state: &CoreProcessState, port: u16) { + if let Ok(status) = current_core_status(Some(process_state), Some(port)) { + emit_core_status(app, &status); + } +} + #[tauri::command] pub(crate) fn start_core_process( app: tauri::AppHandle, diff --git a/src-tauri/src/tests/core_runtime.rs b/src-tauri/src/tests/core_runtime.rs index dc82cad..8ddd1b2 100644 --- a/src-tauri/src/tests/core_runtime.rs +++ b/src-tauri/src/tests/core_runtime.rs @@ -9,6 +9,40 @@ fn core_process_starting_state_tracks_automatic_launch() { assert!(!state.is_starting()); } +#[test] +fn successful_core_install_remains_successful_when_restart_succeeds() { + let result = combine_install_and_restart_results(Ok("installed"), Ok(())); + assert_eq!(result.unwrap(), "installed"); +} + +#[test] +fn successful_core_install_reports_automatic_restart_failure() { + let result = combine_install_and_restart_results(Ok("installed"), Err("port busy".into())); + assert_eq!( + result.unwrap_err(), + "内核已安装,但自动恢复运行失败: port busy" + ); +} + +#[test] +fn failed_core_install_keeps_the_install_error_after_runtime_is_restored() { + let result = + combine_install_and_restart_results::<()>(Err("download cancelled".into()), Ok(())); + assert_eq!(result.unwrap_err(), "download cancelled"); +} + +#[test] +fn failed_core_install_reports_restart_failure_too() { + let result = combine_install_and_restart_results::<()>( + Err("checksum mismatch".into()), + Err("port busy".into()), + ); + assert_eq!( + result.unwrap_err(), + "checksum mismatch;自动恢复原内核运行状态也失败: port busy" + ); +} + #[test] fn replacing_a_core_preserves_only_regular_bundled_assets() { let root = agent_test_home("bundled-assets"); diff --git a/src/App.tsx b/src/App.tsx index 7e6362a..39ba6d7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,7 @@ import { CoreRuntimeProvider, useCoreRuntime } from './coreRuntime'; import { ConfigPanelPage } from './pages/ConfigPanel'; import { ApiAccessPage } from './pages/ApiAccessPage'; import { KernelPage } from './pages/Kernel'; +import { VersionManagementPage } from './pages/VersionManagementPage'; import { OAuthManagementPage } from './pages/ManagementPages'; import { AgentsPage } from './pages/AgentsPage'; import { UsageRecordsPage } from './pages/UsageRecordsPage'; @@ -44,7 +45,7 @@ const pages = [ id: 'versions', labelKey: 'app.nav.versions', icon: PackageOpen, - component: VersionManagementPage, + component: VersionManagementPageWrapper, }, { id: 'config', @@ -96,8 +97,8 @@ function HomePage() { return ; } -function VersionManagementPage() { - return ; +function VersionManagementPageWrapper() { + return ; } function App() { diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index d27814a..e8086df 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -936,6 +936,26 @@ export const jaOverrides = { 'kernel.notice.restarted': 'コアを再起動しました', 'kernel.control.refresh': '状態を更新', 'kernel.versions.title': 'コアバージョン管理', + 'kernel.versions.subtitle': 'アプリ、プロキシコア、モデルカタログのバージョン更新およびオフラインインストールを管理します。', + 'kernel.versions.checkAll': 'すべて更新確認', + 'kernel.versions.checkingAll': 'すべて確認中...', + 'kernel.versions.checkAllSuccess': '全コンポーネントの更新確認が完了しました', + 'kernel.versions.appCardTitle': 'デスクトップアプリ', + 'kernel.versions.coreCardTitle': 'プロキシコア', + 'kernel.versions.catalogCardTitle': 'Codex モデルカタログ', + 'kernel.versions.gitcodeEnabled': 'GitCode 国内ミラー有効', + 'kernel.versions.gitcodeDisabled': '公式 GitHub ソース', + 'kernel.versions.gitcodeSwitchSuccess': 'ダウンロード元を変更しバージョンを再確認しました', + 'kernel.versions.bundledPackage': '内蔵オフラインパッケージ', + 'kernel.versions.offlineRecommend': 'ネットワーク制限がありますか?インターネット接続なしで内蔵オフラインコアを直接インストールできます。', + 'kernel.versions.autoUpdateSupported': 'アプリ内自動更新対応', + 'kernel.versions.autoUpdateManual': 'ポータブル版 / 手動更新', + 'kernel.versions.coreRunningNotice': 'コアの更新または再インストール時、実行中のコアは自動的に停止します。', + 'kernel.versions.currentRunning': '現在実行中', + 'kernel.versions.currentInstalled': '現在インストール済', + 'kernel.versions.latestCloud': '公式最新', + 'kernel.versions.viewChangelog': '変更履歴', + 'kernel.versions.installLatestMissing': '最新版をインストール', 'kernel.versions.offlineHint': 'GitHub に接続できない場合は、オフラインインストールで組み込みコアを使用できます。', 'kernel.versions.current': '現在のバージョン', 'kernel.versions.latest': '最新バージョン', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index ee94889..4f49d8a 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -936,6 +936,26 @@ export const en: Record = { 'kernel.notice.restarted': 'Core restarted', 'kernel.control.refresh': 'Refresh Status', 'kernel.versions.title': 'Core Version Management', + 'kernel.versions.subtitle': 'Manage desktop application, proxy core runtime, and model catalog updates with offline support.', + 'kernel.versions.checkAll': 'Check All Updates', + 'kernel.versions.checkingAll': 'Checking All...', + 'kernel.versions.checkAllSuccess': 'Update check completed for all components', + 'kernel.versions.appCardTitle': 'Desktop Application', + 'kernel.versions.coreCardTitle': 'Proxy Core Kernel', + 'kernel.versions.catalogCardTitle': 'Codex Model Catalog', + 'kernel.versions.gitcodeEnabled': 'GitCode China Mirror Active', + 'kernel.versions.gitcodeDisabled': 'Official GitHub Source', + 'kernel.versions.gitcodeSwitchSuccess': 'Download source updated and versions rechecked', + 'kernel.versions.bundledPackage': 'Bundled Offline Package', + 'kernel.versions.offlineRecommend': 'Network restricted? You can install the bundled offline core directly without an internet connection.', + 'kernel.versions.autoUpdateSupported': 'In-app auto-update supported', + 'kernel.versions.autoUpdateManual': 'Portable Edition / Manual Update', + 'kernel.versions.coreRunningNotice': 'The running core will be stopped automatically during updates or reinstallation.', + 'kernel.versions.currentRunning': 'Current Running', + 'kernel.versions.currentInstalled': 'Current Installed', + 'kernel.versions.latestCloud': 'Official Latest', + 'kernel.versions.viewChangelog': 'Changelog', + 'kernel.versions.installLatestMissing': 'Install Latest', 'kernel.versions.offlineHint': 'If GitHub is unavailable, use Offline Install to install the bundled core.', 'kernel.versions.current': 'Current Version', 'kernel.versions.latest': 'Latest Version', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 08d9a0b..5fe8d91 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -934,6 +934,26 @@ export const zhCN = { 'kernel.notice.restarted': '内核已重启', 'kernel.control.refresh': '刷新状态', 'kernel.versions.title': '内核版本管理', + 'kernel.versions.subtitle': '管理客户端应用、代理内核及模型目录的版本更新与离线安装。', + 'kernel.versions.checkAll': '检查全部更新', + 'kernel.versions.checkingAll': '正在检查全部...', + 'kernel.versions.checkAllSuccess': '已完成全部组件更新检查', + 'kernel.versions.appCardTitle': '桌面客户端', + 'kernel.versions.coreCardTitle': '代理内核', + 'kernel.versions.catalogCardTitle': 'Codex 模型目录', + 'kernel.versions.gitcodeEnabled': '已启用 GitCode 国内加速', + 'kernel.versions.gitcodeDisabled': '官方 GitHub 源', + 'kernel.versions.gitcodeSwitchSuccess': '已切换下载源并重新检测版本', + 'kernel.versions.bundledPackage': '内置离线安装包', + 'kernel.versions.offlineRecommend': '网络连接受限?可直接一键安装内置离线内核,无需联网。', + 'kernel.versions.autoUpdateSupported': '支持应用内自动升级', + 'kernel.versions.autoUpdateManual': '便携绿色版 / 手动更新', + 'kernel.versions.coreRunningNotice': '更新或重装内核时,正在运行的内核将自动先停止。', + 'kernel.versions.currentRunning': '当前运行', + 'kernel.versions.currentInstalled': '当前安装', + 'kernel.versions.latestCloud': '官方最新', + 'kernel.versions.viewChangelog': '更新日志', + 'kernel.versions.installLatestMissing': '安装最新版', 'kernel.versions.offlineHint': 'GitHub 无法连接时,可使用离线安装使用内置内核。', 'kernel.versions.current': '当前版本', 'kernel.versions.latest': '最新版本', diff --git a/src/pages/Kernel.tsx b/src/pages/Kernel.tsx index c05bc59..ed3876e 100644 --- a/src/pages/Kernel.tsx +++ b/src/pages/Kernel.tsx @@ -4,20 +4,11 @@ import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { AlertCircle, - AppWindow, - ArrowRight, - Box, Check, Copy, - Database, - Download, - ExternalLink, Eye, EyeOff, Info, - PackageOpen, - RefreshCw, - RotateCcw, } from 'lucide-react'; import { type CoreStatus, useCoreRuntime } from '../coreRuntime'; import openaiIcon from '../assets/icons/openai-light.svg'; @@ -26,47 +17,7 @@ import geminiIcon from '../assets/icons/gemini.svg'; import { clientApiProfiles } from '../services/clientAccess'; import { useI18n } from '../i18n'; import { useAppUpdate } from '../appUpdate'; - -type CorePlatform = { - os: string; - arch: string; - assetOs: string; - assetArch: string; - archiveKind: 'tar.gz' | 'zip'; -}; - -type CoreLatest = { - version: string; - assetName: string; -}; - -type BundledCoreInfo = { - version: string; - assetName: string; - sizeBytes: number; -}; - -type CoreInstallResult = { - version: string; - assetName: string; - installDir: string; - binaryPath: string | null; -}; - -type CoreInstallTask = { - running: boolean; - cancellable: boolean; - phase: string; - downloaded: number; - total: number | null; - percent: number | null; - message: string | null; - result: CoreInstallResult | null; -}; - -type CodexModelCatalogUpdateResult = { - outcome: 'updated' | 'unchanged'; -}; +import { VersionManagementPage, displayAppVersion } from './VersionManagementPage'; type MessageType = 'info' | 'success' | 'error'; type CoreProcessCommand = 'start_core_process' | 'stop_core_process' | 'restart_core_process'; @@ -77,11 +28,6 @@ type GuiSettings = { runOnStartup: boolean; }; -type VersionSourceSettings = { - preferGitcodeDownloads: boolean; - gitcodeAvailable: boolean; -}; - type CoreConfigSummary = { apiKeys: Array<{ apiKey: string }>; }; @@ -92,102 +38,36 @@ type CoreTlsSettings = { key: string; }; -const APP_RELEASE_URL = 'https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest'; - -let latestAutoCheckStarted = false; -let cachedLatest: CoreLatest | null = null; -let cachedLatestError = ''; -let latestCheckPromise: Promise | null = null; -let latestRequestEpoch = 0; - -function displayAppVersion(version: string) { - const resolvedVersion = version.trim(); - return resolvedVersion.startsWith('v') ? resolvedVersion : `v${resolvedVersion}`; -} - -function requestLatestCore(force = false) { - if (!force && latestCheckPromise) { - return latestCheckPromise; - } - - const requestEpoch = ++latestRequestEpoch; - const request = invoke('check_latest_core') - .then((result) => { - if (requestEpoch === latestRequestEpoch) { - cachedLatest = result; - cachedLatestError = ''; - } - return result; - }) - .catch((error) => { - if (requestEpoch === latestRequestEpoch) { - cachedLatest = null; - cachedLatestError = String(error); - } - throw error; - }) - .finally(() => { - if (latestCheckPromise === request) { - latestCheckPromise = null; - } - }); - latestCheckPromise = request; - - return request; -} - export type KernelView = 'home' | 'versions'; export function KernelPage({ view = 'home' }: { view?: KernelView }) { + if (view === 'versions') { + return ; + } + const { t } = useI18n(); - const { - info: appUpdate, - error: appUpdateError, - checking: checkingAppUpdate, - task: appUpdateTask, - check: checkAppUpdate, - requestInstall: requestAppUpdate, - } = useAppUpdate(); + const { info: appUpdate } = useAppUpdate(); const { status: coreStatus, statusError, refreshStatus, publishStatus, } = useCoreRuntime(); + const [installedAppVersion, setInstalledAppVersion] = useState(''); - const [platform, setPlatform] = useState(null); - const [platformError, setPlatformError] = useState(''); - const [latest, setLatest] = useState(cachedLatest); - const [latestError, setLatestError] = useState(cachedLatestError); - const [bundledCore, setBundledCore] = useState(null); - const [bundledCoreError, setBundledCoreError] = useState(''); - const [checkingLatest, setCheckingLatest] = useState(Boolean(latestCheckPromise)); const [listenHost, setListenHost] = useState('127.0.0.1'); const [customPort, setCustomPort] = useState('8317'); - const [installing, setInstalling] = useState(false); const [processBusy, setProcessBusy] = useState(false); const [processNotice, setProcessNotice] = useState<{ message: string; tone: MessageType; } | null>(null); - const [message, setMessage] = useState(''); - const [messageType, setMessageType] = useState('info'); - const [catalogUpdating, setCatalogUpdating] = useState(false); - const [catalogUpdateError, setCatalogUpdateError] = useState(''); - const [catalogUpdateNotice, setCatalogUpdateNotice] = useState(''); - const [progress, setProgress] = useState(null); - const [installDialogOpen, setInstallDialogOpen] = useState(false); - const [cancellingInstall, setCancellingInstall] = useState(false); const [copiedApiField, setCopiedApiField] = useState(''); const [homeApiKey, setHomeApiKey] = useState(undefined); const [homeApiKeyError, setHomeApiKeyError] = useState(false); const [showHomeApiKey, setShowHomeApiKey] = useState(false); const [tlsEnabled, setTlsEnabled] = useState(false); - const [versionSource, setVersionSource] = useState(null); - const [versionSourceSaving, setVersionSourceSaving] = useState(false); - const [versionSourceError, setVersionSourceError] = useState(''); - const installDialogRef = useRef(null); - const latestCheckEpochRef = useRef(0); + const savedPortRef = useRef(8317); const processNoticeTimerRef = useRef(null); const copiedApiTimerRef = useRef(null); @@ -204,98 +84,32 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { }, 3600); }; - const applyInstallTask = (task: CoreInstallTask, showFinishedDialog = true) => { - if (!task.running && !task.message && !task.result) { - setProgress(null); - setInstalling(false); - setCancellingInstall(false); - return; - } - - setInstalling(task.running); - if (!task.running) { - setCancellingInstall(false); - } - - if (task.running || showFinishedDialog) { - setProgress(task); - setInstallDialogOpen(true); - } else { - setProgress(null); - } - - if (task.result) { - setMessage(task.message || t('kernel.install.completed', { version: task.result.version })); - setMessageType('success'); - void refreshStatus(); - return; - } - - if (task.message) { - setMessage(task.message); - setMessageType(task.phase === '安装失败' ? 'error' : 'info'); - } - }; - useEffect(() => { let disposed = false; - let unlisten: (() => void) | null = null; let unlistenConfig: (() => void) | null = null; - listen('core-install-progress', (event) => { - applyInstallTask(event.payload); - }) - .then((unlistenProgress) => { - if (disposed) { - unlistenProgress(); - } else { - unlisten = unlistenProgress; - } - }) - .catch((error) => { - if (!disposed) { - setMessage(t('kernel.error.progressListener', { error: String(error) })); - setMessageType('error'); - } - }); - void listen('config-files-changed', () => { if (disposed) return; void loadGuiSettings(); void loadTlsSettings(); void refreshStatus(); - if (view === 'versions') void loadVersionSourceSettings(); - if (view === 'home') void loadHomeApiKey(); + void loadHomeApiKey(); }).then((stop) => { if (disposed) stop(); else unlistenConfig = stop; }); - loadPlatform(); - loadBundledCore(); - loadInstallTask(); loadGuiSettings(); - if (view === 'versions') void loadVersionSourceSettings(); void loadTlsSettings(); void getVersion() .then((version) => { if (!disposed) setInstalledAppVersion(version); }) .catch(() => undefined); - if (view === 'home') { - void loadHomeApiKey(); - } - - if (!latestAutoCheckStarted) { - latestAutoCheckStarted = true; - void checkLatest(); - } else if (latestCheckPromise) { - void checkLatest(); - } + void loadHomeApiKey(); return () => { disposed = true; - unlisten?.(); unlistenConfig?.(); if (processNoticeTimerRef.current !== null) { window.clearTimeout(processNoticeTimerRef.current); @@ -306,29 +120,6 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { }; }, []); - useEffect(() => { - if (!installDialogOpen) { - return; - } - - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - installDialogRef.current?.focus(); - - const preventEscapeClose = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - event.preventDefault(); - } - }; - - document.addEventListener('keydown', preventEscapeClose); - - return () => { - document.body.style.overflow = previousOverflow; - document.removeEventListener('keydown', preventEscapeClose); - }; - }, [installDialogOpen]); - const runCoreProcessCommand = async ( command: CoreProcessCommand, messages?: { success?: string; failure?: string }, @@ -370,16 +161,6 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { } catch {} }; - const loadVersionSourceSettings = async () => { - try { - const settings = await invoke('get_version_source_settings'); - setVersionSource(settings); - setVersionSourceError(''); - } catch (error) { - setVersionSourceError(String(error)); - } - }; - const loadHomeApiKey = async () => { try { const settings = await invoke('get_core_config_settings'); @@ -400,197 +181,6 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { } }; - const loadPlatform = async () => { - try { - const result = await invoke('detect_core_platform'); - setPlatform(result); - setPlatformError(''); - } catch (error) { - setPlatform(null); - setPlatformError(String(error)); - } - }; - - const loadBundledCore = async () => { - try { - const result = await invoke('detect_bundled_core'); - setBundledCore(result); - setBundledCoreError(''); - } catch (error) { - setBundledCore(null); - setBundledCoreError(String(error)); - } - }; - - const checkLatest = async (force = false) => { - const checkEpoch = ++latestCheckEpochRef.current; - setCheckingLatest(true); - setLatestError(''); - setMessage(''); - setMessageType('info'); - - try { - const result = await requestLatestCore(force); - if (checkEpoch === latestCheckEpochRef.current) setLatest(result); - } catch (error) { - if (checkEpoch === latestCheckEpochRef.current) { - setLatest(null); - setLatestError(String(error)); - } - } finally { - if (checkEpoch === latestCheckEpochRef.current) setCheckingLatest(false); - } - }; - - const updateVersionSource = async (enabled: boolean) => { - setVersionSourceSaving(true); - setVersionSourceError(''); - try { - const settings = await invoke('set_prefer_gitcode_downloads', { enabled }); - setVersionSource(settings); - cachedLatest = null; - cachedLatestError = ''; - setLatest(null); - await Promise.all([checkAppUpdate(), checkLatest(true)]); - } catch (error) { - await loadVersionSourceSettings(); - setVersionSourceError(t('kernel.versions.gitcodeSaveFailed', { error: String(error) })); - } finally { - setVersionSourceSaving(false); - } - }; - - const loadInstallTask = async () => { - try { - const task = await invoke('get_core_install_task'); - applyInstallTask(task, false); - } catch (error) { - setMessage(t('kernel.error.installTask', { error: String(error) })); - setMessageType('error'); - } - }; - - const installVersion = async (version: string) => { - setInstalling(true); - setMessage(t('kernel.install.installingVersion', { version })); - setMessageType('info'); - setCancellingInstall(false); - setInstallDialogOpen(true); - setProgress({ - running: true, - cancellable: true, - phase: '准备下载', - downloaded: 0, - total: null, - percent: null, - message: null, - result: null, - }); - - try { - const result = await invoke('install_core_version', { version }); - setMessage(t('kernel.install.completed', { version: result.version })); - setMessageType('success'); - setProgress({ - running: false, - cancellable: false, - phase: '安装完成', - downloaded: 1, - total: 1, - percent: 100, - message: t('kernel.install.completed', { version: result.version }), - result, - }); - await Promise.all([refreshStatus(), loadBundledCore()]); - } catch (error) { - const errorMessage = String(error); - setMessage(errorMessage); - setMessageType(errorMessage.includes('取消') ? 'info' : 'error'); - setProgress((current) => ({ - running: false, - cancellable: false, - phase: errorMessage.includes('取消') ? '已取消' : '安装失败', - downloaded: current?.downloaded ?? 0, - total: current?.total ?? null, - percent: current?.percent ?? null, - message: errorMessage, - result: null, - })); - } finally { - setInstalling(false); - } - }; - - const installBundledCore = async () => { - if (!bundledCore) return; - - setInstalling(true); - setMessage(t('kernel.install.installingBundled', { version: bundledCore.version })); - setMessageType('info'); - setCancellingInstall(false); - setInstallDialogOpen(true); - setProgress({ - running: true, - cancellable: false, - phase: '准备内置内核', - downloaded: 0, - total: bundledCore.sizeBytes, - percent: 0, - message: null, - result: null, - }); - try { - const result = await invoke('install_bundled_core'); - setMessage(t('kernel.install.bundledCompleted', { version: result.version })); - setMessageType('success'); - await Promise.all([refreshStatus(), loadBundledCore()]); - } catch (error) { - const errorMessage = String(error); - setMessage(errorMessage); - setMessageType('error'); - setProgress((current) => ({ - running: false, - cancellable: false, - phase: '安装失败', - downloaded: current?.downloaded ?? 0, - total: current?.total ?? null, - percent: current?.percent ?? null, - message: errorMessage, - result: null, - })); - } finally { - setInstalling(false); - } - }; - - const cancelInstall = async () => { - if (cancellingInstall || !progress?.running || !progress.cancellable) { - return; - } - - setCancellingInstall(true); - setMessage(t('kernel.install.cancelling')); - setMessageType('info'); - - try { - await invoke('cancel_core_install'); - } catch (error) { - setCancellingInstall(false); - setMessage(String(error)); - setMessageType('error'); - } - }; - - const closeInstallDialog = () => { - if (installing || progress?.running) { - return; - } - - setInstallDialogOpen(false); - setProgress(null); - setCancellingInstall(false); - }; - const copyApiValue = async (value: string, field: string, message: string) => { try { await navigator.clipboard.writeText(value); @@ -608,68 +198,11 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { } }; - const openAppRelease = async () => { - try { - await invoke('open_external_url', { url: appUpdate?.releaseUrl || APP_RELEASE_URL }); - } catch (error) { - showProcessNotice(t('kernel.error.openUpdate', { error: String(error) }), 'error'); - } - }; - - const updateCodexModelCatalog = async () => { - setCatalogUpdating(true); - setCatalogUpdateError(''); - setCatalogUpdateNotice(''); - try { - const result = await invoke('update_codex_model_catalog'); - setCatalogUpdateNotice(result.outcome === 'updated' - ? t('appUpdate.catalog.updated') - : t('appUpdate.catalog.unchanged')); - } catch (error) { - setCatalogUpdateError(String(error)); - } finally { - setCatalogUpdating(false); - } - }; - - const latestVersion = latest?.version ?? ''; const currentVersion = coreStatus?.currentVersion ?? ''; const coreInstalled = Boolean(coreStatus?.installed); const coreRunning = Boolean(coreStatus?.running); const coreProcessBusy = processBusy || Boolean(coreStatus?.starting); - const busy = checkingLatest || installing || coreProcessBusy; - const installDisabled = busy || Boolean(coreStatus?.running); - const offlineInstallDisabled = installing || coreProcessBusy || coreRunning; - const stopAndInstallLatest = async () => { - if (!latestVersion) return; - - if (coreRunning) { - const stopped = await runCoreProcessCommand('stop_core_process', { - success: t('kernel.notice.stopped'), - }); - if (!stopped) return; - } - - await installVersion(latestVersion); - }; - - const computedPercent = - progress?.percent ?? - (progress?.total && progress.total > 0 ? (progress.downloaded / progress.total) * 100 : null); - const progressKnown = computedPercent !== null; - const progressPercent = clampPercent(computedPercent ?? 0); - const progressText = progress - ? progress.phase === '安装完成' - ? t('kernel.progress.completed') - : progress.phase === '解压中' - ? t('kernel.progress.extracting') - : progress.total - ? `${formatBytes(progress.downloaded)} / ${formatBytes(progress.total)}` - : progress.downloaded > 0 - ? formatBytes(progress.downloaded) - : t('kernel.progress.waiting') - : ''; const statusTone = statusError ? 'error' : coreRunning ? 'success' : 'neutral'; const statusLabel = coreStatus ? coreRunning @@ -680,88 +213,12 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { : statusError ? t('common.detectionFailed') : t('common.detecting'); + const resolvedAppVersion = appUpdate?.currentVersion || installedAppVersion; const currentAppVersion = resolvedAppVersion ? displayAppVersion(resolvedAppVersion) : t('common.detecting'); - const appVersionStatusLabel = appUpdateTask.running - ? t(`appUpdate.phase.${appUpdateTask.phase}` as Parameters[0]) - : appUpdateError - ? t('kernel.update.failed') - : appUpdate?.updateAvailable - ? t('appUpdate.available', { version: displayAppVersion(appUpdate.latestVersion) }) - : appUpdate - ? t('appUpdate.upToDate') - : t('appUpdate.phase.checking'); - const appVersionStatusTone = appUpdateError - ? 'error' - : appUpdate?.updateAvailable - ? 'update' - : appUpdate - ? 'success' - : ''; - const latestLabel = checkingLatest - ? t('kernel.update.checking') - : latestVersion || (latestError ? t('kernel.update.failed') : t('kernel.update.notChecked')); - const updateStateLabel = checkingLatest - ? t('kernel.update.statusChecking') - : latestError - ? t('kernel.update.failed') - : !latestVersion - ? t('kernel.update.notYetChecked') - : currentVersion === latestVersion - ? t('kernel.update.latest') - : t('kernel.update.available'); - const platformOsLabel = platform?.os || (platformError ? t('common.detectionFailed') : t('common.detecting')); - const platformArchLabel = platform?.arch || (platformError ? t('common.detectionFailed') : t('common.detecting')); - const installTaskRunning = Boolean(installing || progress?.running); - const offlineInstallRequired = Boolean(coreStatus && !coreInstalled && latestError); - const versionStatusLabel = installTaskRunning - ? cancellingInstall - ? t('kernel.install.cancelling') - : progress?.phase ? localizeInstallPhase(progress.phase, t) : t('kernel.install.inProgress') - : offlineInstallRequired - ? t('kernel.install.githubFailed') - : message || updateStateLabel; - const versionStatusTone: MessageType | 'update' = installTaskRunning - ? 'info' - : offlineInstallRequired - ? 'error' - : message - ? messageType - : latestError - ? 'error' - : currentVersion && currentVersion === latestVersion - ? 'success' - : latestVersion && currentVersion !== latestVersion - ? 'update' - : 'info'; - const installDialogTone: MessageType = progress?.result - ? 'success' - : progress?.phase === '安装失败' - ? 'error' - : 'info'; - const installDialogTitle = installTaskRunning - ? cancellingInstall - ? t('kernel.install.titleCancelling') - : t('kernel.install.titleInstalling') - : progress?.result - ? t('kernel.install.titleCompleted') - : progress?.phase === '已取消' - ? t('kernel.install.titleCancelled') - : t('kernel.install.titleFailed'); - const installDialogMessage = cancellingInstall - ? t('kernel.install.waitingStop') - : progress?.message || (installTaskRunning ? message || t('kernel.install.taskRunning') : ''); - const installDialogAction = installTaskRunning - ? cancellingInstall - ? t('kernel.install.cancellingShort') - : progress?.cancellable - ? t('kernel.install.cancel') - : t('common.processing') - : t('common.close'); - const installDialogActionDisabled = - installTaskRunning && (cancellingInstall || !progress?.cancellable); + const apiPort = Number(customPort); const apiProfiles = clientApiProfiles( Number.isInteger(apiPort) && apiPort >= 1 && apiPort <= 65535 @@ -777,54 +234,8 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { } as const; return ( -
- {view === 'versions' ? ( -
-
- -
-

{t('app.nav.versions')}

-

{t('kernel.versions.offlineHint')}

-
-
- -
-
-
- - {versionSourceError ?

{versionSourceError}

: null} -
-
- ) : null} - -
- {view === 'home' ? ( +
+
@@ -865,7 +276,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
-
- ) : null} - - {view === 'versions' ? ( -
-
-
- -

{t('appUpdate.title')}

-
- - {appVersionStatusLabel} - -
- -
-
- {t('appUpdate.current')} - {currentAppVersion} -
-
- -
-
- -
- {appUpdateError ? ( -

{appUpdateError}

- ) : !appUpdate?.autoUpdateSupported ? ( -

{t('appUpdate.manualFallback')}

- ) : null} -
- -
- - {appUpdate?.updateAvailable && appUpdate.autoUpdateSupported ? ( - - ) : ( - - )} - -
-
- ) : null} - - {view === 'versions' ? ( -
-
-
- -

{t('kernel.versions.title')}

-
- - {versionStatusLabel} - -
- -
-
- {t('kernel.versions.current')} - {currentVersion || t('kernel.status.notInstalled')} -
-
- {t('kernel.versions.latest')} - {latestLabel} -
-
- {t('kernel.versions.bundled')} - - {bundledCore?.version ?? (bundledCoreError ? t('common.detectionFailed') : t('kernel.versions.notIncluded'))} - -
-
- -
-
- -
- ) : null}
- {view === 'home' ? (
-

API URL

- {typeof homeApiKey === 'string' ? ( -
- - -
- ) : homeApiKey === null ? ( -

{t('kernel.access.noConfiguredKey')}

- ) : ( -

- {homeApiKeyError ? t('common.unavailable') : t('common.loading')} -

- )} +

{t('app.nav.api')}

+
+ {homeApiKeyError ? ( + {t('common.detectionFailed')} + ) : homeApiKey ? ( + <> + {t('kernel.access.firstKey')} + + {showHomeApiKey ? homeApiKey : '••••••••••••••••'} + + + + + ) : homeApiKey === null ? ( + {t('kernel.access.noConfiguredKey')} + ) : ( + {t('common.loading')} + )} +
- + {coreRunning ? t('kernel.access.connectable') : t('kernel.access.waiting')}
{apiProfiles.map((profile) => ( -
+
@@ -1177,65 +394,6 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { ))}
- ) : null} - - {view === 'versions' && installDialogOpen && progress ? ( -
-
-
- {t('kernel.dialog.install')} -

{installDialogTitle}

-
- -
- {t('kernel.dialog.phase')} - {cancellingInstall ? t('kernel.install.cancellingShort') : localizeInstallPhase(progress.phase, t)} -
- -
- -
- -
- {progressKnown ? `${progressPercent.toFixed(1)}%` : t('kernel.dialog.unknownProgress')} - {progressText} -
- -
- {installDialogMessage || '\u00a0'} -
- - -
-
- ) : null} {processNotice ? (
); } - -function localizeInstallPhase( - phase: string, - t: ReturnType['t'], -) { - const keys = { - '准备下载': 'kernel.phase.preparingDownload', - '下载中': 'kernel.phase.downloading', - '解压中': 'kernel.phase.extracting', - '准备内置内核': 'kernel.phase.preparingBundled', - '安装完成': 'kernel.phase.completed', - '安装失败': 'kernel.phase.failed', - '已取消': 'kernel.phase.cancelled', - } as const; - const key = keys[phase as keyof typeof keys]; - return key ? t(key) : phase; -} - -function clampPercent(percent: number) { - return Math.min(100, Math.max(0, percent)); -} - -function formatBytes(bytes: number) { - if (bytes < 1024) { - return `${bytes} B`; - } - - if (bytes < 1024 * 1024) { - return `${(bytes / 1024).toFixed(1)} KB`; - } - - return `${(bytes / 1024 / 1024).toFixed(1)} MB`; -} diff --git a/src/pages/VersionManagementPage.tsx b/src/pages/VersionManagementPage.tsx new file mode 100644 index 0000000..9d63c03 --- /dev/null +++ b/src/pages/VersionManagementPage.tsx @@ -0,0 +1,975 @@ +import { useEffect, useRef, useState } from 'react'; +import { getVersion } from '@tauri-apps/api/app'; +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import { + AlertCircle, + AppWindow, + ArrowRight, + Box, + Check, + Cpu, + Database, + Download, + ExternalLink, + Info, + RefreshCw, + RotateCcw, + Sparkles, + Zap, +} from 'lucide-react'; +import { useCoreRuntime } from '../coreRuntime'; +import { useI18n } from '../i18n'; +import { useAppUpdate } from '../appUpdate'; + +export type CorePlatform = { + os: string; + arch: string; + assetOs: string; + assetArch: string; + archiveKind: 'tar.gz' | 'zip'; +}; + +export type CoreLatest = { + version: string; + assetName: string; +}; + +export type CoreInstallResult = { + version: string; + assetName: string; + installDir: string; + binaryPath: string | null; +}; + +export type CoreInstallTask = { + running: boolean; + cancellable: boolean; + phase: string; + downloaded: number; + total: number | null; + percent: number | null; + message: string | null; + result: CoreInstallResult | null; +}; + +export type CodexModelCatalogUpdateResult = { + outcome: 'updated' | 'unchanged'; +}; + +export type VersionSourceSettings = { + preferGitcodeDownloads: boolean; + gitcodeAvailable: boolean; +}; + +export type MessageType = 'info' | 'success' | 'error'; +const APP_RELEASE_URL = 'https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest'; + +let latestAutoCheckStarted = false; +let cachedLatest: CoreLatest | null = null; +let cachedLatestError = ''; +let latestCheckPromise: Promise | null = null; +let latestRequestEpoch = 0; + +export function displayAppVersion(version: string) { + const resolvedVersion = version.trim(); + return resolvedVersion.startsWith('v') ? resolvedVersion : `v${resolvedVersion}`; +} + +export function requestLatestCore(force = false) { + if (!force && latestCheckPromise) { + return latestCheckPromise; + } + + const requestEpoch = ++latestRequestEpoch; + const request = invoke('check_latest_core') + .then((result) => { + if (requestEpoch === latestRequestEpoch) { + cachedLatest = result; + cachedLatestError = ''; + } + return result; + }) + .catch((error) => { + if (requestEpoch === latestRequestEpoch) { + cachedLatest = null; + cachedLatestError = String(error); + } + throw error; + }) + .finally(() => { + if (latestCheckPromise === request) { + latestCheckPromise = null; + } + }); + latestCheckPromise = request; + + return request; +} + +export function VersionManagementPage() { + const { t } = useI18n(); + const { + info: appUpdate, + error: appUpdateError, + checking: checkingAppUpdate, + task: appUpdateTask, + check: checkAppUpdate, + requestInstall: requestAppUpdate, + } = useAppUpdate(); + + const { + status: coreStatus, + statusError, + refreshStatus, + } = useCoreRuntime(); + + const [installedAppVersion, setInstalledAppVersion] = useState(''); + const [platform, setPlatform] = useState(null); + const [platformError, setPlatformError] = useState(''); + const [latest, setLatest] = useState(cachedLatest); + const [latestError, setLatestError] = useState(cachedLatestError); + const [checkingLatest, setCheckingLatest] = useState(Boolean(latestCheckPromise)); + + const [installing, setInstalling] = useState(false); + const [progress, setProgress] = useState(null); + const [installDialogOpen, setInstallDialogOpen] = useState(false); + const [cancellingInstall, setCancellingInstall] = useState(false); + + const [versionSource, setVersionSource] = useState(null); + const [versionSourceSaving, setVersionSourceSaving] = useState(false); + const [versionSourceError, setVersionSourceError] = useState(''); + + const [catalogUpdating, setCatalogUpdating] = useState(false); + const [catalogUpdateError, setCatalogUpdateError] = useState(''); + const [catalogUpdateNotice, setCatalogUpdateNotice] = useState(''); + + const [toastNotice, setToastNotice] = useState<{ + message: string; + tone: MessageType; + } | null>(null); + + const installDialogRef = useRef(null); + const latestCheckEpochRef = useRef(0); + const toastTimerRef = useRef(null); + + const showToast = (message: string, tone: MessageType = 'info') => { + if (toastTimerRef.current !== null) { + window.clearTimeout(toastTimerRef.current); + } + setToastNotice({ message, tone }); + toastTimerRef.current = window.setTimeout(() => { + setToastNotice(null); + toastTimerRef.current = null; + }, 4000); + }; + + const applyInstallTask = (task: CoreInstallTask, showFinishedDialog = true) => { + if (!task.running && !task.message && !task.result) { + setProgress(null); + setInstalling(false); + setCancellingInstall(false); + return; + } + + setInstalling(task.running); + if (!task.running) { + setCancellingInstall(false); + } + + if (task.running || showFinishedDialog) { + setProgress(task); + setInstallDialogOpen(true); + } else { + setProgress(null); + } + + if (task.result) { + showToast(task.message || t('kernel.install.completed', { version: task.result.version }), 'success'); + void refreshStatus(); + return; + } + + if (task.message && !task.running) { + showToast(task.message, task.phase === '安装失败' ? 'error' : 'info'); + } + }; + + const loadPlatform = async () => { + try { + const result = await invoke('detect_core_platform'); + setPlatform(result); + setPlatformError(''); + } catch (error) { + setPlatform(null); + setPlatformError(String(error)); + } + }; + + const loadVersionSourceSettings = async () => { + try { + const settings = await invoke('get_version_source_settings'); + setVersionSource(settings); + setVersionSourceError(''); + } catch (error) { + setVersionSourceError(String(error)); + } + }; + + const loadInstallTask = async () => { + try { + const task = await invoke('get_core_install_task'); + applyInstallTask(task, false); + } catch {} + }; + + const checkLatest = async (force = false) => { + const checkEpoch = ++latestCheckEpochRef.current; + setCheckingLatest(true); + setLatestError(''); + + try { + const result = await requestLatestCore(force); + if (checkEpoch === latestCheckEpochRef.current) { + setLatest(result); + } + } catch (error) { + if (checkEpoch === latestCheckEpochRef.current) { + setLatest(null); + setLatestError(String(error)); + } + } finally { + if (checkEpoch === latestCheckEpochRef.current) { + setCheckingLatest(false); + } + } + }; + + const updateVersionSource = async (enabled: boolean) => { + setVersionSourceSaving(true); + setVersionSourceError(''); + try { + const settings = await invoke('set_prefer_gitcode_downloads', { enabled }); + setVersionSource(settings); + cachedLatest = null; + cachedLatestError = ''; + setLatest(null); + showToast(t('kernel.versions.gitcodeSwitchSuccess'), 'success'); + await Promise.allSettled([checkAppUpdate(), checkLatest(true)]); + } catch (error) { + await loadVersionSourceSettings(); + setVersionSourceError(t('kernel.versions.gitcodeSaveFailed', { error: String(error) })); + showToast(t('kernel.versions.gitcodeSaveFailed', { error: String(error) }), 'error'); + } finally { + setVersionSourceSaving(false); + } + }; + + const installVersion = async (version: string) => { + setInstalling(true); + setCancellingInstall(false); + setInstallDialogOpen(true); + setProgress({ + running: true, + cancellable: true, + phase: '准备下载', + downloaded: 0, + total: null, + percent: null, + message: null, + result: null, + }); + + try { + const result = await invoke('install_core_version', { version }); + showToast(t('kernel.install.completed', { version: result.version }), 'success'); + setProgress({ + running: false, + cancellable: false, + phase: '安装完成', + downloaded: 1, + total: 1, + percent: 100, + message: t('kernel.install.completed', { version: result.version }), + result, + }); + await refreshStatus(); + } catch (error) { + const errorMessage = String(error); + showToast(errorMessage, errorMessage.includes('取消') ? 'info' : 'error'); + setProgress((current) => ({ + running: false, + cancellable: false, + phase: errorMessage.includes('取消') ? '已取消' : '安装失败', + downloaded: current?.downloaded ?? 0, + total: current?.total ?? null, + percent: current?.percent ?? null, + message: errorMessage, + result: null, + })); + } finally { + setInstalling(false); + } + }; + + const cancelInstall = async () => { + if (cancellingInstall || !progress?.running || !progress.cancellable) { + return; + } + + setCancellingInstall(true); + try { + await invoke('cancel_core_install'); + } catch (error) { + setCancellingInstall(false); + showToast(String(error), 'error'); + } + }; + + const closeInstallDialog = () => { + if (installing || progress?.running) { + return; + } + setInstallDialogOpen(false); + setProgress(null); + setCancellingInstall(false); + }; + + const openAppRelease = async () => { + try { + await invoke('open_external_url', { url: appUpdate?.releaseUrl || APP_RELEASE_URL }); + } catch (error) { + showToast(t('kernel.error.openUpdate', { error: String(error) }), 'error'); + } + }; + + const syncModelCatalog = async () => { + setCatalogUpdating(true); + setCatalogUpdateError(''); + setCatalogUpdateNotice(''); + try { + const result = await invoke('update_codex_model_catalog'); + const notice = result.outcome === 'updated' + ? t('appUpdate.catalog.updated') + : t('appUpdate.catalog.unchanged'); + setCatalogUpdateNotice(notice); + showToast(notice, 'success'); + } catch (error) { + const err = String(error); + setCatalogUpdateError(err); + showToast(err, 'error'); + } finally { + setCatalogUpdating(false); + } + }; + + useEffect(() => { + let disposed = false; + let unlisten: (() => void) | null = null; + let unlistenConfig: (() => void) | null = null; + + listen('core-install-progress', (event) => { + applyInstallTask(event.payload); + }) + .then((unlistenProgress) => { + if (disposed) unlistenProgress(); + else unlisten = unlistenProgress; + }) + .catch(() => undefined); + + void listen('config-files-changed', () => { + if (disposed) return; + void loadVersionSourceSettings(); + void refreshStatus(); + }).then((stop) => { + if (disposed) stop(); + else unlistenConfig = stop; + }); + + loadPlatform(); + loadInstallTask(); + loadVersionSourceSettings(); + + void getVersion() + .then((version) => { + if (!disposed) setInstalledAppVersion(version); + }) + .catch(() => undefined); + + if (!latestAutoCheckStarted) { + latestAutoCheckStarted = true; + void checkLatest(); + } else if (latestCheckPromise) { + void checkLatest(); + } + + return () => { + disposed = true; + unlisten?.(); + unlistenConfig?.(); + if (toastTimerRef.current !== null) { + window.clearTimeout(toastTimerRef.current); + } + }; + }, []); + + useEffect(() => { + if (!installDialogOpen) return; + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + installDialogRef.current?.focus(); + + const preventEscapeClose = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + } + }; + + document.addEventListener('keydown', preventEscapeClose); + return () => { + document.body.style.overflow = previousOverflow; + document.removeEventListener('keydown', preventEscapeClose); + }; + }, [installDialogOpen]); + + // Derived state calculations + const latestVersion = latest?.version ?? ''; + const currentVersion = coreStatus?.currentVersion ?? ''; + const coreInstalled = Boolean(coreStatus?.installed); + const coreRunning = Boolean(coreStatus?.running); + const coreProcessBusy = Boolean(coreStatus?.starting); + const busy = checkingLatest || installing || coreProcessBusy; + const installDisabled = busy || installing; + + const resolvedAppVersion = appUpdate?.currentVersion || installedAppVersion; + const currentAppVersion = resolvedAppVersion ? displayAppVersion(resolvedAppVersion) : t('common.detecting'); + const latestAppVersion = appUpdate?.latestVersion ? displayAppVersion(appUpdate.latestVersion) : ''; + + const appHasUpdate = Boolean(appUpdate?.updateAvailable); + const appVersionStatusTone = appUpdateError + ? 'error' + : appUpdateTask.running + ? 'update' + : appHasUpdate + ? 'update' + : appUpdate + ? 'success' + : ''; + + const appVersionStatusLabel = appUpdateTask.running + ? t(`appUpdate.phase.${appUpdateTask.phase}` as Parameters[0]) + : appUpdateError + ? t('kernel.update.failed') + : appHasUpdate + ? t('appUpdate.available', { version: latestAppVersion }) + : appUpdate + ? t('appUpdate.upToDate') + : t('appUpdate.phase.checking'); + + const coreHasUpdate = Boolean(latestVersion && currentVersion && currentVersion !== latestVersion); + + const coreVersionStatusTone = installing || progress?.running + ? 'info' + : coreRunning + ? 'success' + : latestError + ? 'error' + : coreHasUpdate + ? 'update' + : coreInstalled + ? 'success' + : 'neutral'; + + const coreVersionStatusLabel = installing || progress?.running + ? (cancellingInstall ? t('kernel.install.cancelling') : progress?.phase ? localizeInstallPhase(progress.phase, t) : t('kernel.install.inProgress')) + : coreRunning + ? `${t('kernel.status.running')}${coreStatus?.processId ? ` (PID: ${coreStatus.processId})` : ''}` + : latestError + ? t('kernel.update.failed') + : coreHasUpdate + ? t('kernel.update.available') + : coreInstalled + ? t('kernel.status.stopped') + : t('kernel.status.notInstalled'); + + const platformOsLabel = platform?.os || (platformError ? t('common.detectionFailed') : t('common.detecting')); + const platformArchLabel = platform?.arch || (platformError ? t('common.detectionFailed') : t('common.detecting')); + + // Install dialog calculations + const computedPercent = progress?.percent ?? (progress?.total && progress.total > 0 ? (progress.downloaded / progress.total) * 100 : null); + const progressKnown = computedPercent !== null; + const progressPercent = clampPercent(computedPercent ?? 0); + const progressText = progress + ? progress.phase === '安装完成' + ? t('kernel.progress.completed') + : progress.phase === '解压中' + ? t('kernel.progress.extracting') + : progress.total + ? `${formatBytes(progress.downloaded)} / ${formatBytes(progress.total)}` + : progress.downloaded > 0 + ? formatBytes(progress.downloaded) + : t('kernel.progress.waiting') + : ''; + + const installDialogTone: MessageType = progress?.result + ? 'success' + : progress?.phase === '安装失败' + ? 'error' + : 'info'; + + const installDialogTitle = progress?.running || installing + ? cancellingInstall + ? t('kernel.install.titleCancelling') + : t('kernel.install.titleInstalling') + : progress?.result + ? t('kernel.install.titleCompleted') + : progress?.phase === '已取消' + ? t('kernel.install.titleCancelled') + : t('kernel.install.titleFailed'); + + const installDialogMessage = cancellingInstall + ? t('kernel.install.waitingStop') + : progress?.message || (installing ? t('kernel.install.taskRunning') : ''); + + const installDialogAction = installing || progress?.running + ? cancellingInstall + ? t('kernel.install.cancellingShort') + : progress?.cancellable + ? t('kernel.install.cancel') + : t('common.processing') + : t('common.close'); + + const installDialogActionDisabled = (installing || progress?.running) && (cancellingInstall || !progress?.cancellable); + + const isGitcodeActive = Boolean(versionSource?.preferGitcodeDownloads); + + return ( +
+ {/* Network Download Mirror Banner */} +
+
+
+
+
+
+ {t('kernel.versions.gitcodeSource')} + {isGitcodeActive ? ( + + {t('kernel.versions.gitcodeEnabled')} + + ) : null} +
+

+ {versionSource?.gitcodeAvailable === false + ? t('kernel.versions.gitcodeUnavailable') + : t('kernel.versions.gitcodeSourceHint')} +

+
+
+ +
+ +
+ + {versionSourceError ? ( +
+ + {versionSourceError} +
+ ) : null} +
+ + {/* Main Version Management Cards Grid */} +
+ {/* Card 1: 桌面客户端 (Desktop Application) */} +
+
+
+ +
+

{t('kernel.versions.appCardTitle')}

+ {t('appUpdate.title')} +
+
+ + {appVersionStatusLabel} + +
+ +
+
+ {t('appUpdate.current')} + {currentAppVersion} + {t('kernel.versions.currentRunning')} +
+ + + +
+ {t('appUpdate.latest')} + + {appUpdate ? (latestAppVersion || t('appUpdate.upToDate')) : (checkingAppUpdate ? t('appUpdate.checking') : t('common.detecting'))} + + + {appHasUpdate ? t('kernel.update.available') : t('kernel.update.latest')} + +
+
+ +
+
+ + {appUpdateError ? ( +
+ + {appUpdateError} +
+ ) : !appUpdate?.autoUpdateSupported ? ( +
+ + {t('appUpdate.manualFallback')} +
+ ) : appHasUpdate ? ( +
+ + {t('appUpdate.badgeAvailable', { version: latestAppVersion })} +
+ ) : null} + +
+ + + {appHasUpdate && appUpdate?.autoUpdateSupported ? ( + + ) : null} + + +
+
+ + {/* Card 2: 代理内核 (Proxy Core Kernel) */} +
+
+
+ +
+

{t('kernel.versions.coreCardTitle')}

+ {t('kernel.versions.title')} +
+
+ + {coreVersionStatusLabel} + +
+ +
+
+ {t('kernel.versions.current')} + + {currentVersion || t('kernel.status.notInstalled')} + + + {coreInstalled ? t('kernel.versions.currentInstalled') : t('kernel.status.notInstalled')} + +
+ + + +
+ {t('kernel.versions.latest')} + + {checkingLatest ? t('kernel.update.checking') : (latestVersion || (latestError ? t('common.detectionFailed') : t('kernel.update.notChecked')))} + + + {coreHasUpdate ? t('kernel.update.available') : t('kernel.versions.latestCloud')} + +
+
+ +
+
+
+
+
+
+ + {latestError ? ( +
+ + {latestError} +
+ ) : coreRunning ? ( +
+ + {t('kernel.versions.coreRunningNotice')} +
+ ) : null} + +
+ + + + + +
+
+ + {/* Card 3: Codex 模型目录 (Model Catalog) */} +
+
+
+ +
+

{t('kernel.versions.catalogCardTitle')}

+ {t('appUpdate.catalog.label')} +
+
+ + {catalogUpdating + ? t('appUpdate.catalog.updating') + : catalogUpdateError + ? t('kernel.update.failed') + : t('appUpdate.catalog.unchanged')} + +
+ +
+

+ {t('appUpdate.catalog.description')} +

+

+ {t('appUpdate.catalog.updateHint')} +

+ + {catalogUpdateError ? ( +
+ + {catalogUpdateError} +
+ ) : catalogUpdateNotice ? ( +
+ + {catalogUpdateNotice} +
+ ) : null} +
+ +
+ +
+
+
+ + {/* Installation Progress Modal Dialog */} + {installDialogOpen && progress ? ( +
+
+
+ {t('kernel.dialog.install')} +

{installDialogTitle}

+
+ +
+ {t('kernel.dialog.phase')} + + {cancellingInstall ? t('kernel.install.cancellingShort') : localizeInstallPhase(progress.phase, t)} + +
+ +
+ +
+ +
+ {progressKnown ? `${progressPercent.toFixed(1)}%` : t('kernel.dialog.unknownProgress')} + {progressText} +
+ +
+ {installDialogMessage || ' '} +
+ +
+ +
+
+
+ ) : null} + + {/* Floating Notice Toast */} + {toastNotice ? ( +
+ {toastNotice.tone === 'success' ? ( +
+ ) : null} +
+ ); +} + +function localizeInstallPhase( + phase: string, + t: ReturnType['t'], +) { + const keys = { + '准备下载': 'kernel.phase.preparingDownload', + '下载中': 'kernel.phase.downloading', + '解压中': 'kernel.phase.extracting', + '准备内置内核': 'kernel.phase.preparingBundled', + '安装完成': 'kernel.phase.completed', + '安装失败': 'kernel.phase.failed', + '已取消': 'kernel.phase.cancelled', + } as const; + const key = keys[phase as keyof typeof keys]; + return key ? t(key) : phase; +} + +function clampPercent(percent: number) { + return Math.min(100, Math.max(0, percent)); +} + +function formatBytes(bytes: number) { + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} diff --git a/src/styles.css b/src/styles.css index 5ba5b39..6459887 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1011,311 +1011,443 @@ textarea:focus-visible { gap: 18px; } +/* ========================================================================== + Version Management Redesign + ========================================================================== */ + .version-management-page { - gap: 20px; + display: flex; + flex-direction: column; + gap: 18px; + max-width: 1080px; + margin: 0 auto; + width: 100%; } -.version-page-header { - display: grid; - grid-template-columns: minmax(0, 1fr); + +/* Mirror & Acceleration Banner */ +.version-mirror-card { + display: flex; + flex-wrap: wrap; align-items: center; - gap: 24px; + justify-content: space-between; + gap: 14px 18px; + padding: 14px 18px; + border: 1px solid color-mix(in srgb, var(--ui-accent) 20%, var(--theme-e3e1db)); + border-radius: 12px; + background: color-mix(in srgb, var(--theme-fffdf8) 85%, var(--ui-accent-soft)); + box-shadow: var(--ui-panel-shadow); + transition: border-color 0.2s ease, background-color 0.2s ease; +} + +.version-mirror-content { + display: flex; + align-items: center; + gap: 14px; min-width: 0; + flex: 1 1 300px; +} + +.version-mirror-icon-box { + display: grid; + place-items: center; + width: 38px; + height: 38px; + flex: none; + border-radius: 10px; + color: var(--ui-accent-strong); + background: color-mix(in srgb, var(--ui-accent) 16%, transparent); +} + +.version-mirror-info { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.version-mirror-headline { + display: flex; + align-items: center; + gap: 10px; +} + +.version-mirror-headline strong { + color: var(--theme-2d2a26); + font-size: 15px; + font-weight: 600; +} + +.version-mirror-pill { + display: inline-flex; + align-items: center; + padding: 1px 8px; + font-size: 11px; + font-weight: 600; + border-radius: 999px; +} + +.version-mirror-pill.active { + color: var(--theme-2f6b3f); + background: color-mix(in srgb, var(--theme-2f6b3f) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--theme-2f6b3f) 28%, transparent); +} + +.version-mirror-pill.inactive { + color: var(--theme-7a746c); + background: var(--theme-f4f1ea); + border: 1px solid var(--theme-e3e1db); +} + +.version-mirror-info p { + margin: 0; + color: var(--theme-7a746c); + font-size: 13px; + line-height: 1.4; +} + +.version-mirror-toggle { + flex: none; +} + +.version-mirror-error { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + color: var(--theme-9f2d20); + font-size: 13px; + margin-top: 4px; +} + +/* Cards Grid */ +.version-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; + align-items: stretch; +} + +.version-module-card { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 16px; padding: 22px 24px; border: 1px solid var(--theme-e3e1db); border-radius: 14px; - background: - linear-gradient(135deg, color-mix(in srgb, var(--ui-accent-soft) 62%, transparent), transparent 48%), - var(--theme-fffdf8); + background: var(--theme-fffdf8); box-shadow: var(--ui-panel-shadow); + transition: transform 0.15s ease, box-shadow 0.15s ease; } -.version-page-title { - display: flex; - min-width: 0; - align-items: center; - gap: 14px; -} - -.version-page-title-icon, -.version-product-icon { - display: grid; - flex: none; - place-items: center; - color: var(--ui-accent-strong); - background: var(--ui-accent-soft); -} - -.version-page-title-icon { - width: 46px; - height: 46px; - border: 1px solid color-mix(in srgb, var(--ui-accent) 22%, transparent); - border-radius: 13px; -} - -.version-page-title > div { - min-width: 0; -} - -.version-page-title h1 { - margin: 0; - color: var(--theme-2d2a26); - font-size: 24px; - line-height: var(--line-height-tight); -} - -.version-page-title p { - margin: 5px 0 0; - overflow: hidden; - color: var(--theme-7a746c); - font-size: var(--font-size-meta); - line-height: var(--line-height-body); - text-overflow: ellipsis; - white-space: nowrap; -} - -.config-subpage-panel { - display: grid; - min-width: 0; - gap: 18px; -} - -.control-panel { - grid-column: auto; -} - -.version-panel { - grid-column: auto; -} - -.version-source-control { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 12px 18px; - min-width: 0; - padding: 12px 14px; - border: 1px solid color-mix(in srgb, var(--ui-accent) 17%, var(--theme-e3e1db)); - border-radius: 10px; - background: color-mix(in srgb, var(--theme-fffdf8) 82%, var(--ui-accent-soft)); -} - -.version-source-copy { - display: flex; - min-width: 0; - align-items: center; - gap: 11px; -} - -.version-source-copy > svg { - flex: none; - align-self: flex-start; - margin-top: 2px; - color: var(--ui-accent); -} - -.version-source-copy > div { - display: grid; - min-width: 0; - gap: 3px; -} - -.version-source-copy strong { - color: var(--theme-39352f); - font-size: var(--font-size-label); -} - -.version-source-copy span { - display: -webkit-box; - overflow: hidden; - color: var(--theme-7a746c); - font-size: var(--font-size-caption); - line-height: 1.4; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; -} - -.version-source-error { +.version-module-card.catalog-module-card { grid-column: 1 / -1; - margin: 0; - font-size: var(--font-size-meta); } -.version-product-card { - display: grid; - grid-template-rows: auto auto auto minmax(0, 1fr) auto; - align-content: start; - gap: 18px; - padding: 26px; - overflow: hidden; -} - -.version-product-heading { +.version-card-top { display: flex; - min-width: 0; align-items: center; justify-content: space-between; gap: 14px; } -.version-product-identity { +.version-card-title-group { display: flex; - min-width: 0; align-items: center; - gap: 11px; + gap: 12px; + min-width: 0; } -.version-product-icon { - width: 38px; - height: 38px; - border-radius: 10px; +.version-card-icon { + display: grid; + place-items: center; + width: 40px; + height: 40px; + flex: none; + border-radius: 11px; } -.version-product-icon.core { +.version-card-icon.app-icon { + color: var(--ui-accent-strong); + background: var(--ui-accent-soft); + border: 1px solid color-mix(in srgb, var(--ui-accent) 20%, transparent); +} + +.version-card-icon.core-icon { color: var(--theme-554075); background: var(--theme-f1ecfb); + border: 1px solid color-mix(in srgb, var(--theme-554075) 20%, transparent); } -.version-product-identity h2 { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.version-card-icon.catalog-icon { + color: var(--theme-3f6f98); + background: var(--theme-f1f6fa); + border: 1px solid color-mix(in srgb, var(--theme-3f6f98) 20%, transparent); } -.version-product-heading .state-pill { - min-width: 0; - max-width: 52%; - flex: none; - overflow: hidden; - text-overflow: ellipsis; +.version-card-title-group h2 { + margin: 0; + color: var(--theme-2d2a26); + font-size: 17px; + font-weight: 700; + line-height: 1.2; } -.version-comparison { +.version-card-subtitle { + display: block; + margin-top: 2px; + color: var(--theme-7a746c); + font-size: 12px; +} + +/* Metric Comparison & Tri-metrics */ +.version-metrics-comparison { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; - gap: 16px; - min-height: 118px; - padding: 20px; + gap: 12px; + padding: 14px 16px; border: 1px solid var(--theme-e3e1db); border-radius: 12px; background: var(--theme-f4f1ea); } -.version-comparison-arrow { + +.version-comparison-divider { + display: grid; + place-items: center; color: var(--theme-9a958d); } -.version-value-block { - display: grid; +.version-metric-tile { + display: flex; + flex-direction: column; + gap: 4px; min-width: 0; - gap: 7px; } -.version-value-block span { +.version-metric-label { color: var(--theme-8a847c); - font-size: var(--font-size-meta); - font-weight: 700; + font-size: 12px; + font-weight: 600; } -.version-value-block strong { - min-width: 0; +.version-metric-value { overflow: hidden; color: var(--theme-2d2a26); - font-size: var(--font-size-metric-compact); - line-height: var(--line-height-tight); + font-size: 17px; + font-weight: 700; + font-family: var(--font-family-mono, monospace); + line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; } -.version-value-block.latest strong { +.version-metric-tile.has-update .version-metric-value { color: var(--ui-accent-strong); } -.version-core-values { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - min-height: 118px; - border: 1px solid var(--theme-e3e1db); - border-radius: 12px; - background: var(--theme-f4f1ea); -} - -.version-core-values .version-value-block { - align-content: center; - padding: 18px; -} - -.version-core-values .version-value-block + .version-value-block { - border-left: 1px solid var(--theme-e3e1db); -} - -.version-core-values .version-value-block strong { - font-size: 20px; -} - -.version-card-support { - display: flex; - min-width: 0; +.version-metric-chip { + display: inline-flex; align-items: center; - gap: 11px; - min-height: 58px; - padding: 11px 13px; - border: 1px solid var(--theme-e3e1db); - border-radius: 10px; - background: var(--theme-faf8f2); + align-self: flex-start; + padding: 1px 6px; + font-size: 11px; + font-weight: 600; + border-radius: 5px; } -.version-card-support > svg { +.version-metric-chip.current { + color: var(--theme-5f5a53); + background: var(--theme-e8e5dc); +} + +.version-metric-chip.latest { + color: var(--theme-2f6b3f); + background: color-mix(in srgb, var(--theme-2f6b3f) 14%, transparent); +} + +.version-metric-chip.update { + color: var(--ui-accent-strong); + background: var(--ui-accent-soft); +} + +.version-metric-chip.warning { + color: var(--theme-c77b30); + background: color-mix(in srgb, var(--theme-c77b30) 14%, transparent); +} + +.version-metric-chip.bundled { + color: var(--theme-554075); + background: color-mix(in srgb, var(--theme-554075) 12%, transparent); +} + +/* Feature & Meta bars */ +.version-meta-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 8px; + background: var(--theme-faf8f2); + border: 1px solid var(--theme-e8e4dc); + color: var(--theme-5f5a53); + font-size: 13px; +} + +.version-meta-bar > svg { flex: none; + color: var(--ui-accent); +} + +.version-runtime-chips { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.version-runtime-chip { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 5px 10px; + border-radius: 8px; + background: var(--theme-faf8f2); + border: 1px solid var(--theme-e8e4dc); + font-size: 12px; + color: var(--theme-5f5a53); +} + +.version-runtime-chip.status.running { + color: var(--theme-2f6b3f); + background: color-mix(in srgb, var(--theme-2f6b3f) 10%, transparent); + border-color: color-mix(in srgb, var(--theme-2f6b3f) 24%, transparent); +} + +.version-runtime-chip.status.stopped { color: var(--theme-7a746c); } -.version-card-support > div { - display: grid; - min-width: 0; - gap: 2px; +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; + flex: none; } -.version-card-support strong, -.version-card-support span { - overflow: hidden; - text-overflow: ellipsis; +.version-runtime-chip.status.running .status-dot { + box-shadow: 0 0 0 2px color-mix(in srgb, var(--theme-2f6b3f) 30%, transparent); + animation: pulse-dot 2s infinite ease-in-out; +} + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.6; transform: scale(0.85); } +} + +/* Alert / Notification Banners */ +.version-alert-banner { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 14px; + border-radius: 8px; + font-size: 13px; + line-height: 1.4; +} + +.version-alert-banner > svg { + flex: none; + margin-top: 1px; +} + +.version-alert-banner.info { + color: var(--theme-5f5a53); + background: var(--theme-faf9f5); + border: 1px solid var(--theme-e3e1db); +} + +.version-alert-banner.neutral { + color: var(--theme-6d6760); + background: var(--theme-f4f1ea); + border: 1px solid var(--theme-e3e1db); +} + +.version-alert-banner.update { + color: var(--ui-accent-strong); + background: var(--ui-accent-soft); + border: 1px solid color-mix(in srgb, var(--ui-accent) 26%, transparent); +} + +.version-alert-banner.warning { + color: var(--theme-8b5b21); + background: color-mix(in srgb, var(--theme-8b5b21) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--theme-8b5b21) 24%, transparent); +} + +.version-alert-banner.error { + color: var(--theme-9f2d20); + background: var(--theme-fff1ee); + border: 1px solid color-mix(in srgb, var(--theme-9f2d20) 24%, transparent); +} + +.version-alert-banner.success { + color: var(--theme-2f6b3f); + background: var(--theme-eef7f0); + border: 1px solid color-mix(in srgb, var(--theme-2f6b3f) 24%, transparent); +} + +/* Actions */ +.version-card-actions { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); + gap: 10px; + align-items: stretch; + padding-top: 4px; +} + +.version-card-actions.core-actions { + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); +} + +.version-card-actions button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + height: 40px; + padding: 0 12px; + font-weight: 600; + border-radius: 8px; white-space: nowrap; } -.version-card-support strong { - color: var(--theme-39352f); - font-size: var(--font-size-meta); +.version-action-highlight { + box-shadow: 0 2px 8px color-mix(in srgb, var(--ui-accent) 25%, transparent); } -.version-card-support span { - color: var(--theme-7a746c); - font-size: var(--font-size-caption); +/* Catalog Card Body */ +.version-catalog-body { + display: flex; + flex-direction: column; + gap: 8px; } -.version-card-support span.success { - color: var(--theme-2f6b3f); +.version-catalog-description { + margin: 0; + color: var(--theme-2d2a26); + font-size: 14px; + line-height: 1.5; } -.version-card-support span.error { - color: var(--theme-9f2d20); -} - -.version-card-note { - align-self: start; +.version-catalog-hint { margin: 0; color: var(--theme-7a746c); - font-size: var(--font-size-caption); - line-height: var(--line-height-body); -} - -.version-card-note-slot { - min-height: 0; -} - -.version-card-note.error { - color: var(--theme-9f2d20); + font-size: 13px; + line-height: 1.4; } .client-api-panel { @@ -1517,13 +1649,6 @@ textarea:focus-visible { grid-template-rows: repeat(3, auto); } -.version-management-layout > .version-product-card { - grid-template-rows: auto auto auto minmax(0, 1fr) auto; -} - -.version-management-layout > .version-product-card > .panel-action-row { - padding-top: 0; -} .kernel-layout > .panel > .panel-heading { min-height: 0; @@ -1628,14 +1753,6 @@ textarea:focus-visible { align-items: stretch; } -.button-row.version-action-row { - grid-template-columns: repeat(2, minmax(0, 1fr)); - grid-auto-flow: row; - grid-auto-columns: unset; - grid-auto-rows: 40px; - align-items: stretch; - gap: 10px; -} .control-action-row > button { width: 100%; @@ -1651,35 +1768,8 @@ textarea:focus-visible { white-space: normal; } -.software-update-actions { - grid-template-columns: repeat(2, minmax(0, 1fr)); - grid-auto-flow: row; - grid-auto-rows: 40px; - align-items: stretch; -} - -.software-update-actions > button:last-child:nth-child(3) { - grid-column: 1 / -1; -} - -.software-update-actions > button, -.version-action-row > button { - width: 100%; - height: 40px; - min-height: 40px; - gap: 7px; - padding: 0 10px; - line-height: normal; - overflow-wrap: normal; - text-wrap: nowrap; - white-space: nowrap; -} @container workspace (min-width: 840px) { - .version-page-header { - grid-template-columns: minmax(260px, 0.8fr) minmax(360px, 1.2fr); - } - .home-page { grid-template-columns: minmax(320px, 0.8fr) minmax(0, 1.2fr); grid-auto-rows: 1fr; @@ -1723,16 +1813,6 @@ textarea:focus-visible { padding-bottom: 0; } - .version-management-layout { - grid-template-columns: repeat(2, minmax(0, 1fr)); - align-items: stretch; - } - - .version-management-layout > .panel { - height: 100%; - align-self: stretch; - } - .home-page > .home-layout > .control-panel > .panel-detail-grid, .home-page > .client-api-panel > .client-api-grid { align-content: space-evenly; @@ -1743,37 +1823,17 @@ textarea:focus-visible { } } -@container workspace (max-width: 700px) { - .version-page-header { - gap: 18px; - } - - .version-page-title p { - overflow: visible; - text-overflow: clip; - white-space: normal; - } - - .version-product-heading { - align-items: flex-start; - flex-direction: column; - } - - .version-product-heading .state-pill { - max-width: 100%; - } - - .version-core-values { +@container workspace (max-width: 760px) { + .version-grid { grid-template-columns: minmax(0, 1fr); } - .version-core-values .version-value-block { - padding: 14px 18px; + .version-module-card.catalog-module-card { + grid-column: auto; } - .version-core-values .version-value-block + .version-value-block { - border-top: 1px solid var(--theme-e3e1db); - border-left: 0; + .version-card-actions.core-actions { + grid-template-columns: minmax(0, 1fr); } } diff --git a/tests/versionManagement.test.ts b/tests/versionManagement.test.ts new file mode 100644 index 0000000..a0fb876 --- /dev/null +++ b/tests/versionManagement.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'bun:test'; +import { displayAppVersion } from '../src/pages/VersionManagementPage'; +import { appUpdateIndicatorState } from '../src/appUpdateModel'; + +describe('VersionManagement helper functions', () => { + it('formats app versions with v prefix properly', () => { + expect(displayAppVersion('1.0.0')).toBe('v1.0.0'); + expect(displayAppVersion('v1.2.3')).toBe('v1.2.3'); + expect(displayAppVersion(' 2.3.4 ')).toBe('v2.3.4'); + expect(displayAppVersion(' v3.4.5 ')).toBe('v3.4.5'); + }); + + it('resolves update indicators based on availability and processing state', () => { + expect(appUpdateIndicatorState(true, false)).toBe('available'); + expect(appUpdateIndicatorState(true, true)).toBe('processing'); + expect(appUpdateIndicatorState(false, true)).toBe('processing'); + expect(appUpdateIndicatorState(false, false)).toBeNull(); + }); +});