Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions apps/web/src/components/settings/ConnectionsSettings.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { AdvertisedEndpoint, DesktopWslState } from "@t3tools/contracts";
import { describe, expect, it, vi } from "vite-plus/test";
import {
applyWslEnableSelection,
describeAddEnvironmentProgress,
displayPairingHost,
isQrShareableEndpoint,
isWslSettingsRowVisible,
selectQrEndpointOption,
Expand Down Expand Up @@ -183,3 +185,58 @@ describe("selectQrEndpointOption", () => {
expect(selectQrEndpointOption([], "anything", "anything")).toBeNull();
});
});

describe("describeAddEnvironmentProgress", () => {
const describeAt = (mode: "remote" | "ssh", elapsedMs: number) =>
describeAddEnvironmentProgress({ mode, host: "devbox", elapsedMs });

it("formats elapsed time as m:ss, flooring partial seconds", () => {
expect(describeAt("ssh", 0).elapsedLabel).toBe("0:00");
expect(describeAt("ssh", 4_000).elapsedLabel).toBe("0:04");
expect(describeAt("ssh", 92_000).elapsedLabel).toBe("1:32");
expect(describeAt("ssh", 725_000).elapsedLabel).toBe("12:05");
expect(describeAt("ssh", 4_999).elapsedLabel).toBe("0:04");
});

it("swaps the remote detail for the slow hint at the threshold", () => {
expect(describeAt("remote", 7_999).detail).toBe(
"Verifying the pairing code and saving the environment.",
);
expect(describeAt("remote", 8_000).detail).toBe(
"Still waiting for the host. Check that it is reachable from this device.",
);
});

it("swaps the SSH detail for the slow hint at the threshold", () => {
expect(describeAt("ssh", 7_999).detail).toBe(
"Starting the T3 Code server on the remote machine.",
);
expect(describeAt("ssh", 8_000).detail).toBe(
"Still working. First-time setup installs T3 Code on the remote machine and can take a few minutes.",
);
});

it("names the host being contacted in each mode", () => {
expect(
describeAddEnvironmentProgress({ mode: "remote", host: "backend.example.com", elapsedMs: 0 })
.title,
).toBe("Contacting backend.example.com…");
expect(
describeAddEnvironmentProgress({ mode: "ssh", host: "devbox", elapsedMs: 0 }).title,
).toBe("Connecting to devbox over SSH…");
});
});

describe("displayPairingHost", () => {
it("keeps a bare host or host:port as typed", () => {
expect(displayPairingHost("backend.example.com")).toBe("backend.example.com");
expect(displayPairingHost(" 10.13.37.3:3773 ")).toBe("10.13.37.3:3773");
});

it("drops the scheme, path, and pairing token from a URL", () => {
expect(displayPairingHost("https://backend.example.com/pair#token=ABC")).toBe(
"backend.example.com",
);
expect(displayPairingHost("http://10.13.37.3:3773")).toBe("10.13.37.3:3773");
});
});
46 changes: 46 additions & 0 deletions apps/web/src/components/settings/ConnectionsSettings.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,49 @@ export async function applyWslEnableSelection(input: {
}
return await bridge.setWslBackendEnabled(true);
}

/** Sits under the 10s remote request timeout so the hint lands before a dead host errors out. */
export const ADD_ENVIRONMENT_SLOW_HINT_MS = 8_000;

export function describeAddEnvironmentProgress(input: {
readonly mode: "remote" | "ssh";
readonly host: string;
readonly elapsedMs: number;
}): { readonly title: string; readonly detail: string; readonly elapsedLabel: string } {
const { mode, host, elapsedMs } = input;
const totalSeconds = Math.floor(elapsedMs / 1000);
const seconds = totalSeconds % 60;
const elapsedLabel = `${Math.floor(totalSeconds / 60)}:${String(seconds).padStart(2, "0")}`;
const isSlow = elapsedMs >= ADD_ENVIRONMENT_SLOW_HINT_MS;

if (mode === "ssh") {
return {
title: `Connecting to ${host} over SSH…`,
detail: isSlow
? "Still working. First-time setup installs T3 Code on the remote machine and can take a few minutes."
: "Starting the T3 Code server on the remote machine.",
elapsedLabel,
};
}
return {
title: `Contacting ${host}…`,
detail: isSlow
? "Still waiting for the host. Check that it is reachable from this device."
: "Verifying the pairing code and saving the environment.",
elapsedLabel,
};
}

