fix(tmux): polish installer banner state

This commit is contained in:
777genius 2026-04-14 22:08:10 +03:00
parent 898a795182
commit ff9344c85a
3 changed files with 102 additions and 30 deletions

View file

@ -50,6 +50,8 @@ interface AdaptInput {
detailsOpen: boolean; detailsOpen: boolean;
} }
const RESTART_REQUIRED_PATTERNS = ['restart', 'reboot', 'перезагруз', 'требуется перезагрузка'];
export class TmuxInstallerBannerAdapter { export class TmuxInstallerBannerAdapter {
static create(): TmuxInstallerBannerAdapter { static create(): TmuxInstallerBannerAdapter {
return new TmuxInstallerBannerAdapter(); return new TmuxInstallerBannerAdapter();
@ -58,19 +60,20 @@ export class TmuxInstallerBannerAdapter {
adapt(input: AdaptInput): TmuxInstallerBannerViewModel { adapt(input: AdaptInput): TmuxInstallerBannerViewModel {
const status = input.status; const status = input.status;
const snapshot = input.snapshot; const snapshot = input.snapshot;
const displayPhase = this.#resolveDisplayPhase(snapshot, status);
const hasActiveInstallFlow = const hasActiveInstallFlow =
snapshot.phase !== 'idle' && snapshot.phase !== 'completed' && snapshot.phase !== 'cancelled'; displayPhase !== 'idle' && displayPhase !== 'completed' && displayPhase !== 'cancelled';
const tmuxMissing = status ? !status.effective.available : !input.loading; const tmuxMissing = status ? !status.effective.available : !input.loading;
const visible = const visible =
hasActiveInstallFlow || (snapshot.phase !== 'completed' && !input.loading && tmuxMissing); hasActiveInstallFlow || (displayPhase !== 'completed' && !input.loading && tmuxMissing);
const title = const title =
snapshot.message && snapshot.message &&
(snapshot.phase === 'pending_external_elevation' || (displayPhase === 'pending_external_elevation' ||
snapshot.phase === 'waiting_for_external_step' || displayPhase === 'waiting_for_external_step' ||
snapshot.phase === 'needs_restart' || displayPhase === 'needs_restart' ||
snapshot.phase === 'needs_manual_step') displayPhase === 'needs_manual_step')
? snapshot.message ? snapshot.message
: formatTmuxInstallerTitle(snapshot.phase); : formatTmuxInstallerTitle(displayPhase);
const primaryGuideUrl = const primaryGuideUrl =
status?.autoInstall.manualHints.find((hint) => typeof hint.url === 'string')?.url ?? null; status?.autoInstall.manualHints.find((hint) => typeof hint.url === 'string')?.url ?? null;
const body = const body =
@ -95,7 +98,7 @@ export class TmuxInstallerBannerAdapter {
const manualHints = status?.autoInstall.manualHints ?? []; const manualHints = status?.autoInstall.manualHints ?? [];
const manualHintsCollapsible = status?.platform === 'win32' && manualHints.length > 0; const manualHintsCollapsible = status?.platform === 'win32' && manualHints.length > 0;
const installLabel = const installLabel =
snapshot.phase === 'idle' && displayPhase === 'idle' &&
status?.platform === 'win32' && status?.platform === 'win32' &&
status.autoInstall.strategy === 'wsl' && status.autoInstall.strategy === 'wsl' &&
status.autoInstall.supported status.autoInstall.supported
@ -104,16 +107,16 @@ export class TmuxInstallerBannerAdapter {
: !status.wsl?.distroName : !status.wsl?.distroName
? 'Install Ubuntu in WSL' ? 'Install Ubuntu in WSL'
: 'Install tmux in WSL' : 'Install tmux in WSL'
: formatInstallButtonLabel(snapshot.phase); : formatInstallButtonLabel(displayPhase);
const installDisabled = const installDisabled =
input.loading || input.loading ||
snapshot.phase === 'preparing' || displayPhase === 'preparing' ||
snapshot.phase === 'checking' || displayPhase === 'checking' ||
snapshot.phase === 'requesting_privileges' || displayPhase === 'requesting_privileges' ||
snapshot.phase === 'pending_external_elevation' || displayPhase === 'pending_external_elevation' ||
snapshot.phase === 'waiting_for_external_step' || displayPhase === 'waiting_for_external_step' ||
snapshot.phase === 'installing' || displayPhase === 'installing' ||
snapshot.phase === 'verifying'; displayPhase === 'verifying';
const installButtonPrimary = const installButtonPrimary =
!installDisabled && (installLabel.startsWith('Install') || installLabel.startsWith('Retry')); !installDisabled && (installLabel.startsWith('Install') || installLabel.startsWith('Retry'));
const showRefreshButton = const showRefreshButton =
@ -131,8 +134,8 @@ export class TmuxInstallerBannerAdapter {
locationLabel: formatTmuxLocationLabel(status?.effective.location ?? null), locationLabel: formatTmuxLocationLabel(status?.effective.location ?? null),
runtimeReadyLabel, runtimeReadyLabel,
versionLabel, versionLabel,
phase: snapshot.phase, phase: displayPhase,
progressPercent: formatTmuxInstallerProgress(snapshot.phase), progressPercent: formatTmuxInstallerProgress(displayPhase),
logs: snapshot.logs, logs: snapshot.logs,
manualHints, manualHints,
manualHintsCollapsible, manualHintsCollapsible,
@ -149,4 +152,23 @@ export class TmuxInstallerBannerAdapter {
detailsOpen: input.detailsOpen, detailsOpen: input.detailsOpen,
}; };
} }
#resolveDisplayPhase(
snapshot: TmuxInstallerSnapshot,
status: TmuxStatus | null
): TmuxInstallerSnapshot['phase'] {
if (snapshot.phase !== 'waiting_for_external_step') {
return snapshot.phase;
}
const combinedSignals = [snapshot.message, snapshot.detail, status?.wsl?.statusDetail, ...snapshot.logs]
.filter(Boolean)
.join('\n')
.toLowerCase();
const restartRequired =
status?.wsl?.rebootRequired === true ||
RESTART_REQUIRED_PATTERNS.some((pattern) => combinedSignals.includes(pattern));
return restartRequired ? 'needs_restart' : snapshot.phase;
}
} }

View file

@ -350,4 +350,47 @@ describe('TmuxInstallerBannerAdapter', () => {
expect(result.installDisabled).toBe(true); expect(result.installDisabled).toBe(true);
expect(result.showRefreshButton).toBe(true); expect(result.showRefreshButton).toBe(true);
}); });
it('shows a restart state when external-step details already require a reboot', () => {
const adapter = TmuxInstallerBannerAdapter.create();
const result = adapter.adapt({
status: {
...baseStatus,
platform: 'win32',
autoInstall: {
...baseStatus.autoInstall,
supported: true,
strategy: 'wsl',
},
wsl: {
wslInstalled: true,
rebootRequired: false,
distroName: null,
distroVersion: null,
distroBootstrapped: false,
innerPackageManager: null,
tmuxAvailableInsideWsl: false,
tmuxVersion: null,
tmuxBinaryPath: null,
statusDetail: null,
},
},
snapshot: {
...idleSnapshot,
phase: 'waiting_for_external_step',
strategy: 'wsl',
message: 'Checking WSL after the administrator step...',
detail:
'Требуемая операция выполнена успешно. Чтобы сделанные изменения вступили в силу, следует перезагрузить систему.',
},
loading: false,
error: null,
detailsOpen: false,
});
expect(result.phase).toBe('needs_restart');
expect(result.progressPercent).toBe(96);
expect(result.installLabel).toBe('Re-check after restart');
});
}); });

