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
4 changes: 2 additions & 2 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ import {
} from "~/terminal/ghostty/surface";
import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core";
import { useOpenInPreferredEditor } from "../editorPreferences";
import { isTerminalLinkActivation, isTerminalUrl, resolvePathLinkTarget } from "../terminal-links";
import { isTerminalUrl, resolvePathLinkTarget } from "../terminal-links";
import {
isDiffToggleShortcut,
isTerminalClearShortcut,
Expand Down Expand Up @@ -782,7 +782,6 @@ export function TerminalViewport({
}

function handleLinkActivate(text: string, event: MouseEvent): void {
if (!isTerminalLinkActivation(event)) return;
const latestTerminal = terminalRef.current;
if (!latestTerminal) return;
if (isTerminalUrl(text)) {
Expand All @@ -803,6 +802,7 @@ export function TerminalViewport({
threadRef,
openPreview,
fallbackToBrowser,
forceBrowser: event.metaKey || event.ctrlKey,
}).catch((error: unknown) => {
toastManager.add(
stackedThreadToast({
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/components/preview/openTerminalLinkInPreview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ describe("openTerminalLinkInPreview", () => {
threadRef,
openPreview,
fallbackToBrowser,
forceBrowser: false,
}),
).rejects.toBe(failure);
expect(fallbackToBrowser).not.toHaveBeenCalled();
Expand All @@ -105,6 +106,7 @@ describe("openTerminalLinkInPreview", () => {
threadRef,
openPreview,
fallbackToBrowser,
forceBrowser: false,
});

expect(fallbackToBrowser).toHaveBeenCalledOnce();
Expand All @@ -120,6 +122,7 @@ describe("openTerminalLinkInPreview", () => {
threadRef,
openPreview,
fallbackToBrowser,
forceBrowser: false,
});

expect(openPreview).toHaveBeenCalledOnce();
Expand All @@ -141,6 +144,7 @@ describe("openTerminalLinkInPreview", () => {
threadRef,
openPreview,
fallbackToBrowser: vi.fn(),
forceBrowser: false,
});

await vi.waitFor(() => expect(browserDefaultsMocks.resolve).toHaveBeenCalledOnce());
Expand Down Expand Up @@ -170,6 +174,7 @@ describe("openTerminalLinkInPreview", () => {
threadRef,
openPreview: async () => AsyncResult.failure(cause),
fallbackToBrowser,
forceBrowser: false,
});

expect(fallbackToBrowser).toHaveBeenCalledOnce();
Expand All @@ -194,9 +199,26 @@ describe("openTerminalLinkInPreview", () => {
threadRef,
openPreview: async () => AsyncResult.failure(Cause.interrupt()),
fallbackToBrowser,
forceBrowser: false,
});

expect(reportError).not.toHaveBeenCalled();
expect(fallbackToBrowser).not.toHaveBeenCalled();
});

it("opens in the system browser when Ctrl or Command is held", async () => {
const fallbackToBrowser = vi.fn();
const openPreview = vi.fn(async () => AsyncResult.success(snapshot));

await openTerminalLinkInPreview({
url: "https://example.com/docs",
threadRef,
openPreview,
fallbackToBrowser,
forceBrowser: true,
});

expect(fallbackToBrowser).toHaveBeenCalledOnce();
expect(openPreview).not.toHaveBeenCalled();
});
});
10 changes: 5 additions & 5 deletions apps/web/src/components/preview/openTerminalLinkInPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,19 @@ interface OpenTerminalLinkInPreviewInput<E> {
readonly threadRef: ScopedThreadRef;
readonly openPreview: OpenPreviewMutation<E>;
readonly fallbackToBrowser: () => void;
/** Cmd/Ctrl-click bypasses the preference and opens in the system browser. */
readonly forceBrowser: boolean;
}

/**
* Opens a terminal hyperlink where the "Open links in" setting says. Terminal
* links are activated with the platform modifier already held, so unlike chat
* links the modifier cannot double as the system-browser override; the setting
* alone decides, and the system browser is the fallback whenever the in-app
* one cannot take the URL.
* Opens a terminal hyperlink where the "Open links in" setting says, unless a
* Cmd/Ctrl-click explicitly requests the system browser.
*/
export async function openTerminalLinkInPreview<E>(
input: OpenTerminalLinkInPreviewInput<E>,
): Promise<void> {
const supportsPreview =
!input.forceBrowser &&
isWebUrl(input.url) &&
isPreviewSupportedInRuntime() &&
input.threadRef.threadId.length > 0 &&
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/settings/IntegrationsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,7 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean })
return (
<SettingsRow
{...searchableSetting("browser-link-target")}
description="Where links in the chat and terminal open. Hold ⌘ or Ctrl while clicking a chat link to open it in your default browser either way."
description="Where links in the chat and terminal open. Hold ⌘ or Ctrl while clicking a link to open it in your default browser either way."
resetAction={
!disabled && linkTarget !== DEFAULT_BROWSER_LINK_TARGET ? (
<SettingResetButton
Expand Down
95 changes: 80 additions & 15 deletions apps/web/src/terminal/ghostty/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
isTerminalCompositionCommitInput,
isTerminalCompositionKey,
isTerminalCopyShortcut,
isTerminalLinkPointerGesture,
isTerminalPasteShortcut,
loadTerminalFontFamily,
primeTerminalCopyInput,
Expand Down Expand Up @@ -168,14 +167,15 @@ describe("GhosttyTerminalSurface visibility", () => {
resize() {
for (const callback of resizeCallbacks) callback();
},
pointer(type: string, clientX: number, buttons: number) {
pointer(type: string, clientX: number, buttons: number, shiftKey = false) {
canvas.dispatchEvent(
Object.assign(new Event(type, { cancelable: true }), {
clientX,
clientY: 5,
pointerId: 1,
button: 0,
buttons,
shiftKey,
}),
);
},
Expand Down Expand Up @@ -280,6 +280,84 @@ describe("GhosttyTerminalSurface visibility", () => {
expect(harness.renderedSnapshot.rowData[0]?.cells.some((cell) => cell.selected)).toBe(false);
});

it("starts a selection when dragging from a link", async () => {
const harness = createHarness();
const onLinkActivate = vi.fn();
const surface = await harness.create({ onLinkActivate });
surface.write("https://example.com");
harness.flushFrame();

harness.pointer("pointerdown", 5, 1);
harness.pointer("pointermove", 37, 1);
harness.pointer("pointerup", 37, 0);

expect(onLinkActivate).not.toHaveBeenCalled();
expect(surface.getSelection()).toBe("https");
});

it("keeps a link click active through slight pointer movement", async () => {
const harness = createHarness();
const onLinkActivate = vi.fn();
const surface = await harness.create({ onLinkActivate });
surface.write("https://example.com");
harness.flushFrame();

harness.pointer("pointerdown", 5, 1);
harness.pointer("pointermove", 6, 1);
harness.pointer("pointerup", 6, 0);

expect(onLinkActivate).toHaveBeenCalledOnce();
});

it("uses repeated link clicks for word and line selection", async () => {
const harness = createHarness();
const onLinkActivate = vi.fn();
const surface = await harness.create({ onLinkActivate });
surface.write("https://example.com tail");
harness.flushFrame();

harness.pointer("pointerdown", 5, 1);
harness.pointer("pointerup", 5, 0);
harness.pointer("pointerdown", 5, 1);
harness.pointer("pointerup", 5, 0);
expect(onLinkActivate).toHaveBeenCalledOnce();
expect(surface.getSelection()).not.toBe("");

harness.pointer("pointerdown", 5, 1);
harness.pointer("pointerup", 5, 0);
expect(onLinkActivate).toHaveBeenCalledOnce();
expect(surface.getSelection()).toBe("https://example.com tail");
});

it("uses Shift drags over links for selection", async () => {
const harness = createHarness();
const onLinkActivate = vi.fn();
const surface = await harness.create({ onLinkActivate });
surface.write("https://example.com");
harness.flushFrame();

harness.pointer("pointerdown", 5, 1, true);
harness.pointer("pointermove", 37, 1, true);
harness.pointer("pointerup", 37, 0, true);
expect(onLinkActivate).not.toHaveBeenCalled();
expect(surface.getSelection()).toBe("https");
});

it("does not activate a link replaced before pointer release", async () => {
const harness = createHarness();
const onLinkActivate = vi.fn();
const surface = await harness.create({ onLinkActivate });
surface.write("https://first.example");
harness.flushFrame();

harness.pointer("pointerdown", 5, 1);
surface.write("\x1b[2J\x1b[Hhttps://second.example");
harness.flushFrame();
harness.pointer("pointerup", 5, 0);

expect(onLinkActivate).not.toHaveBeenCalled();
});

it("stops zero-size mounts and repaints when the same size returns", async () => {
const harness = createHarness();
const surface = await harness.create();
Expand Down Expand Up @@ -876,19 +954,6 @@ describe("terminalWheelArrowData", () => {
});
});

describe("isTerminalLinkPointerGesture", () => {
it("uses Command on macOS and Control elsewhere", () => {
expect(isTerminalLinkPointerGesture({ ctrlKey: false, metaKey: true }, "MacIntel")).toBe(true);
expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, "MacIntel")).toBe(false);
expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, "Linux x86_64")).toBe(
true,
);
expect(isTerminalLinkPointerGesture({ ctrlKey: false, metaKey: true }, "Linux x86_64")).toBe(
false,
);
});
});

describe("advanceTerminalSelectionClickSequence", () => {
it("recognizes stationary double and triple pointer presses without PointerEvent.detail", () => {
const first = advanceTerminalSelectionClickSequence(null, {
Expand Down
Loading
Loading