From 80221884ed3ab3796106d1419438d4e603061149 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 21:52:06 +0300 Subject: [PATCH] fix(tmux): collapse installer banner by default --- .../renderer/ui/TmuxInstallerBannerView.tsx | 600 ++++++++++-------- .../TmuxInstallerBannerView.test.tsx | 67 +- 2 files changed, 383 insertions(+), 284 deletions(-) diff --git a/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx index d4cb725a..8e8e63b4 100644 --- a/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx +++ b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx @@ -12,6 +12,8 @@ import { import { useTmuxInstallerBanner } from '../hooks/useTmuxInstallerBanner'; +const SUMMARY_TITLE = 'tmux is not installed'; + const SourceLink = ({ label, url, @@ -35,8 +37,10 @@ const SourceLink = ({ export function TmuxInstallerBannerView(): React.JSX.Element | null { const { viewModel, install, cancel, submitInput, refresh, toggleDetails, openExternal } = useTmuxInstallerBanner(); + const [expanded, setExpanded] = React.useState(false); const [inputValue, setInputValue] = React.useState(''); const [manualHintsExpanded, setManualHintsExpanded] = React.useState(false); + const previousPhaseRef = React.useRef(viewModel.phase); React.useEffect(() => { if (!viewModel.acceptsInput) { @@ -50,6 +54,21 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null { } }, [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) { return null; } @@ -66,297 +85,320 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null { borderColor: 'rgba(245, 158, 11, 0.2)', }} > -
-
-
- - {viewModel.error ? ( - - ) : ( - - )} - - {viewModel.title} -
-

- {viewModel.body} -

- {viewModel.benefitsBody && ( -
- {viewModel.benefitsBody} -
- )} - {(viewModel.platformLabel || - viewModel.locationLabel || - viewModel.runtimeReadyLabel || - viewModel.versionLabel || - viewModel.phase !== 'idle') && ( -
- {viewModel.platformLabel && ( - - Detected OS: {viewModel.platformLabel} - - )} - {viewModel.locationLabel && ( - - Runtime path: {viewModel.locationLabel} - - )} - {viewModel.runtimeReadyLabel && ( - - {viewModel.runtimeReadyLabel} - - )} - {viewModel.versionLabel && ( - - {viewModel.versionLabel} - - )} - {viewModel.phase !== 'idle' && ( - - Phase: {viewModel.phase} - - )} -
- )} -
+ -
- {viewModel.installSupported && ( - - )} - {viewModel.canCancel && ( - - )} - {viewModel.primaryGuideUrl && ( - - )} - {viewModel.manualHintsCollapsible && ( - - )} - {viewModel.showRefreshButton && ( - - )} -
-
- - {viewModel.progressPercent !== null && ( -
-
- Installer progress - - {viewModel.progressPercent}% - + {viewModel.body} +

+ {viewModel.benefitsBody && ( +
+ {viewModel.benefitsBody} +
+ )} + {(viewModel.platformLabel || + viewModel.locationLabel || + viewModel.runtimeReadyLabel || + viewModel.versionLabel || + viewModel.phase !== 'idle') && ( +
+ {viewModel.platformLabel && ( + + Detected OS: {viewModel.platformLabel} + + )} + {viewModel.locationLabel && ( + + Runtime path: {viewModel.locationLabel} + + )} + {viewModel.runtimeReadyLabel && ( + + {viewModel.runtimeReadyLabel} + + )} + {viewModel.versionLabel && ( + + {viewModel.versionLabel} + + )} + {viewModel.phase !== 'idle' && ( + + Phase: {viewModel.phase} + + )} +
+ )}
-
-
-
-
- )} - {viewModel.acceptsInput && ( -
-
{ - event.preventDefault(); - void (async () => { - const submitted = await submitInput(inputValue); - if (submitted) { - setInputValue(''); +
+ {viewModel.installSupported && ( + - - {viewModel.inputSecret && ( -
- Password input is sent directly to the installer terminal and is not added to the log - output. + > + + {viewModel.installLabel} + + )} + {viewModel.canCancel && ( + + )} + {viewModel.primaryGuideUrl && ( + + )} + {viewModel.manualHintsCollapsible && ( + + )} + {viewModel.showRefreshButton && ( + + )} +
+ + {viewModel.progressPercent !== null && ( +
+
+ Installer progress + + {viewModel.progressPercent}% + +
+
+
+
)} -
- )} - {manualHintsVisible && ( -
- {viewModel.manualHints.map((hint) => ( -
-
- {hint.title} -
-
- {hint.description} -
- {hint.command && ( - - {hint.command} - - )} - {hint.url && ( -
- + {viewModel.acceptsInput && ( +
+
{ + event.preventDefault(); + void (async () => { + const submitted = await submitInput(inputValue); + if (submitted) { + setInputValue(''); + } + })(); + }} + > + setInputValue(event.target.value)} + placeholder={viewModel.inputPrompt ?? 'Send input to the installer'} + className="min-w-0 flex-1 rounded-md border px-3 py-2 text-sm" + style={{ + borderColor: 'var(--color-border)', + backgroundColor: 'rgba(0, 0, 0, 0.12)', + color: 'var(--color-text)', + }} + autoComplete="current-password" + /> + +
+ {viewModel.inputSecret && ( +
+ Password input is sent directly to the installer terminal and is not added to + the log output.
)}
- ))} -
- )} + )} - {(viewModel.logs.length > 0 || viewModel.error) && ( -
- - {viewModel.detailsOpen && ( -
-              {[viewModel.error, ...viewModel.logs].filter(Boolean).join('\n')}
-            
+ {manualHintsVisible && ( +
+ {viewModel.manualHints.map((hint) => ( +
+
+ {hint.title} +
+
+ {hint.description} +
+ {hint.command && ( + + {hint.command} + + )} + {hint.url && ( +
+ +
+ )} +
+ ))} +
+ )} + + {(viewModel.logs.length > 0 || viewModel.error) && ( +
+ + {viewModel.detailsOpen && ( +
+                  {[viewModel.error, ...viewModel.logs].filter(Boolean).join('\n')}
+                
+ )} +
)}
)} diff --git a/src/features/tmux-installer/renderer/ui/__tests__/TmuxInstallerBannerView.test.tsx b/src/features/tmux-installer/renderer/ui/__tests__/TmuxInstallerBannerView.test.tsx index 14e67a3f..109774c3 100644 --- a/src/features/tmux-installer/renderer/ui/__tests__/TmuxInstallerBannerView.test.tsx +++ b/src/features/tmux-installer/renderer/ui/__tests__/TmuxInstallerBannerView.test.tsx @@ -93,16 +93,32 @@ describe('TmuxInstallerBannerView', () => { it('keeps Windows setup steps collapsed by default and expands them on demand', async () => { 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'); - const toggleButton = [...host.querySelectorAll('button')].find((button) => - button.textContent?.includes('Show setup steps') + const summaryButton = [...host.querySelectorAll('button')].find((button) => + button.textContent?.includes('tmux is not installed') ); - expect(toggleButton).toBeDefined(); + expect(summaryButton).toBeDefined(); 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(); }); @@ -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).not.toContain('Show setup steps'); @@ -132,4 +157,36 @@ describe('TmuxInstallerBannerView', () => { 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(); + }); + }); });