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
This commit is contained in:
lzt404 committed 2026-08-26 01:56:46 +08:00
1 parent 6462699a3d
commit 32b47575b5
10 files changed
+1609 -1245

No files matched your search

+101 -11
View File
@@ -24,14 +24,22 @@ pub(crate) fn detect_bundled_core() -> Result<Option<BundledCoreInfo>, 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<CoreInstallResult, String> {
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<String>,
) -> Result<CoreInstallResult, String> {
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<F>(
app: &tauri::AppHandle,
process_state: &CoreProcessState,
gui_config_state: &GuiConfigState,
install: F,
) -> Result<CoreInstallResult, String>
where
F: FnOnce() -> Result<CoreInstallResult, String>,
{
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<bool, String> {
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<T>(
app: &tauri::AppHandle,
process_state: &CoreProcessState,
config: &GuiConfigFile,
was_running: bool,
install_result: Result<T, String>,
) -> Result<T, String> {
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<T>(
install_result: Result<T, String>,
restart_result: Result<(), String>,
) -> Result<T, String> {
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,
+34
View File
@@ -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");
+4 -3
View File
@@ -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 <KernelPage view="home" />;
}
function VersionManagementPage() {
return <KernelPage view="versions" />;
function VersionManagementPageWrapper() {
return <VersionManagementPage />;
}
function App() {
+20
View File
@@ -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': '最新バージョン',
+20
View File
@@ -936,6 +936,26 @@ export const en: Record<MessageKey, string> = {
'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',
+20
View File
@@ -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': '最新版本',
+53 -928
View File
File diff suppressed because it is too large. Load diff
+975
View File
@@ -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<CoreLatest> | 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<CoreLatest>('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<CorePlatform | null>(null);
const [platformError, setPlatformError] = useState('');
const [latest, setLatest] = useState<CoreLatest | null>(cachedLatest);
const [latestError, setLatestError] = useState(cachedLatestError);
const [checkingLatest, setCheckingLatest] = useState(Boolean(latestCheckPromise));
const [installing, setInstalling] = useState(false);
const [progress, setProgress] = useState<CoreInstallTask | null>(null);
const [installDialogOpen, setInstallDialogOpen] = useState(false);
const [cancellingInstall, setCancellingInstall] = useState(false);
const [versionSource, setVersionSource] = useState<VersionSourceSettings | null>(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<HTMLDivElement>(null);
const latestCheckEpochRef = useRef(0);
const toastTimerRef = useRef<number | null>(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<CorePlatform>('detect_core_platform');
setPlatform(result);
setPlatformError('');
} catch (error) {
setPlatform(null);
setPlatformError(String(error));
}
};
const loadVersionSourceSettings = async () => {
try {
const settings = await invoke<VersionSourceSettings>('get_version_source_settings');
setVersionSource(settings);
setVersionSourceError('');
} catch (error) {
setVersionSourceError(String(error));
}
};
const loadInstallTask = async () => {
try {
const task = await invoke<CoreInstallTask>('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<VersionSourceSettings>('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<CoreInstallResult>('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<CodexModelCatalogUpdateResult>('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<CoreInstallTask>('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<typeof t>[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 (
<section className="page management-page version-management-page">
{/* Network Download Mirror Banner */}
<section className="version-mirror-card" aria-label={t('kernel.versions.gitcodeSource')}>
<div className="version-mirror-content">
<div className="version-mirror-icon-box">
<Zap size={22} aria-hidden="true" />
</div>
<div className="version-mirror-info">
<div className="version-mirror-headline">
<strong>{t('kernel.versions.gitcodeSource')}</strong>
{isGitcodeActive ? (
<span className="version-mirror-pill active">
{t('kernel.versions.gitcodeEnabled')}
</span>
) : null}
</div>
<p>
{versionSource?.gitcodeAvailable === false
? t('kernel.versions.gitcodeUnavailable')
: t('kernel.versions.gitcodeSourceHint')}
</p>
</div>
</div>
<div className="version-mirror-toggle">
<label className="switch-control" title={t('kernel.versions.gitcodeSource')}>
<input
type="checkbox"
role="switch"
checked={isGitcodeActive}
disabled={
(!versionSource?.gitcodeAvailable && !versionSource?.preferGitcodeDownloads)
|| versionSourceSaving
|| appUpdateTask.running
|| installing
}
aria-label={t('kernel.versions.gitcodeSource')}
onChange={(event) => void updateVersionSource(event.currentTarget.checked)}
/>
<span className="switch-track" />
</label>
</div>
{versionSourceError ? (
<div className="version-mirror-error" role="alert">
<AlertCircle size={15} />
<span>{versionSourceError}</span>
</div>
) : null}
</section>
{/* Main Version Management Cards Grid */}
<div className="version-grid">
{/* Card 1: 桌面客户端 (Desktop Application) */}
<article className="panel version-module-card app-module-card">
<div className="version-card-top">
<div className="version-card-title-group">
<span className="version-card-icon app-icon" aria-hidden="true">
<AppWindow size={22} />
</span>
<div>
<h2>{t('kernel.versions.appCardTitle')}</h2>
<small className="version-card-subtitle">{t('appUpdate.title')}</small>
</div>
</div>
<span className={`state-pill ${appVersionStatusTone}`} title={appUpdateError || appVersionStatusLabel}>
{appVersionStatusLabel}
</span>
</div>
<div className="version-metrics-comparison">
<div className="version-metric-tile">
<span className="version-metric-label">{t('appUpdate.current')}</span>
<strong className="version-metric-value">{currentAppVersion}</strong>
<span className="version-metric-chip current">{t('kernel.versions.currentRunning')}</span>
</div>
<div className="version-comparison-divider" aria-hidden="true">
<ArrowRight size={18} />
</div>
<div className={`version-metric-tile ${appHasUpdate ? 'has-update' : ''}`}>
<span className="version-metric-label">{t('appUpdate.latest')}</span>
<strong className="version-metric-value">
{appUpdate ? (latestAppVersion || t('appUpdate.upToDate')) : (checkingAppUpdate ? t('appUpdate.checking') : t('common.detecting'))}
</strong>
<span className={`version-metric-chip ${appHasUpdate ? 'update' : 'latest'}`}>
{appHasUpdate ? t('kernel.update.available') : t('kernel.update.latest')}
</span>
</div>
</div>
<div className="version-meta-bar">
<Sparkles size={16} aria-hidden="true" />
<span>
{appUpdate?.autoUpdateSupported
? t('kernel.versions.autoUpdateSupported')
: t('kernel.versions.autoUpdateManual')}
</span>
</div>
{appUpdateError ? (
<div className="version-alert-banner error" role="alert">
<AlertCircle size={16} />
<span>{appUpdateError}</span>
</div>
) : !appUpdate?.autoUpdateSupported ? (
<div className="version-alert-banner neutral">
<Info size={16} />
<span>{t('appUpdate.manualFallback')}</span>
</div>
) : appHasUpdate ? (
<div className="version-alert-banner update">
<Download size={16} />
<span>{t('appUpdate.badgeAvailable', { version: latestAppVersion })}</span>
</div>
) : null}
<div className="version-card-actions">
<button
type="button"
className="secondary-button"
disabled={checkingAppUpdate || appUpdateTask.running}
onClick={() => void checkAppUpdate()}
>
<RefreshCw size={15} className={checkingAppUpdate ? 'spin' : ''} aria-hidden="true" />
<span>{checkingAppUpdate ? t('appUpdate.checking') : t('appUpdate.check')}</span>
</button>
{appHasUpdate && appUpdate?.autoUpdateSupported ? (
<button
type="button"
className="primary-button version-action-highlight"
disabled={appUpdateTask.running}
onClick={requestAppUpdate}
>
<Download size={15} aria-hidden="true" />
<span>{t('appUpdate.installNow')}</span>
</button>
) : null}
<button
type="button"
className={appHasUpdate && !appUpdate?.autoUpdateSupported ? 'primary-button' : 'secondary-button'}
onClick={() => void openAppRelease()}
>
<ExternalLink size={15} aria-hidden="true" />
<span>{t('appUpdate.openRelease')}</span>
</button>
</div>
</article>
{/* Card 2: 代理内核 (Proxy Core Kernel) */}
<article className="panel version-module-card core-module-card">
<div className="version-card-top">
<div className="version-card-title-group">
<span className="version-card-icon core-icon" aria-hidden="true">
<Box size={22} />
</span>
<div>
<h2>{t('kernel.versions.coreCardTitle')}</h2>
<small className="version-card-subtitle">{t('kernel.versions.title')}</small>
</div>
</div>
<span className={`state-pill ${coreVersionStatusTone}`} title={coreVersionStatusLabel}>
{coreVersionStatusLabel}
</span>
</div>
<div className="version-metrics-comparison">
<div className="version-metric-tile">
<span className="version-metric-label">{t('kernel.versions.current')}</span>
<strong className="version-metric-value" title={currentVersion || t('kernel.status.notInstalled')}>
{currentVersion || t('kernel.status.notInstalled')}
</strong>
<span className={`version-metric-chip ${coreInstalled ? 'current' : 'warning'}`}>
{coreInstalled ? t('kernel.versions.currentInstalled') : t('kernel.status.notInstalled')}
</span>
</div>
<div className="version-comparison-divider" aria-hidden="true">
<ArrowRight size={18} />
</div>
<div className={`version-metric-tile ${coreHasUpdate ? 'has-update' : ''}`}>
<span className="version-metric-label">{t('kernel.versions.latest')}</span>
<strong className="version-metric-value" title={latestVersion || latestError || t('kernel.update.notChecked')}>
{checkingLatest ? t('kernel.update.checking') : (latestVersion || (latestError ? t('common.detectionFailed') : t('kernel.update.notChecked')))}
</strong>
<span className={`version-metric-chip ${coreHasUpdate ? 'update' : 'latest'}`}>
{coreHasUpdate ? t('kernel.update.available') : t('kernel.versions.latestCloud')}
</span>
</div>
</div>
<div className="version-runtime-chips">
<div className="version-runtime-chip platform">
<Cpu size={15} aria-hidden="true" />
<span><strong>{t('kernel.versions.platform')}:</strong> {platformOsLabel} / {platformArchLabel}</span>
</div>
<div className={`version-runtime-chip status ${coreRunning ? 'running' : 'stopped'}`}>
<span className="status-dot" aria-hidden="true" />
<span>{coreRunning ? t('kernel.status.running') : t('kernel.control.notRunning')}</span>
</div>
</div>
{latestError ? (
<div className="version-alert-banner error" role="alert">
<AlertCircle size={16} />
<span>{latestError}</span>
</div>
) : coreRunning ? (
<div className="version-alert-banner info">
<Info size={16} />
<span>{t('kernel.versions.coreRunningNotice')}</span>
</div>
) : null}
<div className="version-card-actions core-actions">
<button
type="button"
className="secondary-button"
disabled={busy}
onClick={() => void checkLatest(true)}
>
<RefreshCw size={15} className={checkingLatest ? 'spin' : ''} aria-hidden="true" />
<span>{checkingLatest ? t('kernel.update.checking') : t('kernel.versions.check')}</span>
</button>
<button
type="button"
className={latestVersion && (!coreInstalled || currentVersion !== latestVersion) ? 'primary-button version-action-highlight' : 'secondary-button'}
title={latestVersion ? t('kernel.versions.stopAndUpdateVersion', { version: latestVersion }) : t('kernel.versions.installLatest')}
disabled={!latestVersion || busy}
onClick={() => void installVersion(latestVersion)}
>
<Download size={15} aria-hidden="true" />
<span>{!coreInstalled ? t('kernel.versions.installLatestMissing') : t('kernel.versions.installLatest')}</span>
</button>
<button
type="button"
className="secondary-button"
title={t('kernel.versions.reinstallTitle')}
disabled={!currentVersion || installDisabled}
onClick={() => void installVersion(currentVersion)}
>
<RotateCcw size={15} aria-hidden="true" />
<span>{t('kernel.versions.reinstall')}</span>
</button>
</div>
</article>
{/* Card 3: Codex 模型目录 (Model Catalog) */}
<article className="panel version-module-card catalog-module-card">
<div className="version-card-top">
<div className="version-card-title-group">
<span className="version-card-icon catalog-icon" aria-hidden="true">
<Database size={22} />
</span>
<div>
<h2>{t('kernel.versions.catalogCardTitle')}</h2>
<small className="version-card-subtitle">{t('appUpdate.catalog.label')}</small>
</div>
</div>
<span className={`state-pill ${catalogUpdateError ? 'error' : catalogUpdating ? 'update' : 'success'}`}>
{catalogUpdating
? t('appUpdate.catalog.updating')
: catalogUpdateError
? t('kernel.update.failed')
: t('appUpdate.catalog.unchanged')}
</span>
</div>
<div className="version-catalog-body">
<p className="version-catalog-description">
{t('appUpdate.catalog.description')}
</p>
<p className="version-catalog-hint">
{t('appUpdate.catalog.updateHint')}
</p>
{catalogUpdateError ? (
<div className="version-alert-banner error" role="alert">
<AlertCircle size={16} />
<span>{catalogUpdateError}</span>
</div>
) : catalogUpdateNotice ? (
<div className="version-alert-banner success">
<Check size={16} />
<span>{catalogUpdateNotice}</span>
</div>
) : null}
</div>
<div className="version-card-actions">
<button
type="button"
className="primary-button"
disabled={catalogUpdating || appUpdateTask.running || busy}
onClick={() => void syncModelCatalog()}
title={t('appUpdate.catalog.updateHint')}
>
<Database size={15} className={catalogUpdating ? 'spin' : ''} aria-hidden="true" />
<span>
{catalogUpdating
? t('appUpdate.catalog.updating')
: t('appUpdate.catalog.update')}
</span>
</button>
</div>
</article>
</div>
{/* Installation Progress Modal Dialog */}
{installDialogOpen && progress ? (
<div className="install-dialog-backdrop">
<div
ref={installDialogRef}
className={`install-dialog ${installDialogTone}`}
role="dialog"
aria-modal="true"
aria-labelledby="install-dialog-title"
aria-describedby="install-dialog-message"
aria-busy={installing || progress?.running}
tabIndex={-1}
>
<div className="install-dialog-heading">
<span className="install-dialog-eyebrow">{t('kernel.dialog.install')}</span>
<h2 id="install-dialog-title">{installDialogTitle}</h2>
</div>
<div className="install-dialog-phase">
<span>{t('kernel.dialog.phase')}</span>
<strong className="phase-badge">
{cancellingInstall ? t('kernel.install.cancellingShort') : localizeInstallPhase(progress.phase, t)}
</strong>
</div>
<div
className={`install-progress-track ${
progressKnown ? '' : (installing || progress?.running) ? 'unknown is-running' : 'unknown'
}`}
>
<span
className="install-progress-fill"
style={progressKnown ? { width: `${progressPercent}%` } : undefined}
/>
</div>
<div className="install-progress-meta">
<strong>{progressKnown ? `${progressPercent.toFixed(1)}%` : t('kernel.dialog.unknownProgress')}</strong>
<span>{progressText}</span>
</div>
<div
id="install-dialog-message"
className={`install-dialog-message ${installDialogTone}`}
aria-live="polite"
>
{installDialogMessage || ' '}
</div>
<div className="install-dialog-actions">
<button
type="button"
className={(installing || progress?.running) ? 'danger-button' : 'primary-button'}
disabled={installDialogActionDisabled}
onClick={(installing || progress?.running) ? cancelInstall : closeInstallDialog}
>
{installDialogAction}
</button>
</div>
</div>
</div>
) : null}
{/* Floating Notice Toast */}
{toastNotice ? (
<div
className={`config-toast ${toastNotice.tone}`}
role="status"
title={toastNotice.message}
>
{toastNotice.tone === 'success' ? (
<Check size={18} aria-hidden="true" />
) : toastNotice.tone === 'error' ? (
<AlertCircle size={18} aria-hidden="true" />
) : (
<Info size={18} aria-hidden="true" />
)}
<span>{toastNotice.message}</span>
</div>
) : null}
</section>
);
}
function localizeInstallPhase(
phase: string,
t: ReturnType<typeof useI18n>['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`;
}
+363 -303
View File
@@ -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);
}
}
+19
View File
@@ -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();
});
});