Skip to content
Merged
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
19 changes: 19 additions & 0 deletions apps/web/src/components/chat/OpenInPicker.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,25 @@ describe("remote editor links", () => {
expect(state.openUrl).toHaveBeenCalledWith(expect.stringContaining("ssh-remote+sol"));
expect(state.run).not.toHaveBeenCalled();
});

it("explains why a phone or tablet browser has no editor to open", async () => {
await using fixture = await setup();
const { renderPicker, byLabel, primaryButton, menuItems, click, pressOpenFavorite } = fixture;
state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "sol" } };
state.clientEditors = [];
await renderPicker();

expect(primaryButton().disabled).toBe(true);
expect((await pressOpenFavorite()).defaultPrevented).toBe(false);
await click(byLabel("Choose editor"));
await vi.waitFor(() =>
expect(menuItems().map((item) => item.textContent)).toEqual([
"Open from a computer to edit on Test host",
]),
);
expect(menuItems()[0]!.getAttribute("aria-disabled")).toBe("true");
expect(state.openUrl).not.toHaveBeenCalled();
});
});

describe("open in editor announcement", () => {
Expand Down
11 changes: 9 additions & 2 deletions apps/web/src/components/chat/OpenInPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -286,11 +286,18 @@ export const OpenInPicker = memo(function OpenInPicker({
const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null;
const primaryLabel = isPanel ? `Open in ${primaryOption?.label ?? "editor"}` : "Open";

const unavailableReason =
remote.mode === "remote-unavailable"
? `No SSH route to ${environmentLabel}`
: remote.mode === "remote-links" && options.length === 0
? `Open from a computer to edit on ${environmentLabel}`
: null;

const editorItems = (
<>
{remote.mode === "remote-unavailable" ? (
{unavailableReason !== null ? (
<MenuItem density={presentation === "menu" ? "touch" : "default"} disabled>
No SSH route to {environmentLabel}
{unavailableReason}
</MenuItem>
) : (
<>
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/connection/clientMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
DesktopBridge,
} from "@t3tools/contracts";

interface BrowserIdentity {
export interface BrowserIdentity {
readonly userAgent: string;
readonly platform: string;
readonly maxTouchPoints: number;
Expand All @@ -32,7 +32,7 @@ function isIpadosDesktopUserAgent(identity: BrowserIdentity): boolean {
);
}

function browserClientOs(identity: BrowserIdentity): ClientOs {
export function browserClientOs(identity: BrowserIdentity): ClientOs {
const userAgent = identity.userAgent;
if (userAgent.trim() === "") return "unknown";
if (/iphone|ipad|ipod/i.test(userAgent) || isIpadosDesktopUserAgent(identity)) return "iOS";
Expand Down
42 changes: 41 additions & 1 deletion apps/web/src/remoteOpen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
import { buildRemoteOpenUrl, EnvironmentId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import { resolveRemoteOpenState } from "./remoteOpen";
import { remoteFallbackEditors, resolveRemoteOpenState } from "./remoteOpen";

const environmentId = EnvironmentId.make("environment-1");

Expand Down Expand Up @@ -179,6 +179,46 @@ describe("resolveRemoteOpenState", () => {
});
});

describe("remoteFallbackEditors", () => {
it.each([
{
// iPadOS Safari requests the desktop site with a Macintosh user agent by default.
device: "iPad with a desktop user agent",
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
platform: "MacIntel",
maxTouchPoints: 5,
},
{
device: "iPhone",
userAgent:
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
platform: "iPhone",
maxTouchPoints: 5,
},
{
device: "Android phone",
userAgent:
"Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36",
platform: "Linux armv81",
maxTouchPoints: 5,
},
])("offers no desktop editor for remote links on an $device", ({ device: _, ...identity }) => {
expect(remoteFallbackEditors(identity)).toEqual([]);
});

it("keeps the VS Code fallback for remote links on a Mac", () => {
expect(
remoteFallbackEditors({
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
platform: "MacIntel",
maxTouchPoints: 0,
}),
).toEqual(["vscode"]);
});
});

describe("buildRemoteOpenUrl", () => {
it("roots Windows paths", () => {
expect(
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/remoteOpen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as Schema from "effect/Schema";
import { Atom } from "effect/reactivity";
import { useAtomValue } from "@effect/atom-react";

import { browserClientOs, type BrowserIdentity } from "~/connection/clientMetadata";
import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";
import { isLoopbackHostname } from "~/environments/primary/target";
import { useLocalStorage } from "~/hooks/useLocalStorage";
Expand Down Expand Up @@ -149,6 +150,12 @@ export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteO

const REMOTE_FALLBACK_EDITORS: ReadonlyArray<EditorId> = ["vscode"];

// Phone and tablet browsers have no desktop editor to receive a vscode:// remote link.
export function remoteFallbackEditors(identity: BrowserIdentity): ReadonlyArray<EditorId> {
const os = browserClientOs(identity);
return os === "iOS" || os === "Android" ? [] : REMOTE_FALLBACK_EDITORS;
}

// Probed once per page: installed editors rarely change, and the probe spawns processes.
const remoteCapableEditorsAtom = Atom.make((get): ReadonlyArray<EditorId> => {
const probe = window.desktopBridge?.probeRemoteEditors;
Expand All @@ -159,7 +166,7 @@ const remoteCapableEditorsAtom = Atom.make((get): ReadonlyArray<EditorId> => {
},
() => undefined,
);
return REMOTE_FALLBACK_EDITORS;
return remoteFallbackEditors(navigator);
}).pipe(Atom.keepAlive, Atom.withLabel("remote-capable-editors"));

export function useRemoteCapableEditors(): ReadonlyArray<EditorId> {
Expand Down
Loading