fix(tmux): collapse installer banner by default

This commit is contained in:
777genius 2026-04-14 21:52:06 +03:00
parent 51aac9b7a1
commit 80221884ed
2 changed files with 383 additions and 284 deletions

View file

@ -12,6 +12,8 @@ import {
import { useTmuxInstallerBanner } from '../hooks/useTmuxInstallerBanner'; import { useTmuxInstallerBanner } from '../hooks/useTmuxInstallerBanner';
const SUMMARY_TITLE = 'tmux is not installed';
const SourceLink = ({ const SourceLink = ({
label, label,
url, url,
@ -35,8 +37,10 @@ const SourceLink = ({
export function TmuxInstallerBannerView(): React.JSX.Element | null { export function TmuxInstallerBannerView(): React.JSX.Element | null {
const { viewModel, install, cancel, submitInput, refresh, toggleDetails, openExternal } = const { viewModel, install, cancel, submitInput, refresh, toggleDetails, openExternal } =
useTmuxInstallerBanner(); useTmuxInstallerBanner();
const [expanded, setExpanded] = React.useState(false);
const [inputValue, setInputValue] = React.useState(''); const [inputValue, setInputValue] = React.useState('');
const [manualHintsExpanded, setManualHintsExpanded] = React.useState(false); const [manualHintsExpanded, setManualHintsExpanded] = React.useState(false);
const previousPhaseRef = React.useRef(viewModel.phase);
React.useEffect(() => { React.useEffect(() => {
if (!viewModel.acceptsInput) { if (!viewModel.acceptsInput) {
@ -50,6 +54,21 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
} }
}, [viewModel.manualHintsCollapsible]); }, [viewModel.manualHintsCollapsible]);
React.useEffect(() => {
const previousPhase = previousPhaseRef.current;
const becameActive =
previousPhase === 'idle' &&
viewModel.phase !== 'idle' &&
viewModel.phase !== 'completed' &&
viewModel.phase !== 'cancelled';
if (becameActive) {
setExpanded(true);
}
previousPhaseRef.current = viewModel.phase;
}, [viewModel.phase]);
if (!viewModel.visible) { if (!viewModel.visible) {
return null; return null;
} }
@ -66,297 +85,320 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
borderColor: 'rgba(245, 158, 11, 0.2)', borderColor: 'rgba(245, 158, 11, 0.2)',
}} }}
> >
<div className="space-y-3"> <button
<div className="min-w-0 max-w-4xl"> type="button"
<div className="flex items-start gap-2 text-base font-semibold leading-6"> aria-expanded={expanded}
<span className="pt-0.5"> onClick={() => setExpanded((current) => !current)}
{viewModel.error ? ( className="flex w-full items-center justify-between gap-3 text-left"
<AlertTriangle className="size-4 text-red-300" /> >
) : ( <span className="flex min-w-0 items-start gap-2 text-base font-semibold leading-6">
<Wrench className="size-4 text-amber-300" /> <span className="pt-0.5">
)} {viewModel.error ? (
</span> <AlertTriangle className="size-4 text-red-300" />
<span>{viewModel.title}</span> ) : (
</div> <Wrench className="size-4 text-amber-300" />
<p )}
className="mt-2 text-[15px] leading-7" </span>
style={{ color: 'var(--color-text-secondary)' }} <span className="truncate">{SUMMARY_TITLE}</span>
> </span>
{viewModel.body} {expanded ? (
</p> <ChevronUp className="size-4 shrink-0" />
{viewModel.benefitsBody && ( ) : (
<div <ChevronDown className="size-4 shrink-0" />
className="mt-3 max-w-4xl rounded-md border px-3 py-2 text-[13px] leading-6" )}
style={{ </button>
borderColor: 'rgba(245, 158, 11, 0.18)',
backgroundColor: 'rgba(255, 255, 255, 0.03)',
color: 'var(--color-text-secondary)',
}}
>
{viewModel.benefitsBody}
</div>
)}
{(viewModel.platformLabel ||
viewModel.locationLabel ||
viewModel.runtimeReadyLabel ||
viewModel.versionLabel ||
viewModel.phase !== 'idle') && (
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px]">
{viewModel.platformLabel && (
<span
className="rounded-full px-2 py-1"
style={{
color: 'var(--color-text-muted)',
backgroundColor: 'rgba(255, 255, 255, 0.04)',
}}
>
Detected OS: {viewModel.platformLabel}
</span>
)}
{viewModel.locationLabel && (
<span
className="rounded-full px-2 py-1"
style={{
color: 'var(--color-text-muted)',
backgroundColor: 'rgba(255, 255, 255, 0.04)',
}}
>
Runtime path: {viewModel.locationLabel}
</span>
)}
{viewModel.runtimeReadyLabel && (
<span
className="rounded-full px-2 py-1"
style={{
color: 'var(--color-text-muted)',
backgroundColor: 'rgba(255, 255, 255, 0.04)',
}}
>
{viewModel.runtimeReadyLabel}
</span>
)}
{viewModel.versionLabel && (
<span
className="rounded-full px-2 py-1"
style={{
color: 'var(--color-text-muted)',
backgroundColor: 'rgba(255, 255, 255, 0.04)',
}}
>
{viewModel.versionLabel}
</span>
)}
{viewModel.phase !== 'idle' && (
<span
className="rounded-full px-2 py-1"
style={{
color: 'var(--color-text-muted)',
backgroundColor: 'rgba(255, 255, 255, 0.04)',
}}
>
Phase: {viewModel.phase}
</span>
)}
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2 pt-1"> {expanded && (
{viewModel.installSupported && ( <div className="mt-4 space-y-3">
<button <div className="min-w-0 max-w-4xl">
type="button" {viewModel.title !== SUMMARY_TITLE && (
onClick={() => void install()} <div
disabled={viewModel.installDisabled} className="text-sm font-medium leading-6"
className={`inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${ style={{ color: 'var(--color-text-secondary)' }}
viewModel.installButtonPrimary ? 'hover:bg-emerald-500/20' : 'hover:bg-white/5' >
}`} {viewModel.title}
style={ </div>
viewModel.installButtonPrimary )}
? { <p
borderColor: 'rgba(34, 197, 94, 0.75)', className="mt-2 text-[15px] leading-7"
backgroundColor: 'rgba(34, 197, 94, 0.16)', style={{ color: 'var(--color-text-secondary)' }}
color: '#dcfce7',
}
: { borderColor: 'var(--color-border)' }
}
> >
<Wrench className="size-4" /> {viewModel.body}
{viewModel.installLabel} </p>
</button> {viewModel.benefitsBody && (
)} <div
{viewModel.canCancel && ( className="mt-3 max-w-4xl rounded-md border px-3 py-2 text-[13px] leading-6"
<button style={{
type="button" borderColor: 'rgba(245, 158, 11, 0.18)',
onClick={() => void cancel()} backgroundColor: 'rgba(255, 255, 255, 0.03)',
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5" color: 'var(--color-text-secondary)',
style={{ borderColor: 'var(--color-border)' }} }}
> >
<XCircle className="size-4" /> {viewModel.benefitsBody}
Cancel </div>
</button> )}
)} {(viewModel.platformLabel ||
{viewModel.primaryGuideUrl && ( viewModel.locationLabel ||
<button viewModel.runtimeReadyLabel ||
type="button" viewModel.versionLabel ||
onClick={() => void openExternal(viewModel.primaryGuideUrl)} viewModel.phase !== 'idle') && (
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5" <div className="mt-3 flex flex-wrap items-center gap-2 text-[11px]">
style={{ borderColor: 'var(--color-border)' }} {viewModel.platformLabel && (
> <span
<ExternalLink className="size-4" /> className="rounded-full px-2 py-1"
Manual guide style={{
</button> color: 'var(--color-text-muted)',
)} backgroundColor: 'rgba(255, 255, 255, 0.04)',
{viewModel.manualHintsCollapsible && ( }}
<button >
type="button" Detected OS: {viewModel.platformLabel}
onClick={() => setManualHintsExpanded((current) => !current)} </span>
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5" )}
style={{ borderColor: 'var(--color-border)' }} {viewModel.locationLabel && (
> <span
{manualHintsExpanded ? ( className="rounded-full px-2 py-1"
<ChevronUp className="size-4" /> style={{
) : ( color: 'var(--color-text-muted)',
<ChevronDown className="size-4" /> backgroundColor: 'rgba(255, 255, 255, 0.04)',
)} }}
{manualHintsExpanded >
? 'Hide setup steps' Runtime path: {viewModel.locationLabel}
: `Show setup steps (${viewModel.manualHints.length})`} </span>
</button> )}
)} {viewModel.runtimeReadyLabel && (
{viewModel.showRefreshButton && ( <span
<button className="rounded-full px-2 py-1"
type="button" style={{
onClick={() => void refresh()} color: 'var(--color-text-muted)',
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5" backgroundColor: 'rgba(255, 255, 255, 0.04)',
style={{ borderColor: 'var(--color-border)' }} }}
> >
<RefreshCw className="size-4" /> {viewModel.runtimeReadyLabel}
Re-check </span>
</button> )}
)} {viewModel.versionLabel && (
</div> <span
</div> className="rounded-full px-2 py-1"
style={{
{viewModel.progressPercent !== null && ( color: 'var(--color-text-muted)',
<div className="mt-3"> backgroundColor: 'rgba(255, 255, 255, 0.04)',
<div className="mb-1 flex items-center justify-between text-[11px]"> }}
<span style={{ color: 'var(--color-text-muted)' }}>Installer progress</span> >
<span style={{ color: 'var(--color-text-secondary)' }}> {viewModel.versionLabel}
{viewModel.progressPercent}% </span>
</span> )}
{viewModel.phase !== 'idle' && (
<span
className="rounded-full px-2 py-1"
style={{
color: 'var(--color-text-muted)',
backgroundColor: 'rgba(255, 255, 255, 0.04)',
}}
>
Phase: {viewModel.phase}
</span>
)}
</div>
)}
</div> </div>
<div
className="h-2 overflow-hidden rounded-full"
style={{ backgroundColor: 'rgba(255, 255, 255, 0.08)' }}
>
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${viewModel.progressPercent}%`,
backgroundColor: viewModel.error ? '#ef4444' : '#f59e0b',
}}
/>
</div>
</div>
)}
{viewModel.acceptsInput && ( <div className="flex flex-wrap items-center gap-2 pt-1">
<div className="mt-3 space-y-2"> {viewModel.installSupported && (
<form <button
className="flex flex-col gap-2 sm:flex-row sm:items-center" type="button"
onSubmit={(event) => { onClick={() => void install()}
event.preventDefault(); disabled={viewModel.installDisabled}
void (async () => { className={`inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
const submitted = await submitInput(inputValue); viewModel.installButtonPrimary ? 'hover:bg-emerald-500/20' : 'hover:bg-white/5'
if (submitted) { }`}
setInputValue(''); style={
viewModel.installButtonPrimary
? {
borderColor: 'rgba(34, 197, 94, 0.75)',
backgroundColor: 'rgba(34, 197, 94, 0.16)',
color: '#dcfce7',
}
: { borderColor: 'var(--color-border)' }
} }
})(); >
}} <Wrench className="size-4" />
> {viewModel.installLabel}
<input </button>
type={viewModel.inputSecret ? 'password' : 'text'} )}
value={inputValue} {viewModel.canCancel && (
onChange={(event) => setInputValue(event.target.value)} <button
placeholder={viewModel.inputPrompt ?? 'Send input to the installer'} type="button"
className="min-w-0 flex-1 rounded-md border px-3 py-2 text-sm" onClick={() => void cancel()}
style={{ className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5"
borderColor: 'var(--color-border)', style={{ borderColor: 'var(--color-border)' }}
backgroundColor: 'rgba(0, 0, 0, 0.12)', >
color: 'var(--color-text)', <XCircle className="size-4" />
}} Cancel
autoComplete="current-password" </button>
/> )}
<button {viewModel.primaryGuideUrl && (
type="submit" <button
className="inline-flex items-center justify-center rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-60" type="button"
style={{ borderColor: 'var(--color-border)' }} onClick={() => void openExternal(viewModel.primaryGuideUrl)}
> className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5"
Send input style={{ borderColor: 'var(--color-border)' }}
</button> >
</form> <ExternalLink className="size-4" />
{viewModel.inputSecret && ( Manual guide
<div className="text-[11px]" style={{ color: 'var(--color-text-muted)' }}> </button>
Password input is sent directly to the installer terminal and is not added to the log )}
output. {viewModel.manualHintsCollapsible && (
<button
type="button"
onClick={() => setManualHintsExpanded((current) => !current)}
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)' }}
>
{manualHintsExpanded ? (
<ChevronUp className="size-4" />
) : (
<ChevronDown className="size-4" />
)}
{manualHintsExpanded
? 'Hide setup steps'
: `Show setup steps (${viewModel.manualHints.length})`}
</button>
)}
{viewModel.showRefreshButton && (
<button
type="button"
onClick={() => void refresh()}
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)' }}
>
<RefreshCw className="size-4" />
Re-check
</button>
)}
</div>
{viewModel.progressPercent !== null && (
<div>
<div className="mb-1 flex items-center justify-between text-[11px]">
<span style={{ color: 'var(--color-text-muted)' }}>Installer progress</span>
<span style={{ color: 'var(--color-text-secondary)' }}>
{viewModel.progressPercent}%
</span>
</div>
<div
className="h-2 overflow-hidden rounded-full"
style={{ backgroundColor: 'rgba(255, 255, 255, 0.08)' }}
>
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${viewModel.progressPercent}%`,
backgroundColor: viewModel.error ? '#ef4444' : '#f59e0b',
}}
/>
</div>
</div> </div>
)} )}
</div>
)}
{manualHintsVisible && ( {viewModel.acceptsInput && (
<div className="mt-3 grid gap-2 lg:grid-cols-2"> <div className="space-y-2">
{viewModel.manualHints.map((hint) => ( <form
<div className="flex flex-col gap-2 sm:flex-row sm:items-center"
key={`${hint.title}-${hint.command ?? hint.url ?? hint.description}`} onSubmit={(event) => {
className="rounded-md border px-3 py-2" event.preventDefault();
style={{ void (async () => {
borderColor: 'rgba(245, 158, 11, 0.18)', const submitted = await submitInput(inputValue);
backgroundColor: 'rgba(255, 255, 255, 0.02)', if (submitted) {
}} setInputValue('');
> }
<div className="text-xs font-semibold" style={{ color: 'var(--color-text)' }}> })();
{hint.title} }}
</div> >
<div className="mt-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}> <input
{hint.description} type={viewModel.inputSecret ? 'password' : 'text'}
</div> value={inputValue}
{hint.command && ( onChange={(event) => setInputValue(event.target.value)}
<code className="mt-2 block rounded bg-black/20 px-2 py-1 font-mono text-[11px]"> placeholder={viewModel.inputPrompt ?? 'Send input to the installer'}
{hint.command} className="min-w-0 flex-1 rounded-md border px-3 py-2 text-sm"
</code> style={{
)} borderColor: 'var(--color-border)',
{hint.url && ( backgroundColor: 'rgba(0, 0, 0, 0.12)',
<div className="mt-2"> color: 'var(--color-text)',
<SourceLink label={hint.title} url={hint.url} onOpen={openExternal} /> }}
autoComplete="current-password"
/>
<button
type="submit"
className="inline-flex items-center justify-center rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-60"
style={{ borderColor: 'var(--color-border)' }}
>
Send input
</button>
</form>
{viewModel.inputSecret && (
<div className="text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
Password input is sent directly to the installer terminal and is not added to
the log output.
</div> </div>
)} )}
</div> </div>
))} )}
</div>
)}
{(viewModel.logs.length > 0 || viewModel.error) && ( {manualHintsVisible && (
<div className="mt-3"> <div className="grid gap-2 lg:grid-cols-2">
<button {viewModel.manualHints.map((hint) => (
type="button" <div
onClick={toggleDetails} key={`${hint.title}-${hint.command ?? hint.url ?? hint.description}`}
className="text-xs underline-offset-4 hover:underline" className="rounded-md border px-3 py-2"
style={{ color: 'var(--color-text-secondary)' }} style={{
> borderColor: 'rgba(245, 158, 11, 0.18)',
{viewModel.detailsOpen ? 'Hide details' : 'Show details'} backgroundColor: 'rgba(255, 255, 255, 0.02)',
</button> }}
{viewModel.detailsOpen && ( >
<pre <div className="text-xs font-semibold" style={{ color: 'var(--color-text)' }}>
className="mt-2 max-h-64 overflow-auto rounded-md border p-3 text-xs" {hint.title}
style={{ </div>
borderColor: 'var(--color-border)', <div className="mt-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
backgroundColor: 'rgba(0, 0, 0, 0.18)', {hint.description}
color: 'var(--color-text-secondary)', </div>
}} {hint.command && (
> <code className="mt-2 block rounded bg-black/20 px-2 py-1 font-mono text-[11px]">
{[viewModel.error, ...viewModel.logs].filter(Boolean).join('\n')} {hint.command}
</pre> </code>
)}
{hint.url && (
<div className="mt-2">
<SourceLink label={hint.title} url={hint.url} onOpen={openExternal} />
</div>
)}
</div>
))}
</div>
)}
{(viewModel.logs.length > 0 || viewModel.error) && (
<div>
<button
type="button"
onClick={toggleDetails}
className="text-xs underline-offset-4 hover:underline"
style={{ color: 'var(--color-text-secondary)' }}
>
{viewModel.detailsOpen ? 'Hide details' : 'Show details'}
</button>
{viewModel.detailsOpen && (
<pre
className="mt-2 max-h-64 overflow-auto rounded-md border p-3 text-xs"
style={{
borderColor: 'var(--color-border)',
backgroundColor: 'rgba(0, 0, 0, 0.18)',
color: 'var(--color-text-secondary)',
}}
>
{[viewModel.error, ...viewModel.logs].filter(Boolean).join('\n')}
</pre>
)}
</div>
)} )}
</div> </div>
)} )}