View file

@ -79,7 +79,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
return ( return (
<div <div
className={`mb-6 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`} className={`mb-6 rounded-lg border-l-4 px-3 ${expanded ? `py-3 ${BANNER_MIN_H}` : 'py-2.5'}`}
style={{ style={{
borderLeftColor: viewModel.error ? '#ef4444' : '#f59e0b', borderLeftColor: viewModel.error ? '#ef4444' : '#f59e0b',
backgroundColor: 'rgba(245, 158, 11, 0.08)', backgroundColor: 'rgba(245, 158, 11, 0.08)',
@ -90,28 +90,35 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
type="button" type="button"
aria-expanded={expanded} aria-expanded={expanded}
onClick={() => setExpanded((current) => !current)} onClick={() => setExpanded((current) => !current)}
className="flex min-h-[1.75rem] w-full items-center justify-between gap-3 text-left" className="group flex w-full items-center justify-between gap-3 rounded-md px-1 py-0.5 text-left transition-colors hover:bg-white/[0.03]"
> >
<span className="flex min-w-0 items-center gap-3"> <span className="flex min-w-0 items-center gap-2.5">
<span className="shrink-0"> <span className="inline-flex shrink-0 items-center justify-center">
{viewModel.error ? ( {viewModel.error ? (
<AlertTriangle className="size-4 text-red-300" /> <AlertTriangle className="size-3.5 text-red-300" />
) : ( ) : (
<Wrench className="size-4 text-amber-300" /> <Wrench className="size-3.5 text-amber-300" />
)} )}
</span> </span>
<span <span
className="truncate text-sm leading-5" className="truncate text-xs font-medium leading-5"
style={{ color: 'var(--color-text)' }} style={{ color: 'var(--color-text)' }}
> >
{SUMMARY_TITLE} {SUMMARY_TITLE}
</span> </span>
</span> </span>
{expanded ? ( <span
<ChevronUp className="size-4 shrink-0" /> className="inline-flex size-6 shrink-0 items-center justify-center rounded-md transition-colors group-hover:bg-white/[0.03]"
) : ( style={{
<ChevronDown className="size-4 shrink-0" /> color: 'var(--color-text-secondary)',
)} }}
>
{expanded ? (
<ChevronUp className="size-3.5 shrink-0" />
) : (
<ChevronDown className="size-3.5 shrink-0" />
)}
</span>
</button> </button>
{expanded && ( {expanded && (