Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 @@ -55,7 +55,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 @@ -747,7 +747,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 @@ -768,6 +767,7 @@ export function TerminalViewport({
threadRef,
openPreview,
fallbackToBrowser,
forceBrowser: event.metaKey || event.ctrlKey,
});
return;
}
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/components/preview/openTerminalLinkInPreview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ describe("openTerminalLinkInPreview", () => {
threadRef,
openPreview,
fallbackToBrowser,
forceBrowser: false,
});

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

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

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

expect(fallbackToBrowser).toHaveBeenCalledOnce();
Expand All @@ -167,9 +171,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
29 changes: 15 additions & 14 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 @@ -280,6 +279,21 @@ 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("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 +890,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
55 changes: 27 additions & 28 deletions apps/web/src/terminal/ghostty/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,15 +463,6 @@ export function terminalWheelArrowData(rows: number, applicationCursorKeys: bool
return sequence.repeat(Math.abs(rows));
}

export function isTerminalLinkPointerGesture(
event: Pick<MouseEvent, "ctrlKey" | "metaKey">,
platform = navigator.platform,
): boolean {
return isMacPlatform(platform)
? event.metaKey && !event.ctrlKey
: event.ctrlKey && !event.metaKey;
}

export function ghosttyMouseButton(button: number): number | null {
switch (button) {
case 0:
Expand Down Expand Up @@ -588,9 +579,9 @@ export class GhosttyTerminalSurface {
private mouseReportingPointerId: number | null = null;
private mouseReportingButton: number | null = null;
private linkActivationPointerId: number | null = null;
private linkActivationOrigin: { x: number; y: number; time: number } | null = null;
private hoveredLink: TerminalLinkWithRange | null = null;
private hoverPointer: { x: number; y: number } | null = null;
private linkModifierActive = false;
private selectionClickSequence: TerminalSelectionClickSequence | null = null;
private selectionMoved = false;
private composing = false;
Expand Down Expand Up @@ -1014,7 +1005,6 @@ export class GhosttyTerminalSurface {
}

private readonly onKeyDown = (event: KeyboardEvent) => {
this.updateLinkModifier(event);
// Presses handled outside the terminal must also swallow their release:
// beforeKey runs side effects (keybindings, navigation sends), so it cannot
// be consulted again on keyup, and Kitty report-event-types sessions would
Expand Down Expand Up @@ -1117,7 +1107,6 @@ export class GhosttyTerminalSurface {
};

private readonly onKeyUp = (event: KeyboardEvent) => {
this.updateLinkModifier(event);
if (this.suppressedKeyCodes.delete(event.code)) return;
if (isTerminalCompositionKey(event, this.composing)) {
return;
Expand All @@ -1139,7 +1128,6 @@ export class GhosttyTerminalSurface {

private readonly onBlur = () => {
this.focused = false;
this.linkModifierActive = false;
this.refreshHoveredLink();
// Suppressions survive blur deliberately: a shortcut that moves focus (for
// example terminal-toggle) must still swallow its own keyup if focus comes
Expand Down Expand Up @@ -1260,14 +1248,24 @@ export class GhosttyTerminalSurface {
return;
}
if (event.button !== 0) return;
if (isTerminalLinkPointerGesture(event)) {
if (this.linkAt(event.clientX, event.clientY)) {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
event.preventDefault();
event.stopPropagation();
this.linkActivationPointerId = event.pointerId;
this.linkActivationOrigin = {
x: event.clientX,
y: event.clientY,
time: event.timeStamp,
};
this.canvas.setPointerCapture(event.pointerId);
return;
}
this.clearHoveredLink();
this.beginSelection(event);
this.canvas.setPointerCapture(event.pointerId);
};

private beginSelection(event: { clientX: number; clientY: number; timeStamp: number }): void {
const cell = this.cellAt(event.clientX, event.clientY);
this.selectionMoved = false;
this.selectionClickSequence = advanceTerminalSelectionClickSequence(
Expand Down Expand Up @@ -1302,12 +1300,22 @@ export class GhosttyTerminalSurface {
}
}
this.forceFullRender = true;
this.canvas.setPointerCapture(event.pointerId);
this.requestRender();
};
}

private readonly onPointerMove = (event: PointerEvent) => {
if (this.linkActivationPointerId === event.pointerId) return;
if (this.linkActivationPointerId === event.pointerId) {
const origin = this.linkActivationOrigin;
if (origin === null || (event.clientX === origin.x && event.clientY === origin.y)) return;
this.linkActivationPointerId = null;
this.linkActivationOrigin = null;
this.clearHoveredLink();
this.beginSelection({
clientX: origin.x,
clientY: origin.y,
timeStamp: origin.time,
});
}
// Hover motion is only reportable in any-event tracking (DEC 1003); normal and
// button-event tracking never report motion without a captured pressed button.
const anyEventTracking = this.synchronizeMouseTrackingState();
Expand All @@ -1317,7 +1325,6 @@ export class GhosttyTerminalSurface {
) {
event.preventDefault();
this.hoverPointer = { x: event.clientX, y: event.clientY };
this.linkModifierActive = isTerminalLinkPointerGesture(event);
// A drag whose press was already sent to the terminal application cannot
// turn into link activation midway through, so link feedback would lie.
this.setHoveredLink(null);
Expand Down Expand Up @@ -1392,14 +1399,6 @@ export class GhosttyTerminalSurface {

private updateHoverCursor(event: PointerEvent): void {
this.hoverPointer = { x: event.clientX, y: event.clientY };
this.linkModifierActive = isTerminalLinkPointerGesture(event);
this.refreshHoveredLink();
}

private updateLinkModifier(event: Pick<KeyboardEvent, "ctrlKey" | "metaKey">): void {
const active = isTerminalLinkPointerGesture(event);
if (active === this.linkModifierActive) return;
this.linkModifierActive = active;
this.refreshHoveredLink();
}

Expand All @@ -1416,7 +1415,7 @@ export class GhosttyTerminalSurface {

private refreshHoveredLink(): void {
const pointer = this.hoverPointer;
const link = pointer && this.linkModifierActive ? this.linkAt(pointer.x, pointer.y) : null;
const link = pointer ? this.linkAt(pointer.x, pointer.y) : null;
this.setHoveredLink(link);
}

Expand All @@ -1441,6 +1440,7 @@ export class GhosttyTerminalSurface {
event.preventDefault();
event.stopPropagation();
this.linkActivationPointerId = null;
this.linkActivationOrigin = null;
if (this.canvas.hasPointerCapture(event.pointerId)) {
this.canvas.releasePointerCapture(event.pointerId);
}
Expand All @@ -1463,7 +1463,6 @@ export class GhosttyTerminalSurface {
this.clearHoveredLink();
} else {
this.hoverPointer = { x: event.clientX, y: event.clientY };
this.linkModifierActive = isTerminalLinkPointerGesture(event);
this.refreshHoveredLink();
}
return;
Expand Down
Loading