Skip to content
Closed
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
25 changes: 25 additions & 0 deletions apps/desktop/src/window/DesktopApplicationMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,4 +180,29 @@ describe("DesktopApplicationMenu", () => {
assert.equal(yield* Deferred.await(selectedAction), "zoom-in");
}),
);

it.effect("lets the renderer own Edit clipboard accelerators", () =>
Effect.gen(function* () {
const selectedAction = yield* Deferred.make<string>();
const applicationMenuTemplate =
yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();

yield* configureMenu(selectedAction, applicationMenuTemplate);

const template = yield* Deferred.await(applicationMenuTemplate);
const editMenu = template.find((item) => item.label === "Edit");
assert.isDefined(editMenu);
if (!Array.isArray(editMenu.submenu)) {
throw new Error("Expected Edit menu submenu to be an array.");
}

for (const role of ["cut", "copy", "paste", "selectAll"] as const) {
const item: Electron.MenuItemConstructorOptions | undefined = editMenu.submenu.find(
(entry) => entry.role === role,
);
assert.isDefined(item);
assert.equal(item.registerAccelerator, false);
}
}),
);
});
35 changes: 34 additions & 1 deletion apps/desktop/src/window/DesktopApplicationMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,39 @@ const checkForUpdatesFromMenu = Effect.gen(function* () {
}
}).pipe(Effect.withSpan("desktop.menu.checkForUpdates"));

function desktopEditMenu(platform: NodeJS.Platform): Electron.MenuItemConstructorOptions {
// Native Edit accelerators copy the focused DOM selection. The terminal is a
// canvas plus an empty IME textarea, so those chords must reach the renderer:
// Ctrl+C is SIGINT (or copy of the Ghostty selection), Ctrl+A is
// beginning-of-line, and Cmd/Ctrl+C must not write an empty clipboard. Menu
// clicks still run the roles.
const submenu: Electron.MenuItemConstructorOptions[] = [
{ role: "undo", registerAccelerator: false },
{ role: "redo", registerAccelerator: false },
{ type: "separator" },
{ role: "cut", registerAccelerator: false },
{ role: "copy", registerAccelerator: false },
{ role: "paste", registerAccelerator: false },
];
if (platform === "darwin") {
submenu.push({ role: "pasteAndMatchStyle", registerAccelerator: false });
}
submenu.push(
{ role: "delete", registerAccelerator: false },
{ role: "selectAll", registerAccelerator: false },
);
if (platform === "darwin") {
submenu.push(
{ type: "separator" },
{
label: "Speech",
submenu: [{ role: "startSpeaking" }, { role: "stopSpeaking" }],
},
);
}
return { label: "Edit", submenu };
}

const handleCheckForUpdatesMenuClick = Effect.gen(function* () {
const updates = yield* DesktopUpdates.DesktopUpdates;
const electronDialog = yield* ElectronDialog.ElectronDialog;
Expand Down Expand Up @@ -183,7 +216,7 @@ export const make = Effect.gen(function* () {
{ role: environment.platform === "darwin" ? "close" : "quit" },
],
},
{ role: "editMenu" },
desktopEditMenu(environment.platform),
{
label: "View",
submenu: [
Expand Down
42 changes: 22 additions & 20 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ import {
import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
import { Button } from "~/components/ui/button";
import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";
import { cn } from "~/lib/utils";
import { openUrlInHostBrowser } from "~/lib/openUrlInHostBrowser";
import { type TerminalContextSelection } from "~/lib/terminalContext";
import { cn } from "~/lib/utils";
import {
GhosttyTerminalSurface,
type GhosttyTerminalSurfaceOptions,
Expand Down Expand Up @@ -66,8 +67,11 @@ import { useAttachedTerminalSession } from "../state/terminalSessions";
import { serverEnvironment } from "../state/server";
import { previewEnvironment } from "../state/preview";
import { terminalEnvironment } from "../state/terminal";
import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";
import { useAtomCommand } from "../state/use-atom-command";
import {
canOpenTerminalLinkInPreview,
openTerminalLinkInPreview,
} from "./preview/openTerminalLinkInPreview";
import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut";
import {
resolveTerminalFontPreference,
Expand Down Expand Up @@ -750,26 +754,24 @@ export function TerminalViewport({
const latestTerminal = terminalRef.current;
if (!latestTerminal) return;
if (/^https?:\/\//u.test(text)) {
if (!localApi) {
writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser.");
return;
}
const fallbackToBrowser = () => {
void localApi.shell.openExternal(text).catch((error: unknown) => {
writeSystemMessage(
latestTerminal,
error instanceof Error ? error.message : "Unable to open link",
);
});
if (!openUrlInHostBrowser(text)) {
writeSystemMessage(latestTerminal, "Unable to open link");
}
};
void openTerminalLinkInPreview({
url: text,
position: { x: event.clientX, y: event.clientY },
threadRef,
openPreview,
localApi,
fallbackToBrowser,
});
// Loopback URLs can open the in-app preview without a user gesture.
// Public URLs must click a _blank link in this same tick, or the
// current browser never sees them.
if (canOpenTerminalLinkInPreview(text, threadRef)) {
void openTerminalLinkInPreview({
url: text,
threadRef,
openPreview,
fallbackToBrowser,
});
return;
}
fallbackToBrowser();
return;
}
const target = resolvePathLinkTarget(text, cwd);
Expand Down
53 changes: 15 additions & 38 deletions apps/web/src/components/preview/openTerminalLinkInPreview.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import type { LocalApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";
import type { PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/unstable/reactivity";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";

import {
canOpenTerminalLinkInPreview,
openTerminalLinkInPreview,
TerminalLinkContextMenuShowError,
TerminalLinkPreviewOpenError,
} from "./openTerminalLinkInPreview";

const previewSupported = vi.hoisted(() => ({ value: true }));

vi.mock("~/previewStateStore", () => ({
applyPreviewServerSnapshot: vi.fn(),
isPreviewSupportedInRuntime: () => true,
isPreviewSupportedInRuntime: () => previewSupported.value,
}));

vi.mock("~/rightPanelStore", () => ({
Expand All @@ -35,44 +37,31 @@ const snapshot: PreviewSessionSnapshot = {
};

afterEach(() => {
previewSupported.value = true;
vi.restoreAllMocks();
});

describe("canOpenTerminalLinkInPreview", () => {
it("keeps loopback URLs in preview and sends public URLs to the host browser", () => {
expect(canOpenTerminalLinkInPreview("http://localhost:3000/app", threadRef)).toBe(true);
expect(canOpenTerminalLinkInPreview("https://example.com/docs", threadRef)).toBe(false);
});
});

describe("openTerminalLinkInPreview", () => {
it("preserves context-menu failures with terminal link context before falling back", async () => {
const cause = new Error("menu unavailable");
it("falls back without opening preview when the URL is not previewable", async () => {
const fallbackToBrowser = vi.fn();
const openPreview = vi.fn(async () => AsyncResult.success(snapshot));
const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined);

await openTerminalLinkInPreview({
url: "http://localhost:3000/path?token=secret",
position: { x: 12, y: 34 },
url: "https://example.com/docs",
threadRef,
openPreview,
localApi: {
contextMenu: {
show: vi.fn(async () => {
throw cause;
}),
},
} as unknown as LocalApi,
fallbackToBrowser,
});

expect(fallbackToBrowser).toHaveBeenCalledOnce();
expect(openPreview).not.toHaveBeenCalled();
expect(reportError).toHaveBeenCalledOnce();
const error = reportError.mock.calls[0]?.[0];
expect(error).toBeInstanceOf(TerminalLinkContextMenuShowError);
expect(error).toMatchObject({
environmentId: "local",
threadId: "thread-1",
targetOrigin: "http://localhost:3000",
cause,
});
expect(error.message).not.toContain("menu unavailable");
expect(error.targetOrigin).not.toContain("secret");
});

it("preserves the complete preview failure cause before falling back", async () => {
Expand All @@ -83,14 +72,8 @@ describe("openTerminalLinkInPreview", () => {

await openTerminalLinkInPreview({
url: "http://127.0.0.1:5173/",
position: { x: 12, y: 34 },
threadRef,
openPreview: async () => AsyncResult.failure(cause),
localApi: {
contextMenu: {
show: vi.fn(async () => "open-in-preview"),
},
} as unknown as LocalApi,
fallbackToBrowser,
});

Expand All @@ -113,14 +96,8 @@ describe("openTerminalLinkInPreview", () => {

await openTerminalLinkInPreview({
url: "http://localhost:5173/",
position: { x: 12, y: 34 },
threadRef,
openPreview: async () => AsyncResult.failure(Cause.interrupt()),
localApi: {
contextMenu: {
show: vi.fn(async () => "open-in-preview"),
},
} as unknown as LocalApi,
fallbackToBrowser,
});

Expand Down
80 changes: 22 additions & 58 deletions apps/web/src/components/preview/openTerminalLinkInPreview.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { LocalApi, ScopedThreadRef } from "@t3tools/contracts";
import type { ScopedThreadRef } from "@t3tools/contracts";
import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
import { isPreviewableUrl } from "@t3tools/shared/preview";
import * as Schema from "effect/Schema";
Expand All @@ -15,15 +15,6 @@ const terminalLinkErrorContext = {
cause: Schema.Defect(),
};

export class TerminalLinkContextMenuShowError extends Schema.TaggedErrorClass<TerminalLinkContextMenuShowError>()(
"TerminalLinkContextMenuShowError",
terminalLinkErrorContext,
) {
override get message(): string {
return `Failed to show the context menu for terminal link ${this.targetOrigin}.`;
}
}

export class TerminalLinkPreviewOpenError extends Schema.TaggedErrorClass<TerminalLinkPreviewOpenError>()(
"TerminalLinkPreviewOpenError",
terminalLinkErrorContext,
Expand All @@ -33,24 +24,24 @@ export class TerminalLinkPreviewOpenError extends Schema.TaggedErrorClass<Termin
}
}

export function canOpenTerminalLinkInPreview(
url: string,
threadRef: Pick<ScopedThreadRef, "threadId">,
): boolean {
return isPreviewableUrl(url) && isPreviewSupportedInRuntime() && threadRef.threadId.length > 0;
}

interface OpenTerminalLinkInPreviewInput<E> {
readonly url: string;
readonly position: { x: number; y: number };
readonly threadRef: ScopedThreadRef;
readonly openPreview: OpenPreviewMutation<E>;
readonly localApi: LocalApi;
readonly fallbackToBrowser: () => void;
}

export async function openTerminalLinkInPreview<E>(
input: OpenTerminalLinkInPreviewInput<E>,
): Promise<void> {
const supportsPreview =
isPreviewableUrl(input.url) &&
isPreviewSupportedInRuntime() &&
input.threadRef.threadId.length > 0;

if (!supportsPreview) {
if (!canOpenTerminalLinkInPreview(input.url, input.threadRef)) {
input.fallbackToBrowser();
return;
}
Expand All @@ -61,51 +52,24 @@ export async function openTerminalLinkInPreview<E>(
targetOrigin: new URL(input.url).origin,
};

let choice: "open-in-preview" | "open-in-browser" | null;
try {
choice = await input.localApi.contextMenu.show(
[
{ id: "open-in-preview", label: "Open in preview" },
{ id: "open-in-browser", label: "Open in browser" },
],
input.position,
);
} catch (cause) {
const result = await input.openPreview({
environmentId: input.threadRef.environmentId,
input: { threadId: input.threadRef.threadId, url: input.url },
});
if (result._tag === "Failure") {
if (isAtomCommandInterrupted(result)) {
return;
}
console.error(
new TerminalLinkContextMenuShowError({
new TerminalLinkPreviewOpenError({
...errorContext,
cause,
cause: result.cause,
}),
);
input.fallbackToBrowser();
return;
}

if (choice === "open-in-preview") {
const result = await input.openPreview({
environmentId: input.threadRef.environmentId,
input: { threadId: input.threadRef.threadId, url: input.url },
});
if (result._tag === "Failure") {
if (isAtomCommandInterrupted(result)) {
return;
}
console.error(
new TerminalLinkPreviewOpenError({
...errorContext,
cause: result.cause,
}),
);
input.fallbackToBrowser();
return;
}
recordVisitForThread(input.threadRef, input.url);
applyPreviewServerSnapshot(input.threadRef, result.value);
useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId);
return;
}

if (choice === "open-in-browser") {
input.fallbackToBrowser();
}
recordVisitForThread(input.threadRef, input.url);
applyPreviewServerSnapshot(input.threadRef, result.value);
useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId);
}
Loading
Loading