/** The host a user typed, reduced to what identifies the server: no scheme, path, or pairing token. */
export function displayPairingHost(input: string): string {
const raw = input.trim();
for (const candidate of [raw, `http://${raw}`]) {
try {
const host = new URL(candidate).host;
if (host) return host;
} catch {
// fall through to the next candidate
}
}
return raw;
}
100 changes: 72 additions & 28 deletions apps/web/src/components/settings/ConnectionsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestam
import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";
import {
applyWslEnableSelection,
describeAddEnvironmentProgress,
displayPairingHost,
isQrShareableEndpoint,
isWslSettingsRowVisible,
selectQrEndpointOption,
Expand Down Expand Up @@ -540,6 +542,40 @@ function endpointShareHint(endpoint: AdvertisedEndpoint, url: string): string {
}
}

type AddEnvironmentProgress = {
readonly mode: "remote" | "ssh";
readonly host: string;
readonly startedAtMs: number;
};

const AddEnvironmentProgressPanel = memo(function AddEnvironmentProgressPanel({
progress,
}: {
progress: AddEnvironmentProgress;
}) {
const nowMs = useRelativeTimeTick(1_000);
const { title, detail, elapsedLabel } = describeAddEnvironmentProgress({
mode: progress.mode,
host: progress.host,
elapsedMs: Math.max(0, nowMs - progress.startedAtMs),
});
return (
<div className="flex items-start gap-3 rounded-lg border border-border/60 bg-muted/30 px-4 py-3">
<Spinner className="mt-0.5 size-4 shrink-0 text-primary" aria-hidden />
<div role="status" className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">{title}</span>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">{detail}</p>
</div>
<span
aria-hidden
className="shrink-0 font-mono text-xs tabular-nums leading-5 text-muted-foreground"
>
{elapsedLabel}
</span>
</div>
);
});

type PairingLinkListRowProps = {
pairingLink: ServerPairingLinkRecord;
credential: string | undefined;
Expand Down Expand Up @@ -1841,7 +1877,9 @@ export function ConnectionsSettings() {
// Tracks the arrow-key/hover highlight so Enter selects it instead of submitting the typed text.
const highlightedSshHostRef = useRef<DesktopDiscoveredSshHost | undefined>(undefined);
const [savedBackendError, setSavedBackendError] = useState<string | null>(null);
const [isAddingSavedBackend, setIsAddingSavedBackend] = useState(false);
const [addEnvironmentProgress, setAddEnvironmentProgress] =
useState<AddEnvironmentProgress | null>(null);
const isAddingSavedBackend = addEnvironmentProgress !== null;
const [removingSavedEnvironmentId, setRemovingSavedEnvironmentId] =
useState<EnvironmentId | null>(null);
const [isUpdatingDesktopServerExposure, setIsUpdatingDesktopServerExposure] = useState(false);
Expand Down Expand Up @@ -2169,14 +2207,19 @@ export function ConnectionsSettings() {
// Shared by manual SSH submission and discovered-host selection.
const connectSavedBackendSshTarget = useCallback(
async (target: DesktopSshEnvironmentTarget) => {
setIsAddingSavedBackend(true);
// A discovered-host pick already started the clock before resolving the alias.
setAddEnvironmentProgress((current) => ({
mode: "ssh",
host: target.alias,
startedAtMs: current?.startedAtMs ?? Date.now(),
}));
setSavedBackendError(null);
const result = await connectSshEnvironment({ target, label: "" });
if (result._tag === "Failure") {
if (!isAtomCommandInterrupted(result)) {
setSavedBackendError(formatDesktopSshConnectionError(squashAtomCommandFailure(result)));
}
setIsAddingSavedBackend(false);
setAddEnvironmentProgress(null);
return;
}

Expand All @@ -2191,7 +2234,7 @@ export function ConnectionsSettings() {
title: "Environment connected",
description: `${target.alias} is ready over an SSH-managed tunnel.`,
});
setIsAddingSavedBackend(false);
setAddEnvironmentProgress(null);
},
[connectSshEnvironment],
);
Expand All @@ -2214,7 +2257,6 @@ export function ConnectionsSettings() {
return;
}

setIsAddingSavedBackend(true);
setSavedBackendError(null);
let remotePairingInput: ReturnType<typeof parseRemotePairingFields>;
try {
Expand All @@ -2232,10 +2274,14 @@ export function ConnectionsSettings() {
description: message,
}),
);
setIsAddingSavedBackend(false);
return;
}

setAddEnvironmentProgress({
mode: "remote",
host: displayPairingHost(remotePairingInput.host),
startedAtMs: Date.now(),
});
const result = await connectPairing(remotePairingInput);
if (result._tag === "Failure") {
if (!isAtomCommandInterrupted(result)) {
Expand All @@ -2250,7 +2296,7 @@ export function ConnectionsSettings() {
}),
);
}
setIsAddingSavedBackend(false);
setAddEnvironmentProgress(null);
return;
}

Expand All @@ -2265,7 +2311,7 @@ export function ConnectionsSettings() {
title: "Backend added",
description: "The environment is saved and will reconnect on app startup.",
});
setIsAddingSavedBackend(false);
setAddEnvironmentProgress(null);
}, [
connectPairing,
connectSavedBackendSshTarget,
Expand Down Expand Up @@ -2293,15 +2339,15 @@ export function ConnectionsSettings() {
async (target: DesktopDiscoveredSshHost) => {
if (isAddingSavedBackend || !desktopBridge) return;

setIsAddingSavedBackend(true);
setAddEnvironmentProgress({ mode: "ssh", host: target.alias, startedAtMs: Date.now() });
setSavedBackendError(null);
setSavedBackendSshHost(target.alias);
let resolved: DesktopSshEnvironmentTarget;
try {
resolved = await desktopBridge.resolveSshHost(target.alias);
} catch (error) {
setSavedBackendError(formatDesktopSshConnectionError(error));
setIsAddingSavedBackend(false);
setAddEnvironmentProgress(null);
return;
}
setSavedBackendSshUsername(resolved.username ?? "");
Expand Down Expand Up @@ -2536,15 +2582,14 @@ export function ConnectionsSettings() {
<div className="space-y-4">
{renderRemoteFields()}
{savedBackendError ? <p className="text-xs text-destructive">{savedBackendError}</p> : null}
<Button
variant="outline"
className="w-full"
disabled={isAddingSavedBackend}
onClick={() => void handleAddSavedBackend()}
>
<PlusIcon className="size-3.5" />
{isAddingSavedBackend ? "Adding…" : "Add environment"}
</Button>
{addEnvironmentProgress ? (
<AddEnvironmentProgressPanel progress={addEnvironmentProgress} />
) : (
<Button variant="outline" className="w-full" onClick={() => void handleAddSavedBackend()}>
<PlusIcon className="size-3.5" />
Add environment
</Button>
)}
</div>
);
const renderSshFields = () => (
Expand Down Expand Up @@ -2657,15 +2702,14 @@ export function ConnectionsSettings() {
{savedBackendError ?? discoveredSshHostsError}
</div>
) : null}
<Button
variant="outline"
className="w-full"
disabled={isAddingSavedBackend}
onClick={() => void handleAddSavedBackend()}
>
<PlusIcon className="size-3.5" />
{isAddingSavedBackend ? "Adding…" : "Add environment"}
</Button>
{addEnvironmentProgress ? (
<AddEnvironmentProgressPanel progress={addEnvironmentProgress} />
) : (
<Button variant="outline" className="w-full" onClick={() => void handleAddSavedBackend()}>
<PlusIcon className="size-3.5" />
Add environment
</Button>
)}
</div>
</div>
);
Expand Down
Loading