View file

@ -93,16 +93,32 @@ describe('TmuxInstallerBannerView', () => {
it('keeps Windows setup steps collapsed by default and expands them on demand', async () => { it('keeps Windows setup steps collapsed by default and expands them on demand', async () => {
const { host, root } = renderBanner(baseViewModel); const { host, root } = renderBanner(baseViewModel);
expect(host.textContent).toContain('Show setup steps (2)'); expect(host.textContent).toContain('tmux is not installed');
expect(host.textContent).not.toContain('WSL is available, but no Linux distribution is installed yet.');
expect(host.textContent).not.toContain('Show setup steps (2)');
expect(host.textContent).not.toContain('wsl --install --no-distribution'); expect(host.textContent).not.toContain('wsl --install --no-distribution');
const toggleButton = [...host.querySelectorAll('button')].find((button) => const summaryButton = [...host.querySelectorAll('button')].find((button) =>
button.textContent?.includes('Show setup steps') button.textContent?.includes('tmux is not installed')
); );
expect(toggleButton).toBeDefined(); expect(summaryButton).toBeDefined();
await act(async () => { await act(async () => {
toggleButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); summaryButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(host.textContent).toContain('WSL is available, but no Linux distribution is installed yet.');
expect(host.textContent).toContain('Show setup steps (2)');
expect(host.textContent).not.toContain('Hide setup steps');
const setupToggle = [...host.querySelectorAll('button')].find((button) =>
button.textContent?.includes('Show setup steps')
);
expect(setupToggle).toBeDefined();
await act(async () => {
setupToggle?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve(); await Promise.resolve();
}); });
@ -125,6 +141,15 @@ describe('TmuxInstallerBannerView', () => {
], ],
}); });
const summaryButton = [...host.querySelectorAll('button')].find((button) =>
button.textContent?.includes('tmux is not installed')
);
expect(summaryButton).toBeDefined();
act(() => {
summaryButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(host.textContent).toContain('brew install tmux'); expect(host.textContent).toContain('brew install tmux');
expect(host.textContent).not.toContain('Show setup steps'); expect(host.textContent).not.toContain('Show setup steps');
@ -132,4 +157,36 @@ describe('TmuxInstallerBannerView', () => {
root.unmount(); root.unmount();
}); });
}); });
it('auto-expands when installer flow becomes active', async () => {
const { host, root } = renderBanner(baseViewModel);
mockUseTmuxInstallerBanner.mockReturnValue({
viewModel: {
...baseViewModel,
title: 'tmux needs a restart',
body: 'Restart Windows before continuing.',
phase: 'needs_restart',
progressPercent: 96,
},
install: vi.fn(),
cancel: vi.fn(),
submitInput: vi.fn(),
refresh: vi.fn(),
toggleDetails: vi.fn(),
openExternal: vi.fn(),
});
await act(async () => {
root.render(React.createElement(TmuxInstallerBannerView));
await Promise.resolve();
});
expect(host.textContent).toContain('Restart Windows before continuing.');
expect(host.textContent).toContain('96%');
act(() => {
root.unmount();
});
});
}); });