fix(tmux): tighten windows installer flow
This commit is contained in:
parent
65da1b8429
commit
1062fe3b65
11 changed files with 401 additions and 63 deletions
|
|
@ -15,6 +15,8 @@ import type { TmuxInstallPlan } from '@features/tmux-installer/main/infrastructu
|
|||
const MAX_LOG_LINES = 400;
|
||||
const RETRY_WITH_UPDATE_PATTERNS = ['unable to locate package', 'failed to fetch'];
|
||||
const RECOMMENDED_WSL_DISTRO_NAME = 'Ubuntu';
|
||||
const WINDOWS_DISTRO_APPEAR_RETRY_DELAY_MS = 2_000;
|
||||
const WINDOWS_DISTRO_APPEAR_RETRY_ATTEMPTS = 6;
|
||||
|
||||
class TmuxInstallCancelledError extends Error {
|
||||
constructor() {
|
||||
|
|
@ -33,6 +35,7 @@ export class TmuxInstallerRunnerAdapter
|
|||
readonly #wslService: TmuxWslService;
|
||||
readonly #windowsElevatedStepRunner: WindowsElevatedStepRunner;
|
||||
readonly #presenter: TmuxInstallerProgressPresenter;
|
||||
readonly #sleep: (ms: number) => Promise<void>;
|
||||
#cancelRequested = false;
|
||||
#snapshot: TmuxInstallerSnapshot = {
|
||||
phase: 'idle',
|
||||
|
|
@ -55,7 +58,8 @@ export class TmuxInstallerRunnerAdapter
|
|||
commandRunner = new TmuxCommandRunner(),
|
||||
terminalSession = new TmuxInstallTerminalSession(),
|
||||
wslService = new TmuxWslService(),
|
||||
windowsElevatedStepRunner = new WindowsElevatedStepRunner()
|
||||
windowsElevatedStepRunner = new WindowsElevatedStepRunner(),
|
||||
sleep: (ms: number) => Promise<void> = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
) {
|
||||
this.#statusSource = statusSource;
|
||||
this.#presenter = presenter;
|
||||
|
|
@ -64,6 +68,7 @@ export class TmuxInstallerRunnerAdapter
|
|||
this.#terminalSession = terminalSession;
|
||||
this.#wslService = wslService;
|
||||
this.#windowsElevatedStepRunner = windowsElevatedStepRunner;
|
||||
this.#sleep = sleep;
|
||||
}
|
||||
|
||||
getSnapshot(): TmuxInstallerSnapshot {
|
||||
|
|
@ -412,15 +417,15 @@ export class TmuxInstallerRunnerAdapter
|
|||
inputPrompt: null,
|
||||
inputSecret: false,
|
||||
});
|
||||
const status = await this.#refreshStatus();
|
||||
if (!status.wsl?.distroName) {
|
||||
const status = await this.#waitForWindowsDistroStatus();
|
||||
if (status.wsl?.rebootRequired) {
|
||||
this.#setSnapshot({
|
||||
phase: 'needs_manual_step',
|
||||
phase: 'needs_restart',
|
||||
strategy: 'wsl',
|
||||
message: 'WSL distro install still needs a manual step',
|
||||
message: 'Restart Windows before continuing with tmux setup',
|
||||
detail:
|
||||
status.wsl?.statusDetail ??
|
||||
'The app could not confirm that a WSL distro is ready yet. Finish the distro install manually, then re-check.',
|
||||
status.wsl.statusDetail ??
|
||||
'Windows still needs a restart before the installed WSL distro can be finalized.',
|
||||
error: null,
|
||||
canCancel: false,
|
||||
acceptsInput: false,
|
||||
|
|
@ -429,6 +434,33 @@ export class TmuxInstallerRunnerAdapter
|
|||
});
|
||||
return status;
|
||||
}
|
||||
if (!status.wsl?.distroName) {
|
||||
this.#setSnapshot({
|
||||
phase: 'waiting_for_external_step',
|
||||
strategy: 'wsl',
|
||||
message: 'Finish Ubuntu setup in WSL',
|
||||
detail:
|
||||
'Ubuntu installation was started, but Windows has not exposed the distro to the app yet. Wait a moment, then click Re-check. If Ubuntu appears in the Start menu, open it once and complete the first Linux user setup.',
|
||||
error: null,
|
||||
canCancel: false,
|
||||
acceptsInput: false,
|
||||
inputPrompt: null,
|
||||
inputSecret: false,
|
||||
});
|
||||
return status;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
async #waitForWindowsDistroStatus(): Promise<TmuxStatus> {
|
||||
let status = await this.#refreshStatus();
|
||||
for (let attempt = 0; attempt < WINDOWS_DISTRO_APPEAR_RETRY_ATTEMPTS; attempt += 1) {
|
||||
if (status.wsl?.distroName || status.wsl?.rebootRequired) {
|
||||
return status;
|
||||
}
|
||||
await this.#sleep(WINDOWS_DISTRO_APPEAR_RETRY_DELAY_MS);
|
||||
status = await this.#refreshStatus();
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -366,4 +366,105 @@ describe('TmuxInstallerRunnerAdapter', () => {
|
|||
expect(runner.getSnapshot().phase).toBe('waiting_for_external_step');
|
||||
expect(runner.getSnapshot().message).toContain('Ubuntu');
|
||||
});
|
||||
|
||||
it('keeps Windows distro install in an external-step state when Ubuntu is still being provisioned', async () => {
|
||||
const presenter = createPresenter();
|
||||
const initialStatus = createBaseStatus({
|
||||
platform: 'win32',
|
||||
nativeSupported: false,
|
||||
autoInstall: {
|
||||
supported: true,
|
||||
strategy: 'wsl',
|
||||
packageManagerLabel: 'WSL',
|
||||
requiresTerminalInput: false,
|
||||
requiresAdmin: false,
|
||||
requiresRestart: false,
|
||||
mayOpenExternalWindow: true,
|
||||
reasonIfUnsupported: null,
|
||||
manualHints: [],
|
||||
},
|
||||
wsl: {
|
||||
wslInstalled: true,
|
||||
rebootRequired: false,
|
||||
distroName: null,
|
||||
distroVersion: null,
|
||||
distroBootstrapped: false,
|
||||
innerPackageManager: null,
|
||||
tmuxAvailableInsideWsl: false,
|
||||
tmuxVersion: null,
|
||||
tmuxBinaryPath: null,
|
||||
statusDetail: 'No distro is configured yet.',
|
||||
},
|
||||
wslPreference: null,
|
||||
});
|
||||
const pendingStatus = createBaseStatus({
|
||||
platform: 'win32',
|
||||
nativeSupported: false,
|
||||
autoInstall: initialStatus.autoInstall,
|
||||
effective: {
|
||||
available: false,
|
||||
location: null,
|
||||
version: null,
|
||||
binaryPath: null,
|
||||
runtimeReady: false,
|
||||
detail: 'WSL is available, but no Linux distribution is installed yet.',
|
||||
},
|
||||
wsl: {
|
||||
wslInstalled: true,
|
||||
rebootRequired: false,
|
||||
distroName: null,
|
||||
distroVersion: null,
|
||||
distroBootstrapped: false,
|
||||
innerPackageManager: null,
|
||||
tmuxAvailableInsideWsl: false,
|
||||
tmuxVersion: null,
|
||||
tmuxBinaryPath: null,
|
||||
statusDetail: 'WSL is available, but no Linux distribution is installed yet.',
|
||||
},
|
||||
wslPreference: {
|
||||
preferredDistroName: 'Ubuntu',
|
||||
source: 'persisted',
|
||||
},
|
||||
});
|
||||
let statusCallCount = 0;
|
||||
const statusSource = {
|
||||
getStatus: vi.fn(async () => {
|
||||
statusCallCount += 1;
|
||||
return statusCallCount === 1 ? initialStatus : pendingStatus;
|
||||
}),
|
||||
invalidateStatus: vi.fn(),
|
||||
};
|
||||
const runner = new TmuxInstallerRunnerAdapter(
|
||||
statusSource as never,
|
||||
presenter as never,
|
||||
{
|
||||
resolve: vi.fn(async () => {
|
||||
throw new Error('resolve() should not run while distro is still provisioning');
|
||||
}),
|
||||
} as never,
|
||||
{
|
||||
run: vi.fn(async () => ({ exitCode: 0 })),
|
||||
cancel: vi.fn(),
|
||||
} as never,
|
||||
{
|
||||
run: vi.fn(),
|
||||
writeLine: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
} as never,
|
||||
{
|
||||
persistPreferredDistro: vi.fn(async () => undefined),
|
||||
} as never,
|
||||
{
|
||||
runWslCoreInstall: vi.fn(),
|
||||
} as never,
|
||||
async () => undefined
|
||||
);
|
||||
|
||||
await expect(runner.install()).resolves.toBeUndefined();
|
||||
|
||||
expect(statusCallCount).toBeGreaterThan(2);
|
||||
expect(runner.getSnapshot().phase).toBe('waiting_for_external_step');
|
||||
expect(runner.getSnapshot().message).toContain('Finish Ubuntu setup');
|
||||
expect(runner.getSnapshot().detail).toContain('Wait a moment, then click Re-check');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { spawn } from 'node:child_process';
|
|||
|
||||
import { killProcessTree } from '@main/utils/childProcess';
|
||||
|
||||
import { decodeInstallerProcessOutput } from '../runtime/decodeInstallerProcessOutput';
|
||||
|
||||
import type { ChildProcessByStdio } from 'node:child_process';
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
|
|
@ -45,7 +47,8 @@ export class TmuxCommandRunner {
|
|||
return {
|
||||
push: (chunk: string): void => {
|
||||
pending += chunk;
|
||||
const lines = pending.split(/\r?\n/);
|
||||
const normalizedPending = pending.replace(/\r(?!\n)/g, '\n');
|
||||
const lines = normalizedPending.split('\n');
|
||||
pending = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
emitLine(line);
|
||||
|
|
@ -64,8 +67,12 @@ export class TmuxCommandRunner {
|
|||
const stdoutWriter = createBufferedLineWriter();
|
||||
const stderrWriter = createBufferedLineWriter();
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer | string) => stdoutWriter.push(String(chunk)));
|
||||
child.stderr.on('data', (chunk: Buffer | string) => stderrWriter.push(String(chunk)));
|
||||
child.stdout.on('data', (chunk: Buffer | string) =>
|
||||
stdoutWriter.push(decodeInstallerProcessOutput(chunk))
|
||||
);
|
||||
child.stderr.on('data', (chunk: Buffer | string) =>
|
||||
stderrWriter.push(decodeInstallerProcessOutput(chunk))
|
||||
);
|
||||
child.on('error', (error) => {
|
||||
this.#activeChild = null;
|
||||
reject(error);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { decodeInstallerProcessOutput } from '../decodeInstallerProcessOutput';
|
||||
|
||||
describe('decodeInstallerProcessOutput', () => {
|
||||
it('decodes cp866 Windows console output with Cyrillic text', () => {
|
||||
const buffer = Buffer.from([0x8f, 0xe0, 0xa8, 0xa2, 0xa5, 0xe2, 0x20, 0x8c, 0xa8, 0xe0]);
|
||||
|
||||
expect(decodeInstallerProcessOutput(buffer, 'win32')).toBe('Привет Мир');
|
||||
});
|
||||
|
||||
it('keeps utf8 output readable on non-Windows platforms', () => {
|
||||
const buffer = Buffer.from('tmux is available\n', 'utf8');
|
||||
|
||||
expect(decodeInstallerProcessOutput(buffer, 'darwin')).toBe('tmux is available\n');
|
||||
});
|
||||
|
||||
it('decodes utf16le output when it contains a BOM', () => {
|
||||
const utf16le = Buffer.from('\uFEFFWSL core installation command completed.', 'utf16le');
|
||||
|
||||
expect(decodeInstallerProcessOutput(utf16le, 'win32')).toContain('WSL core installation');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
const UTF8_DECODER = new TextDecoder('utf-8');
|
||||
const UTF16LE_DECODER = new TextDecoder('utf-16le');
|
||||
const IBM866_DECODER = new TextDecoder('ibm866');
|
||||
const WINDOWS_1251_DECODER = new TextDecoder('windows-1251');
|
||||
|
||||
export function decodeInstallerProcessOutput(
|
||||
output: string | Buffer,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): string {
|
||||
if (typeof output === 'string') {
|
||||
return stripNulls(output);
|
||||
}
|
||||
if (output.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (hasUtf16LeBom(output) || looksLikeUtf16Le(output)) {
|
||||
return stripNulls(UTF16LE_DECODER.decode(output));
|
||||
}
|
||||
|
||||
const utf8 = stripNulls(UTF8_DECODER.decode(output));
|
||||
if (platform !== 'win32') {
|
||||
return utf8;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
utf8,
|
||||
stripNulls(IBM866_DECODER.decode(output)),
|
||||
stripNulls(WINDOWS_1251_DECODER.decode(output)),
|
||||
];
|
||||
|
||||
return candidates.slice(1).reduce(
|
||||
(best, candidate) => (scoreDecodedText(candidate) > scoreDecodedText(best) ? candidate : best),
|
||||
candidates[0] ?? utf8
|
||||
);
|
||||
}
|
||||
|
||||
function stripNulls(value: string): string {
|
||||
return value.replace(/\0/g, '');
|
||||
}
|
||||
|
||||
function hasUtf16LeBom(buffer: Buffer): boolean {
|
||||
return buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe;
|
||||
}
|
||||
|
||||
function looksLikeUtf16Le(buffer: Buffer): boolean {
|
||||
const sampleSize = Math.min(buffer.length, 512);
|
||||
if (sampleSize < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let pairs = 0;
|
||||
let nullsAtOddIndex = 0;
|
||||
for (let i = 0; i + 1 < sampleSize; i += 2) {
|
||||
pairs += 1;
|
||||
if (buffer[i + 1] === 0) {
|
||||
nullsAtOddIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return pairs > 0 && nullsAtOddIndex / pairs >= 0.3;
|
||||
}
|
||||
|
||||
function scoreDecodedText(value: string): number {
|
||||
let score = 0;
|
||||
|
||||
for (const char of value) {
|
||||
const codePoint = char.codePointAt(0) ?? 0;
|
||||
if (char === '\uFFFD') {
|
||||
score -= 25;
|
||||
continue;
|
||||
}
|
||||
if (char === '\n' || char === '\r' || char === '\t') {
|
||||
score += 0.5;
|
||||
continue;
|
||||
}
|
||||
if (codePoint >= 0x20 && codePoint <= 0x7e) {
|
||||
score += 1;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
(codePoint >= 0x0400 && codePoint <= 0x04ff) ||
|
||||
(codePoint >= 0x0500 && codePoint <= 0x052f)
|
||||
) {
|
||||
score += 4;
|
||||
continue;
|
||||
}
|
||||
if (codePoint >= 0x2500 && codePoint <= 0x257f) {
|
||||
score += 0.4;
|
||||
continue;
|
||||
}
|
||||
if (codePoint < 0x20) {
|
||||
score -= 5;
|
||||
continue;
|
||||
}
|
||||
score += 0.1;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { execFile } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
import { decodeInstallerProcessOutput } from '../runtime/decodeInstallerProcessOutput';
|
||||
|
||||
import { TmuxWslPreferenceStore } from './TmuxWslPreferenceStore';
|
||||
|
||||
import type {
|
||||
|
|
@ -373,37 +375,7 @@ export class TmuxWslService {
|
|||
}
|
||||
|
||||
#decodeOutput(output: string | Buffer): string {
|
||||
if (typeof output === 'string') {
|
||||
return output.replace(/\0/g, '');
|
||||
}
|
||||
if (output.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const hasUtf16LeBom = output.length >= 2 && output[0] === 0xff && output[1] === 0xfe;
|
||||
const decoded =
|
||||
hasUtf16LeBom || this.#looksLikeUtf16Le(output)
|
||||
? output.toString('utf16le')
|
||||
: output.toString('utf8');
|
||||
return decoded.replace(/\0/g, '');
|
||||
}
|
||||
|
||||
#looksLikeUtf16Le(buffer: Buffer): boolean {
|
||||
const sampleSize = Math.min(buffer.length, 512);
|
||||
if (sampleSize < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let pairs = 0;
|
||||
let nullsAtOddIndex = 0;
|
||||
for (let i = 0; i + 1 < sampleSize; i += 2) {
|
||||
pairs += 1;
|
||||
if (buffer[i + 1] === 0) {
|
||||
nullsAtOddIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return pairs > 0 && nullsAtOddIndex / pairs >= 0.3;
|
||||
return decodeInstallerProcessOutput(output);
|
||||
}
|
||||
|
||||
#parseWslDistros(stdout: string): string[] {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
formatTmuxInstallerProgress,
|
||||
formatTmuxInstallerTitle,
|
||||
formatTmuxLocationLabel,
|
||||
formatTmuxOptionalBenefits,
|
||||
formatTmuxPlatformLabel,
|
||||
} from '@features/tmux-installer/renderer/utils/formatTmuxInstallerText';
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ export interface TmuxInstallerBannerViewModel {
|
|||
loading: boolean;
|
||||
title: string;
|
||||
body: string;
|
||||
benefitsBody: string | null;
|
||||
error: string | null;
|
||||
platformLabel: string | null;
|
||||
locationLabel: string | null;
|
||||
|
|
@ -31,6 +33,8 @@ export interface TmuxInstallerBannerViewModel {
|
|||
installSupported: boolean;
|
||||
installDisabled: boolean;
|
||||
installLabel: string;
|
||||
installButtonPrimary: boolean;
|
||||
showRefreshButton: boolean;
|
||||
canCancel: boolean;
|
||||
acceptsInput: boolean;
|
||||
inputPrompt: string | null;
|
||||
|
|
@ -60,6 +64,12 @@ export class TmuxInstallerBannerAdapter {
|
|||
const title =
|
||||
snapshot.phase === 'idle' && status?.effective.available && !status.effective.runtimeReady
|
||||
? 'tmux needs one more step'
|
||||
: snapshot.message &&
|
||||
(snapshot.phase === 'pending_external_elevation' ||
|
||||
snapshot.phase === 'waiting_for_external_step' ||
|
||||
snapshot.phase === 'needs_restart' ||
|
||||
snapshot.phase === 'needs_manual_step')
|
||||
? snapshot.message
|
||||
: formatTmuxInstallerTitle(snapshot.phase);
|
||||
const primaryGuideUrl =
|
||||
status?.autoInstall.manualHints.find((hint) => typeof hint.url === 'string')?.url ?? null;
|
||||
|
|
@ -71,6 +81,8 @@ export class TmuxInstallerBannerAdapter {
|
|||
status?.effective.detail ??
|
||||
status?.wsl?.statusDetail ??
|
||||
'tmux improves persistent teammate reliability and cleaner recovery for long-running tasks.';
|
||||
const benefitsBody =
|
||||
status && !status.effective.runtimeReady ? formatTmuxOptionalBenefits(status.platform) : null;
|
||||
const runtimeReadyLabel = status
|
||||
? status.effective.runtimeReady
|
||||
? 'Ready for persistent teammates'
|
||||
|
|
@ -93,12 +105,27 @@ export class TmuxInstallerBannerAdapter {
|
|||
? 'Install Ubuntu in WSL'
|
||||
: 'Install tmux in WSL'
|
||||
: formatInstallButtonLabel(snapshot.phase);
|
||||
const installDisabled =
|
||||
input.loading ||
|
||||
snapshot.phase === 'preparing' ||
|
||||
snapshot.phase === 'checking' ||
|
||||
snapshot.phase === 'requesting_privileges' ||
|
||||
snapshot.phase === 'pending_external_elevation' ||
|
||||
snapshot.phase === 'waiting_for_external_step' ||
|
||||
snapshot.phase === 'installing' ||
|
||||
snapshot.phase === 'verifying';
|
||||
const installButtonPrimary =
|
||||
!installDisabled && (installLabel.startsWith('Install') || installLabel.startsWith('Retry'));
|
||||
const showRefreshButton =
|
||||
!(status?.autoInstall.supported ?? false) ||
|
||||
(installLabel !== 'Re-check' && installLabel !== 'Re-check after restart');
|
||||
|
||||
return {
|
||||
visible,
|
||||
loading: input.loading,
|
||||
title,
|
||||
body,
|
||||
benefitsBody,
|
||||
error: input.error ?? snapshot.error ?? status?.error ?? null,
|
||||
platformLabel: formatTmuxPlatformLabel(status?.platform ?? null),
|
||||
locationLabel: formatTmuxLocationLabel(status?.effective.location ?? null),
|
||||
|
|
@ -111,16 +138,10 @@ export class TmuxInstallerBannerAdapter {
|
|||
manualHintsCollapsible,
|
||||
primaryGuideUrl,
|
||||
installSupported: status?.autoInstall.supported ?? false,
|
||||
installDisabled:
|
||||
input.loading ||
|
||||
snapshot.phase === 'preparing' ||
|
||||
snapshot.phase === 'checking' ||
|
||||
snapshot.phase === 'requesting_privileges' ||
|
||||
snapshot.phase === 'pending_external_elevation' ||
|
||||
snapshot.phase === 'waiting_for_external_step' ||
|
||||
snapshot.phase === 'installing' ||
|
||||
snapshot.phase === 'verifying',
|
||||
installDisabled,
|
||||
installLabel,
|
||||
installButtonPrimary,
|
||||
showRefreshButton,
|
||||
canCancel: snapshot.canCancel,
|
||||
acceptsInput: snapshot.acceptsInput,
|
||||
inputPrompt: snapshot.inputPrompt,
|
||||
|
|
|
|||
|
|
@ -73,6 +73,9 @@ describe('TmuxInstallerBannerAdapter', () => {
|
|||
expect(result.manualHints).toHaveLength(1);
|
||||
expect(result.manualHintsCollapsible).toBe(false);
|
||||
expect(result.body).toContain('persistent teammate reliability');
|
||||
expect(result.benefitsBody).toContain('Optional, but recommended');
|
||||
expect(result.installButtonPrimary).toBe(true);
|
||||
expect(result.showRefreshButton).toBe(true);
|
||||
});
|
||||
|
||||
it('prioritizes renderer errors and disables the install button while installing', () => {
|
||||
|
|
@ -98,12 +101,14 @@ describe('TmuxInstallerBannerAdapter', () => {
|
|||
|
||||
expect(result.title).toBe('Installing tmux');
|
||||
expect(result.body).toBe('Renderer bridge failed');
|
||||
expect(result.benefitsBody).toContain('Optional, but recommended');
|
||||
expect(result.error).toBe('Renderer bridge failed');
|
||||
expect(result.installDisabled).toBe(true);
|
||||
expect(result.canCancel).toBe(true);
|
||||
expect(result.acceptsInput).toBe(false);
|
||||
expect(result.progressPercent).toBe(68);
|
||||
expect(result.logs).toEqual(['Downloading bottle...']);
|
||||
expect(result.installButtonPrimary).toBe(false);
|
||||
});
|
||||
|
||||
it('exposes a manual guide url when auto install is unavailable', () => {
|
||||
|
|
@ -143,8 +148,10 @@ describe('TmuxInstallerBannerAdapter', () => {
|
|||
|
||||
expect(result.platformLabel).toBe('Windows');
|
||||
expect(result.primaryGuideUrl).toBe('https://learn.microsoft.com/en-us/windows/wsl/install');
|
||||
expect(result.progressPercent).toBe(100);
|
||||
expect(result.progressPercent).toBe(82);
|
||||
expect(result.manualHintsCollapsible).toBe(true);
|
||||
expect(result.benefitsBody).toContain('With tmux in WSL');
|
||||
expect(result.showRefreshButton).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the banner visible when tmux is installed but runtime is not ready yet', () => {
|
||||
|
|
@ -174,6 +181,7 @@ describe('TmuxInstallerBannerAdapter', () => {
|
|||
expect(result.locationLabel).toBe('Host runtime');
|
||||
expect(result.runtimeReadyLabel).toBe('Installed, but not active yet');
|
||||
expect(result.versionLabel).toBe('tmux 3.4');
|
||||
expect(result.benefitsBody).toContain('With tmux in WSL');
|
||||
});
|
||||
|
||||
it('exposes installer input metadata for interactive privilege flows', () => {
|
||||
|
|
@ -259,5 +267,39 @@ describe('TmuxInstallerBannerAdapter', () => {
|
|||
|
||||
expect(installWslResult.installLabel).toBe('Install WSL');
|
||||
expect(installUbuntuResult.installLabel).toBe('Install Ubuntu in WSL');
|
||||
expect(installWslResult.installButtonPrimary).toBe(true);
|
||||
expect(installUbuntuResult.installButtonPrimary).toBe(true);
|
||||
});
|
||||
|
||||
it('uses a specific Windows external-step message as the title', () => {
|
||||
const adapter = TmuxInstallerBannerAdapter.create();
|
||||
|
||||
const result = adapter.adapt({
|
||||
status: {
|
||||
...baseStatus,
|
||||
platform: 'win32',
|
||||
autoInstall: {
|
||||
...baseStatus.autoInstall,
|
||||
supported: true,
|
||||
strategy: 'wsl',
|
||||
},
|
||||
},
|
||||
snapshot: {
|
||||
...idleSnapshot,
|
||||
phase: 'waiting_for_external_step',
|
||||
strategy: 'wsl',
|
||||
message: 'Finish Ubuntu setup in WSL',
|
||||
detail:
|
||||
'Ubuntu installation was started, but Windows has not exposed the distro to the app yet.',
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
detailsOpen: false,
|
||||
});
|
||||
|
||||
expect(result.title).toBe('Finish Ubuntu setup in WSL');
|
||||
expect(result.progressPercent).toBe(48);
|
||||
expect(result.installDisabled).toBe(true);
|
||||
expect(result.showRefreshButton).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -84,6 +84,18 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
|
|||
>
|
||||
{viewModel.body}
|
||||
</p>
|
||||
{viewModel.benefitsBody && (
|
||||
<div
|
||||
className="mt-3 max-w-4xl rounded-md border px-3 py-2 text-[13px] leading-6"
|
||||
style={{
|
||||
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 ||
|
||||
|
|
@ -155,8 +167,18 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
|
|||
type="button"
|
||||
onClick={() => void install()}
|
||||
disabled={viewModel.installDisabled}
|
||||
className="inline-flex items-center gap-2 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)' }}
|
||||
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 ${
|
||||
viewModel.installButtonPrimary ? 'hover:bg-emerald-500/20' : 'hover:bg-white/5'
|
||||
}`}
|
||||
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}
|
||||
|
|
@ -201,15 +223,17 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
|
|||
: `Show setup steps (${viewModel.manualHints.length})`}
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
{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>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ const baseViewModel: TmuxInstallerBannerViewModel = {
|
|||
loading: false,
|
||||
title: 'tmux is not installed',
|
||||
body: 'WSL is available, but no Linux distribution is installed yet.',
|
||||
benefitsBody:
|
||||
'Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable.',
|
||||
error: null,
|
||||
platformLabel: 'Windows',
|
||||
locationLabel: null,
|
||||
|
|
@ -45,6 +47,8 @@ const baseViewModel: TmuxInstallerBannerViewModel = {
|
|||
installSupported: true,
|
||||
installDisabled: false,
|
||||
installLabel: 'Install Ubuntu in WSL',
|
||||
installButtonPrimary: true,
|
||||
showRefreshButton: true,
|
||||
canCancel: false,
|
||||
acceptsInput: false,
|
||||
inputPrompt: null,
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export function formatTmuxInstallerProgress(phase: TmuxInstallerPhase): number |
|
|||
if (phase === 'verifying') return 90;
|
||||
if (phase === 'needs_restart') return 96;
|
||||
if (phase === 'completed') return 100;
|
||||
if (phase === 'needs_manual_step') return 100;
|
||||
if (phase === 'needs_manual_step') return 82;
|
||||
if (phase === 'error') return 100;
|
||||
if (phase === 'cancelled') return 0;
|
||||
return null;
|
||||
|
|
@ -61,3 +61,15 @@ export function formatTmuxLocationLabel(location: 'host' | 'wsl' | null): string
|
|||
if (location === 'wsl') return 'WSL runtime';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatTmuxOptionalBenefits(platform: TmuxPlatform | null): string | null {
|
||||
if (!platform) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
return 'Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better.';
|
||||
}
|
||||
|
||||
return 'Optional, but recommended. The app works without tmux. With tmux, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better.';
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue