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
5 changes: 4 additions & 1 deletion apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,10 @@ function useChatMarkdownState({
sshTarget: null,
remoteOpenTargets: undefined,
isDesktopRenderer: window.desktopBridge !== undefined,
desktopHomeEnvironmentId: readDesktopHomeEnvironmentId(),
desktopHomeEnvironmentId:
target._tag === "BearerConnectionTarget"
? readDesktopHomeEnvironmentId(environmentId)
: null,
}).mode !== "local-exec"
) {
description = `The file was not found on ${target.label} at ${error.checkedPath ?? error.resource.path}.`;
Expand Down
19 changes: 11 additions & 8 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/
import { useEnvironmentQuery } from "../state/query";
import { useEnvironmentScope } from "~/state/session";
import {
primaryServerAvailableEditorsAtom,
serverAvailableEditorsAtom,
primaryServerKeybindingsAtom,
serverEnvironment,
} from "../state/server";
Expand Down Expand Up @@ -1643,14 +1643,17 @@ export default function ChatView(props: ChatViewProps) {
const scriptKeybindings =
useAtomValue(serverEnvironment.configValueAtom(environmentId))?.keybindings ??
DEFAULT_RESOLVED_KEYBINDINGS;
const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom);
const availableEditors = useAtomValue(
serverAvailableEditorsAtom(activeThread?.environmentId ?? environmentId),
);
const remoteOpenState = useRemoteOpenState(activeThread?.environmentId ?? environmentId);
const showOpenInPicker = shouldShowOpenInPicker({
activeProjectName: activeProject?.title,
activeThreadEnvironmentId: activeThread?.environmentId ?? environmentId,
primaryEnvironmentId,
remoteOpenMode: remoteOpenState.mode,
});
const showOpenInPicker =
Boolean(activeProject?.title) &&
shouldShowOpenInPicker({
environmentId: activeThread?.environmentId ?? environmentId,
primaryEnvironmentId,
remoteOpenMode: remoteOpenState.mode,
});
useOpenFavoriteEditorShortcut({
enabled: showOpenInPicker && !backgroundThread,
environmentId: activeThread?.environmentId ?? environmentId,
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/components/chat/OpenInPicker.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { EnvironmentId } from "@t3tools/contracts";
import { expect, it } from "vite-plus/test";

import { shouldShowOpenInPicker } from "./OpenInPicker.logic";

const environmentId = EnvironmentId.make("service");
const primaryEnvironmentId = EnvironmentId.make("desktop");

it.each([
{ primaryEnvironmentId: null, remoteOpenMode: "local-exec", expected: true },
{ primaryEnvironmentId: environmentId, remoteOpenMode: "local-exec", expected: true },
{ primaryEnvironmentId, remoteOpenMode: "local-exec", expected: false },
{ primaryEnvironmentId, remoteOpenMode: "remote-links", expected: true },
] as const)(
"offers editor opening for $remoteOpenMode with primary $primaryEnvironmentId",
(input) => {
expect(
shouldShowOpenInPicker({
...input,
environmentId,
}),
).toBe(input.expected);
},
);
10 changes: 3 additions & 7 deletions apps/web/src/components/chat/OpenInPicker.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,12 @@ import type { EnvironmentId } from "@t3tools/contracts";
import type { RemoteOpenMode } from "../../remoteOpen";

export function shouldShowOpenInPicker(input: {
readonly activeProjectName: string | undefined;
readonly activeThreadEnvironmentId: EnvironmentId;
readonly environmentId: EnvironmentId;
readonly primaryEnvironmentId: EnvironmentId | null;
readonly remoteOpenMode: RemoteOpenMode;
}): boolean {
if (!input.activeProjectName) return false;
if (
input.primaryEnvironmentId !== null &&
input.activeThreadEnvironmentId === input.primaryEnvironmentId
) {
if (input.primaryEnvironmentId === null) return true;
if (input.environmentId === input.primaryEnvironmentId) {
return true;
}
return input.remoteOpenMode !== "local-exec";
Expand Down
27 changes: 24 additions & 3 deletions apps/web/src/components/files/FilePreviewPanel.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { EnvironmentId, ThreadId, type ProjectReadFileResult } from "@t3tools/contracts";
import {
EnvironmentId,
ThreadId,
type EditorId,
type ProjectReadFileResult,
} from "@t3tools/contracts";
import { AsyncResult } from "effect/reactivity";
import { describe, expect, it, vi } from "vite-plus/test";
import { act, fakeTimers, render } from "../../../test/render";
Expand All @@ -19,6 +24,10 @@ vi.mock(import("~/state/session"), async (original) => ({
readEnvironmentScope: () => true,
useEnvironmentScope: () => true,
}));
vi.mock(import("~/state/environments"), async (original) => ({
...(await original()),
usePrimaryEnvironmentId: () => null,
}));
vi.mock(import("~/state/filesystem"), async (original) => ({
...(await original()),
useFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false, error: null }),
Expand Down Expand Up @@ -95,7 +104,7 @@ function setup() {
};
}

function markdownPreview(truncated: boolean) {
function markdownPreview(truncated: boolean, availableEditors: ReadonlyArray<EditorId> = []) {
state.file = {
relativePath: "tasks.md",
contents: "- [ ] Keep the rest of this file\n",
Expand All @@ -111,7 +120,7 @@ function markdownPreview(truncated: boolean) {
threadRef={threadRef}
composerDraftTarget={threadRef}
keybindings={[]}
availableEditors={[]}
availableEditors={availableEditors}
openInShortcutEnabled={false}
revealLine={null}
revealRequestId={0}
Expand All @@ -125,6 +134,18 @@ function markdownPreview(truncated: boolean) {
);
}

it("opens a local service file in the editor without a desktop primary", async () => {
using _settings = setup();
await using screen = await render(markdownPreview(false, ["vscode"]));
const open = screen.getByRole("button", { name: "Open file in preferred editor" });
await expect.element(open).toBeEnabled();
await open.click();
expect(state.writeFile).toHaveBeenCalledExactlyOnceWith({
environmentId,
input: { cwd: "/workspace/tasks.md", editor: "vscode" },
});
});

describe("rendered Markdown task saves", () => {
it("keeps a truncated read read-only without queuing a replacement", async () => {
using _settings = setup();
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { lazy, Suspense, useEffect, useRef, useState } from "react";
import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";
import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls";
import { OpenInPicker } from "~/components/chat/OpenInPicker";
import { shouldShowOpenInPicker } from "~/components/chat/OpenInPicker.logic";
import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer";
import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions";
import { MorphIcon } from "~/components/MorphIcon";
Expand Down Expand Up @@ -1283,7 +1284,11 @@ export default function FilePreviewPanel({
</div>
</ScrollArea>
{absolutePath &&
(environmentId === primaryEnvironmentId || remoteOpenState.mode !== "local-exec") ? (
shouldShowOpenInPicker({
environmentId,
primaryEnvironmentId,
remoteOpenMode: remoteOpenState.mode,
}) ? (
<OpenInPicker
environmentId={environmentId}
keybindings={keybindings}
Expand Down
59 changes: 57 additions & 2 deletions apps/web/src/remoteOpen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@ import {
PrimaryConnectionTarget,
RelayConnectionTarget,
SshConnectionTarget,
type EnvironmentPresentation,
} from "@t3tools/client-runtime/connection";
import { buildRemoteOpenUrl, EnvironmentId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
import * as Option from "effect/Option";
import { Atom } from "effect/reactivity";
import { describe, expect, it, vi } from "vite-plus/test";

import { resolveRemoteOpenState } from "./remoteOpen";
import { readDesktopHomeEnvironmentId, resolveRemoteOpenState } from "./remoteOpen";
import { appAtomRegistry } from "./rpc/atomRegistry";
import { environmentPresentations } from "./state/presentation";

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

Expand Down Expand Up @@ -221,3 +226,53 @@ describe("buildRemoteOpenUrl", () => {
}
});
});

it("recognizes a same-home service after a missing ID without reloading the page", async () => {
const target = new BearerConnectionTarget({
environmentId,
label: "laptop service",
connectionId: "paired-1",
});
const presentation: EnvironmentPresentation = {
entry: { target, profile: Option.none(), enabled: true },
connection: { phase: "connecting", error: null, traceId: null },
serverConfig: null,
};
const presentationAtom = Atom.make<EnvironmentPresentation | null>(presentation);
using _presentation = vi
.spyOn(environmentPresentations, "presentationAtom")
.mockReturnValue(presentationAtom);
const getHomeEnvironmentId = vi.fn<() => Promise<string | null>>().mockResolvedValue(null);
vi.stubGlobal("window", { desktopBridge: { getHomeEnvironmentId } });
try {
expect(readDesktopHomeEnvironmentId(environmentId)).toBeNull();
await vi.waitFor(() => expect(getHomeEnvironmentId).toHaveResolvedWith(null));

getHomeEnvironmentId.mockResolvedValue(environmentId);
appAtomRegistry.set(presentationAtom, {
...presentation,
connection: { phase: "connected", error: null, traceId: null },
});
await vi.waitFor(() => {
expect(readDesktopHomeEnvironmentId(environmentId)).toBe(environmentId);
});

const callsAfterConnecting = getHomeEnvironmentId.mock.calls.length;
appAtomRegistry.set(presentationAtom, { ...presentation, serverConfig: null });
expect(readDesktopHomeEnvironmentId(environmentId)).toBe(environmentId);
expect(getHomeEnvironmentId).toHaveBeenCalledTimes(callsAfterConnecting);

appAtomRegistry.set(presentationAtom, {
...presentation,
entry: {
...presentation.entry,
target: new SshConnectionTarget({ environmentId, label: "remote", connectionId: "ssh-1" }),
},
});
expect(readDesktopHomeEnvironmentId(environmentId)).toBeNull();
expect(getHomeEnvironmentId).toHaveBeenCalledTimes(callsAfterConnecting);
} finally {
appAtomRegistry.dispose();
vi.unstubAllGlobals();
}
});
42 changes: 28 additions & 14 deletions apps/web/src/remoteOpen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,20 +102,32 @@ export function resolveRemoteOpenState(input: {
return REMOTE_UNAVAILABLE;
}

// Read once per page: the id is permanent once a server has written it to the home.
const desktopHomeEnvironmentIdAtom = Atom.make((get): string | null => {
const read = window.desktopBridge?.getHomeEnvironmentId;
read?.().then(
(id) => {
if (id !== null) get.setSelf(id);
},
() => undefined,
);
return null;
}).pipe(Atom.keepAlive, Atom.withLabel("desktop-home-environment-id"));
const desktopHomeEnvironmentIdAtom = Atom.family((environmentId: EnvironmentId) =>
Atom.make((get): string | null => {
const presentation = get(environmentPresentations.presentationAtom(environmentId));
if (presentation?.entry.target._tag !== "BearerConnectionTarget") return null;

const previous = Option.getOrNull(get.self<string | null>());
if (previous !== null) return previous;

// Connection changes retry a missing file; a written home ID is permanent.
let active = true;
get.addFinalizer(() => {
active = false;
});
const read = window.desktopBridge?.getHomeEnvironmentId;
read?.().then(
(id) => {
if (active && id !== null) get.setSelf(id);
},
() => undefined,
);
return null;
}).pipe(Atom.keepAlive, Atom.withLabel(`desktop-home-environment-id:${environmentId}`)),
);

export function readDesktopHomeEnvironmentId(): string | null {
return appAtomRegistry.get(desktopHomeEnvironmentIdAtom);
export function readDesktopHomeEnvironmentId(environmentId: EnvironmentId): string | null {
return appAtomRegistry.get(desktopHomeEnvironmentIdAtom(environmentId));
}

function remoteOpenStatesEqual(previous: RemoteOpenState, next: RemoteOpenState): boolean {
Expand Down Expand Up @@ -145,7 +157,9 @@ const remoteOpenResolutionAtom = Atom.family((environmentId: EnvironmentId) =>
remoteOpenTargets: presentation?.serverConfig?.remoteOpenTargets,
isDesktopRenderer: window.desktopBridge !== undefined,
desktopHomeEnvironmentId:
entry.target._tag === "BearerConnectionTarget" ? get(desktopHomeEnvironmentIdAtom) : null,
entry.target._tag === "BearerConnectionTarget"
? get(desktopHomeEnvironmentIdAtom(environmentId))
: null,
}),
isResolved: true,
};
Expand Down
26 changes: 24 additions & 2 deletions apps/web/src/state/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connect
import { EnvironmentId, type KeybindingRule, type ServerConfig } from "@t3tools/contracts";
import { compileResolvedKeybindingsConfig } from "@t3tools/shared/keybindings";
import { Atom, AtomRegistry } from "effect/reactivity";
import { afterEach, describe, expect, it } from "vite-plus/test";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";

import { resolveShortcutCommand } from "../keybindings";
import { createShortcutKeybindingsAtom } from "./server";
import {
createShortcutKeybindingsAtom,
serverAvailableEditorsAtom,
serverEnvironment,
} from "./server";

const PRIMARY = EnvironmentId.make("primary");
const A = EnvironmentId.make("a");
Expand Down Expand Up @@ -67,6 +71,24 @@ afterEach(() => {
for (const registry of registries.splice(0)) registry.dispose();
});

it("reads and refreshes the active environment's editors without a desktop primary", () => {
const aConfig = Atom.make<ServerConfig | null>(null);
const bConfig = Atom.make<ServerConfig | null>({ ...config([]), availableEditors: ["zed"] });
using _configs = vi
.spyOn(serverEnvironment, "configValueAtom")
.mockImplementation((environmentId) => (environmentId === A ? aConfig : bConfig));
const registry = AtomRegistry.make();
registries.push(registry);

expect(registry.get(serverAvailableEditorsAtom(A))).toEqual([]);
registry.set(aConfig, { ...config([]), availableEditors: ["vscode"] });
expect(registry.get(serverAvailableEditorsAtom(A))).toEqual(["vscode"]);
expect(registry.get(serverAvailableEditorsAtom(B))).toEqual(["zed"]);
registry.set(aConfig, { ...config([]), availableEditors: ["cursor"] });
expect(registry.get(serverAvailableEditorsAtom(A))).toEqual(["cursor"]);
expect(registry.get(serverAvailableEditorsAtom(B))).toEqual(["zed"]);
});

describe("createShortcutKeybindingsAtom", () => {
it("updates shortcuts from the first connected, configured environment without a primary", () => {
const harness = makeHarness({});
Expand Down
11 changes: 7 additions & 4 deletions apps/web/src/state/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,13 @@ export const primaryServerKeybindingsAtom = createShortcutKeybindingsAtom({
environmentsAtom: environmentConnectionsAtom,
}).pipe(Atom.withLabel("web-primary-server-keybindings"));

export const primaryServerAvailableEditorsAtom = Atom.make(
(get): ReadonlyArray<EditorId> =>
get(primaryServerConfigAtom)?.availableEditors ?? EMPTY_AVAILABLE_EDITORS,
).pipe(Atom.withLabel("web-primary-server-available-editors"));
export const serverAvailableEditorsAtom = Atom.family((environmentId: EnvironmentId) =>
Atom.make(
(get): ReadonlyArray<EditorId> =>
get(serverEnvironment.configValueAtom(environmentId))?.availableEditors ??
EMPTY_AVAILABLE_EDITORS,
).pipe(Atom.withLabel(`web-server-available-editors:${environmentId}`)),
);

const EMPTY_ENVIRONMENT_THEMES: ReadonlyArray<EnvironmentTheme> = [];

Expand Down