fix(tmux): collapse installer banner by default
This commit is contained in:
parent
51aac9b7a1
commit
80221884ed
2 changed files with 383 additions and 284 deletions
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue