| author | |
| committer | |
| log | 1add47b322ab1dfb5010bb363613650176b88088 |
| tree | bb013f1d4340b2b08ee466efcca69c1aed24236f |
| parent | 80991402dcdc488838fb4d8b21171bd38d9ab0aa |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 159 insertions(+), 21 deletions(-)
apps/web/src/components/ThreadTerminalDrawer.tsx+6-12| ... | @@ -440,7 +440,6 @@ export function TerminalViewport({ | ... | @@ -440,7 +440,6 @@ export function TerminalViewport({ |
| 440 | onData: (data) => handleData(data), | 440 | onData: (data) => handleData(data), |
| 441 | onResize: (cols, rows) => void resizeTerminal(cols, rows), | 441 | onResize: (cols, rows) => void resizeTerminal(cols, rows), |
| 442 | onSelectionChange: () => handleSelectionChange(), | 442 | onSelectionChange: () => handleSelectionChange(), |
| 443 | onCopy: (text) => handleCopy(text), | ||
| 444 | beforeKey: (event) => handleBeforeKey(event), | 443 | beforeKey: (event) => handleBeforeKey(event), |
| 445 | onLinkActivate: (text, event) => handleLinkActivate(text, event), | 444 | onLinkActivate: (text, event) => handleLinkActivate(text, event), |
| 446 | }; | 445 | }; |
| ... | @@ -668,17 +667,6 @@ export function TerminalViewport({ | ... | @@ -668,17 +667,6 @@ export function TerminalViewport({ |
| 668 | })(); | 667 | })(); |
| 669 | } | 668 | } |
| 670 | 669 | ||
| 671 | function handleCopy(text: string): void { | ||
| 672 | void writeTextToClipboard(text, "terminal selection").catch((error: unknown) => { | ||
| 673 | const activeTerminal = terminalRef.current; | ||
| 674 | if (!activeTerminal) return; | ||
| 675 | writeSystemMessage( | ||
| 676 | activeTerminal, | ||
| 677 | error instanceof Error ? error.message : "Unable to copy terminal selection", | ||
| 678 | ); | ||
| 679 | }); | ||
| 680 | } | ||
| 681 | |||
| 682 | function handleData(data: string): void { | 670 | function handleData(data: string): void { |
| 683 | void (async () => { | 671 | void (async () => { |
| 684 | const result = await writeTerminal(data); | 672 | const result = await writeTerminal(data); |
| ... | @@ -696,6 +684,12 @@ export function TerminalViewport({ | ... | @@ -696,6 +684,12 @@ export function TerminalViewport({ |
| 696 | return; | 684 | return; |
| 697 | } | 685 | } |
| 698 | clearSelectionAction(); | 686 | clearSelectionAction(); |
| 687 | // A copy shortcut that clears the selection (Ctrl+C) must also close | ||
| 688 | // the context menu that appears with the selection, but a clear that | ||
| 689 | // never opened a menu must not dismiss an unrelated one. | ||
| 690 | if (selectionActionMenuOpenRef.current) { | ||
| 691 | void localApi?.contextMenu.close(); | ||
| 692 | } | ||
| 699 | } | 693 | } |
| 700 | 694 | ||
| 701 | const handleMouseUp = (event: MouseEvent) => { | 695 | const handleMouseUp = (event: MouseEvent) => { |
apps/web/src/components/settings/SettingsFontPreviews.tsx-1| ... | @@ -238,7 +238,6 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu | ... | @@ -238,7 +238,6 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu |
| 238 | onData: echo, | 238 | onData: echo, |
| 239 | onResize: noop, | 239 | onResize: noop, |
| 240 | onSelectionChange: noop, | 240 | onSelectionChange: noop, |
| 241 | onCopy: (text) => void navigator.clipboard?.writeText(text).catch(noop), | ||
| 242 | // Tab keeps walking the settings page instead of feeding the echo loop. | 241 | // Tab keeps walking the settings page instead of feeding the echo loop. |
| 243 | beforeKey: (event) => event.key !== "Tab", | 242 | beforeKey: (event) => event.key !== "Tab", |
| 244 | onLinkActivate: noop, | 243 | onLinkActivate: noop, |
apps/web/src/contextMenuFallback.test.ts+35-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { showContextMenuFallback } from "./contextMenuFallback"; | 3 | import { dismissContextMenu, showContextMenuFallback } from "./contextMenuFallback"; |
| 4 | 4 | ||
| 5 | type FakeListener = (event: FakeDomEvent) => void; | 5 | type FakeListener = (event: FakeDomEvent) => void; |
| 6 | 6 | ||
| ... | @@ -236,3 +236,37 @@ describe("showContextMenuFallback", () => { | ... | @@ -236,3 +236,37 @@ describe("showContextMenuFallback", () => { |
| 236 | await expect(selectionPromise).resolves.toBe("rename:project-b"); | 236 | await expect(selectionPromise).resolves.toBe("rename:project-b"); |
| 237 | }); | 237 | }); |
| 238 | }); | 238 | }); |
| 239 | |||
| 240 | describe("dismissContextMenu", () => { | ||
| 241 | it("resolves an open menu with null", async () => { | ||
| 242 | const selectionPromise = showContextMenuFallback([ | ||
| 243 | { id: "rename", label: "Rename" }, | ||
| 244 | { id: "delete", label: "Delete" }, | ||
| 245 | ]); | ||
| 246 | expect(findButton("Rename")).toBeTruthy(); | ||
| 247 | |||
| 248 | dismissContextMenu(); | ||
| 249 | |||
| 250 | await expect(selectionPromise).resolves.toBeNull(); | ||
| 251 | expect(findButton("Rename")).toBeUndefined(); | ||
| 252 | }); | ||
| 253 | |||
| 254 | it("is a no-op when no menu is open", async () => { | ||
| 255 | dismissContextMenu(); | ||
| 256 | expect(findButton("Rename")).toBeUndefined(); | ||
| 257 | }); | ||
| 258 | |||
| 259 | it("dismisses the prior menu when a new one opens", async () => { | ||
| 260 | const firstPromise = showContextMenuFallback([{ id: "first", label: "First" }]); | ||
| 261 | expect(findButton("First")).toBeTruthy(); | ||
| 262 | |||
| 263 | const secondPromise = showContextMenuFallback([{ id: "second", label: "Second" }]); | ||
| 264 | |||
| 265 | await expect(firstPromise).resolves.toBeNull(); | ||
| 266 | expect(findButton("First")).toBeUndefined(); | ||
| 267 | expect(findButton("Second")).toBeTruthy(); | ||
| 268 | |||
| 269 | dismissContextMenu(); | ||
| 270 | await expect(secondPromise).resolves.toBeNull(); | ||
| 271 | }); | ||
| 272 | }); |
apps/web/src/contextMenuFallback.ts+27| ... | @@ -101,6 +101,21 @@ function isNodeWithinMenuStack(target: EventTarget | null, menuStack: readonly H | ... | @@ -101,6 +101,21 @@ function isNodeWithinMenuStack(target: EventTarget | null, menuStack: readonly H |
| 101 | return false; | 101 | return false; |
| 102 | } | 102 | } |
| 103 | 103 | ||
| 104 | // Only one fallback menu exists at a time in the renderer; the active one is | ||
| 105 | // tracked so a state change (for example a terminal selection clearing) can | ||
| 106 | // dismiss it with the same result as an outside click or Escape. | ||
| 107 | let activeContextMenuDismiss: (() => void) | null = null; | ||
| 108 | |||
| 109 | /** | ||
| 110 | * Closes the currently open fallback context menu, resolving its show() with | ||
| 111 | * null (the same result as dismissing by outside click or Escape). No-op when | ||
| 112 | * no fallback menu is open. | ||
| 113 | */ | ||
| 114 | export function dismissContextMenu(): void { | ||
| 115 | activeContextMenuDismiss?.(); | ||
| 116 | activeContextMenuDismiss = null; | ||
| 117 | } | ||
| 118 | |||
| 104 | /** | 119 | /** |
| 105 | * Imperative DOM-based context menu for non-Electron environments. | 120 | * Imperative DOM-based context menu for non-Electron environments. |
| 106 | * Supports nested submenus and resolves with the clicked leaf item id. | 121 | * Supports nested submenus and resolves with the clicked leaf item id. |
| ... | @@ -114,11 +129,16 @@ export function showContextMenuFallback<T extends string>( | ... | @@ -114,11 +129,16 @@ export function showContextMenuFallback<T extends string>( |
| 114 | let isDisposed = false; | 129 | let isDisposed = false; |
| 115 | let canDismissFromPointer = false; | 130 | let canDismissFromPointer = false; |
| 116 | 131 | ||
| 132 | const dismiss = () => cleanup(null); | ||
| 133 | |||
| 117 | const cleanup = (result: T | null) => { | 134 | const cleanup = (result: T | null) => { |
| 118 | if (isDisposed) { | 135 | if (isDisposed) { |
| 119 | return; | 136 | return; |
| 120 | } | 137 | } |
| 121 | isDisposed = true; | 138 | isDisposed = true; |
| 139 | if (activeContextMenuDismiss === dismiss) { | ||
| 140 | activeContextMenuDismiss = null; | ||
| 141 | } | ||
| 122 | document.removeEventListener("keydown", onKeyDown); | 142 | document.removeEventListener("keydown", onKeyDown); |
| 123 | document.removeEventListener("pointerdown", onPointerDown, true); | 143 | document.removeEventListener("pointerdown", onPointerDown, true); |
| 124 | document.removeEventListener("contextmenu", onContextMenu, true); | 144 | document.removeEventListener("contextmenu", onContextMenu, true); |
| ... | @@ -299,6 +319,13 @@ export function showContextMenuFallback<T extends string>( | ... | @@ -299,6 +319,13 @@ export function showContextMenuFallback<T extends string>( |
| 299 | document.addEventListener("pointerdown", onPointerDown, true); | 319 | document.addEventListener("pointerdown", onPointerDown, true); |
| 300 | document.addEventListener("contextmenu", onContextMenu, true); | 320 | document.addEventListener("contextmenu", onContextMenu, true); |
| 301 | openMenu(items, position?.x ?? 0, position?.y ?? 0, 0); | 321 | openMenu(items, position?.x ?? 0, position?.y ?? 0, 0); |
| 322 | // Only one fallback menu can be open at a time: a new show must dismiss | ||
| 323 | // any prior one, or its DOM and listeners leak and close() can only ever | ||
| 324 | // reach the newest menu. | ||
| 325 | if (activeContextMenuDismiss) { | ||
| 326 | activeContextMenuDismiss(); | ||
| 327 | } | ||
| 328 | activeContextMenuDismiss = dismiss; | ||
| 302 | 329 | ||
| 303 | requestAnimationFrame(() => { | 330 | requestAnimationFrame(() => { |
| 304 | canDismissFromPointer = true; | 331 | canDismissFromPointer = true; |
apps/web/src/localApi.test.ts+10| ... | @@ -13,12 +13,14 @@ const showContextMenuFallbackMock = | ... | @@ -13,12 +13,14 @@ const showContextMenuFallbackMock = |
| 13 | position?: { x: number; y: number }, | 13 | position?: { x: number; y: number }, |
| 14 | ) => Promise<T | null> | 14 | ) => Promise<T | null> |
| 15 | >(); | 15 | >(); |
| 16 | const dismissContextMenuMock = vi.fn<() => void>(); | ||
| 16 | 17 | ||
| 17 | const requestConfirmDialogMock = | 18 | const requestConfirmDialogMock = |
| 18 | vi.fn<(message: string, options?: ConfirmDialogOptions) => Promise<boolean> | undefined>(); | 19 | vi.fn<(message: string, options?: ConfirmDialogOptions) => Promise<boolean> | undefined>(); |
| 19 | 20 | ||
| 20 | vi.mock("./contextMenuFallback", () => ({ | 21 | vi.mock("./contextMenuFallback", () => ({ |
| 21 | showContextMenuFallback: showContextMenuFallbackMock, | 22 | showContextMenuFallback: showContextMenuFallbackMock, |
| 23 | dismissContextMenu: dismissContextMenuMock, | ||
| 22 | })); | 24 | })); |
| 23 | 25 | ||
| 24 | vi.mock("./confirmDialog", () => ({ | 26 | vi.mock("./confirmDialog", () => ({ |
| ... | @@ -85,6 +87,14 @@ describe("LocalApi", () => { | ... | @@ -85,6 +87,14 @@ describe("LocalApi", () => { |
| 85 | expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 }); | 87 | expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 }); |
| 86 | }); | 88 | }); |
| 87 | 89 | ||
| 90 | it("dismisses an open browser context menu without a desktop bridge", async () => { | ||
| 91 | const { createLocalApi } = await import("./localApi"); | ||
| 92 | |||
| 93 | await createLocalApi().contextMenu.close(); | ||
| 94 | |||
| 95 | expect(dismissContextMenuMock).toHaveBeenCalledOnce(); | ||
| 96 | }); | ||
| 97 | |||
| 88 | it("uses the themed confirmation host when it is available", async () => { | 98 | it("uses the themed confirmation host when it is available", async () => { |
| 89 | requestConfirmDialogMock.mockResolvedValue(true); | 99 | requestConfirmDialogMock.mockResolvedValue(true); |
| 90 | const { createLocalApi } = await import("./localApi"); | 100 | const { createLocalApi } = await import("./localApi"); |
apps/web/src/localApi.ts+9-1| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import type { ConfirmDialogOptions, ContextMenuItem, LocalApi } from "@t3tools/contracts"; | 1 | import type { ConfirmDialogOptions, ContextMenuItem, LocalApi } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | import { requestConfirmDialog } from "./confirmDialog"; | 3 | import { requestConfirmDialog } from "./confirmDialog"; |
| 4 | import { showContextMenuFallback } from "./contextMenuFallback"; | 4 | import { dismissContextMenu, showContextMenuFallback } from "./contextMenuFallback"; |
| 5 | import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage"; | 5 | import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage"; |
| 6 | import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; | 6 | import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; |
| 7 | 7 | ||
| ... | @@ -41,6 +41,14 @@ function createBrowserLocalApi(): LocalApi { | ... | @@ -41,6 +41,14 @@ function createBrowserLocalApi(): LocalApi { |
| 41 | } | 41 | } |
| 42 | return showContextMenuFallback(items, position); | 42 | return showContextMenuFallback(items, position); |
| 43 | }, | 43 | }, |
| 44 | // A native desktop menu blocks keyboard input and closes on outside | ||
| 45 | // interaction, so nothing to do there; the DOM fallback needs an explicit | ||
| 46 | // dismiss when the state behind it goes away. | ||
| 47 | close: async () => { | ||
| 48 | if (!window.desktopBridge) { | ||
| 49 | dismissContextMenu(); | ||
| 50 | } | ||
| 51 | }, | ||
| 44 | }, | 52 | }, |
| 45 | persistence: { | 53 | persistence: { |
| 46 | getClientSettings: async () => { | 54 | getClientSettings: async () => { |
apps/web/src/terminal/ghostty/surface.test.ts+3-2| ... | @@ -219,11 +219,12 @@ describe("isTerminalCopyShortcut", () => { | ... | @@ -219,11 +219,12 @@ describe("isTerminalCopyShortcut", () => { |
| 219 | expect(isTerminalCopyShortcut(event({ metaKey: true }), "MacIntel")).toBe(true); | 219 | expect(isTerminalCopyShortcut(event({ metaKey: true }), "MacIntel")).toBe(true); |
| 220 | }); | 220 | }); |
| 221 | 221 | ||
| 222 | it("uses the conventional Ctrl+Shift+C shortcut elsewhere", () => { | 222 | it("copies with Ctrl+C and Ctrl+Shift+C elsewhere", () => { |
| 223 | expect(isTerminalCopyShortcut(event({ ctrlKey: true }), "Linux x86_64")).toBe(false); | 223 | expect(isTerminalCopyShortcut(event({ ctrlKey: true }), "Linux x86_64")).toBe(true); |
| 224 | expect(isTerminalCopyShortcut(event({ ctrlKey: true, shiftKey: true }), "Linux x86_64")).toBe( | 224 | expect(isTerminalCopyShortcut(event({ ctrlKey: true, shiftKey: true }), "Linux x86_64")).toBe( |
| 225 | true, | 225 | true, |
| 226 | ); | 226 | ); |
| 227 | expect(isTerminalCopyShortcut(event({}), "Linux x86_64")).toBe(false); | ||
| 227 | }); | 228 | }); |
| 228 | 229 | ||
| 229 | it("uses the produced character instead of the physical key position", () => { | 230 | it("uses the produced character instead of the physical key position", () => { |
apps/web/src/terminal/ghostty/surface.ts+68-4| ... | @@ -333,7 +333,7 @@ export function isTerminalCopyShortcut( | ... | @@ -333,7 +333,7 @@ export function isTerminalCopyShortcut( |
| 333 | platform = navigator.platform, | 333 | platform = navigator.platform, |
| 334 | ) { | 334 | ) { |
| 335 | if (event.key.toLowerCase() !== "c") return false; | 335 | if (event.key.toLowerCase() !== "c") return false; |
| 336 | return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; | 336 | return isMacPlatform(platform) ? event.metaKey : event.ctrlKey; |
| 337 | } | 337 | } |
| 338 | 338 | ||
| 339 | export function isTerminalPasteShortcut( | 339 | export function isTerminalPasteShortcut( |
| ... | @@ -463,7 +463,6 @@ export interface GhosttyTerminalSurfaceOptions { | ... | @@ -463,7 +463,6 @@ export interface GhosttyTerminalSurfaceOptions { |
| 463 | readonly onData: (data: string) => void; | 463 | readonly onData: (data: string) => void; |
| 464 | readonly onResize: (cols: number, rows: number) => void; | 464 | readonly onResize: (cols: number, rows: number) => void; |
| 465 | readonly onSelectionChange: () => void; | 465 | readonly onSelectionChange: () => void; |
| 466 | readonly onCopy: (text: string) => void; | ||
| 467 | readonly beforeKey: (event: KeyboardEvent) => boolean; | 466 | readonly beforeKey: (event: KeyboardEvent) => boolean; |
| 468 | readonly onLinkActivate: (text: string, event: MouseEvent) => void; | 467 | readonly onLinkActivate: (text: string, event: MouseEvent) => void; |
| 469 | } | 468 | } |
| ... | @@ -531,6 +530,8 @@ export class GhosttyTerminalSurface { | ... | @@ -531,6 +530,8 @@ export class GhosttyTerminalSurface { |
| 531 | private theme: GhosttyTheme; | 530 | private theme: GhosttyTheme; |
| 532 | private readonly suppressedKeyCodes = new Set<string>(); | 531 | private readonly suppressedKeyCodes = new Set<string>(); |
| 533 | private pasteShortcutToken = 0; | 532 | private pasteShortcutToken = 0; |
| 533 | private copyShortcutToken = 0; | ||
| 534 | private clearSelectionAfterCopy = false; | ||
| 534 | private wheelRemainder = 0; | 535 | private wheelRemainder = 0; |
| 535 | private dprMedia: MediaQueryList | null = null; | 536 | private dprMedia: MediaQueryList | null = null; |
| 536 | // Read live on every blink decision, and watched so that dropping the | 537 | // Read live on every blink decision, and watched so that dropping the |
| ... | @@ -901,9 +902,58 @@ export class GhosttyTerminalSurface { | ... | @@ -901,9 +902,58 @@ export class GhosttyTerminalSurface { |
| 901 | return; | 902 | return; |
| 902 | } | 903 | } |
| 903 | if (isTerminalCopyShortcut(event) && this.hasSelection()) { | 904 | if (isTerminalCopyShortcut(event) && this.hasSelection()) { |
| 904 | event.preventDefault(); | 905 | // A plain Ctrl+C/Cmd+C fires the browser's native copy event, caught in |
| 906 | // onCopyEvent; not preventing the default keeps that path alive. WebKit | ||
| 907 | // omits the keyboard copy event without a DOM selection, so race the | ||
| 908 | // clipboard write against it the same way paste races its read. The | ||
| 909 | // Shift variant has no native event (Chrome binds Ctrl+Shift+C to | ||
| 910 | // inspect), so synthesize one with execCommand("copy"). | ||
| 911 | if (event.shiftKey) { | ||
| 912 | event.preventDefault(); | ||
| 913 | document.execCommand("copy"); | ||
| 914 | } else { | ||
| 915 | // A plain Ctrl+C is also SIGINT on non-mac: clear the selection once | ||
| 916 | // it copies so the next Ctrl+C reaches the shell. The Shift chord and | ||
| 917 | // Cmd+C are copy-only, so they keep the selection; resetting the flag | ||
| 918 | // up front also drops any clear owed by an earlier gesture that never | ||
| 919 | // completed. | ||
| 920 | this.clearSelectionAfterCopy = !event.shiftKey && !isMacPlatform(navigator.platform); | ||
| 921 | const clipboard = navigator.clipboard; | ||
| 922 | if (typeof clipboard?.writeText === "function") { | ||
| 923 | // Defer the write past the default action: the native copy event | ||
| 924 | // (dispatched synchronously with the default action) claims the | ||
| 925 | // token first when it fires, and the write covers browsers whose | ||
| 926 | // shortcut produces no copy event. Skipping a write the native | ||
| 927 | // event already handled stops a stale resolution from clobbering a | ||
| 928 | // clipboard the user filled after this copy. | ||
| 929 | const token = ++this.copyShortcutToken; | ||
| 930 | const selection = this.getSelection(); | ||
| 931 | void Promise.resolve().then(() => { | ||
| 932 | if (this.disposed || this.copyShortcutToken !== token) return; | ||
| 933 | void clipboard.writeText(selection).then( | ||
| 934 | () => { | ||
| 935 | // The write may have been superseded while in flight; only | ||
| 936 | // touch the selection if this gesture still owns the token. | ||
| 937 | if (this.disposed || this.copyShortcutToken !== token) return; | ||
| 938 | if (this.clearSelectionAfterCopy) { | ||
| 939 | this.clearSelectionAfterCopy = false; | ||
| 940 | this.clearSelection(); | ||
| 941 | } | ||
| 942 | }, | ||
| 943 | () => { | ||
| 944 | // The write failed and the native event has already had its | ||
| 945 | // chance, so nothing copied and no clear is owed by this | ||
| 946 | // gesture; a newer one may have just set the flag, so only | ||
| 947 | // drop it if this gesture still owns the token. | ||
| 948 | if (this.copyShortcutToken === token) { | ||
| 949 | this.clearSelectionAfterCopy = false; | ||
| 950 | } | ||
| 951 | }, | ||
| 952 | ); | ||
| 953 | }); | ||
| 954 | } | ||
| 955 | } | ||
| 905 | this.suppressedKeyCodes.add(event.code); | 956 | this.suppressedKeyCodes.add(event.code); |
| 906 | this.options.onCopy(this.getSelection()); | ||
| 907 | return; | 957 | return; |
| 908 | } | 958 | } |
| 909 | if (isTerminalPasteShortcut(event)) { | 959 | if (isTerminalPasteShortcut(event)) { |
| ... | @@ -989,6 +1039,18 @@ export class GhosttyTerminalSurface { | ... | @@ -989,6 +1039,18 @@ export class GhosttyTerminalSurface { |
| 989 | this.dprMedia.addEventListener("change", this.onDevicePixelRatioChange); | 1039 | this.dprMedia.addEventListener("change", this.onDevicePixelRatioChange); |
| 990 | } | 1040 | } |
| 991 | 1041 | ||
| 1042 | private readonly onCopyEvent = (event: ClipboardEvent) => { | ||
| 1043 | if (!this.hasSelection()) return; | ||
| 1044 | event.preventDefault(); | ||
| 1045 | event.clipboardData?.setData("text/plain", this.getSelection()); | ||
| 1046 | // The native event beat any deferred write; drop the in-flight fallback. | ||
| 1047 | this.copyShortcutToken += 1; | ||
| 1048 | if (this.clearSelectionAfterCopy) { | ||
| 1049 | this.clearSelectionAfterCopy = false; | ||
| 1050 | this.clearSelection(); | ||
| 1051 | } | ||
| 1052 | }; | ||
| 1053 | |||
| 992 | private readonly onPaste = (event: ClipboardEvent) => { | 1054 | private readonly onPaste = (event: ClipboardEvent) => { |
| 993 | // Always suppress the browser's default insertion: content the textarea | 1055 | // Always suppress the browser's default insertion: content the textarea |
| 994 | // would receive (for example an html-only clipboard converted to text) | 1056 | // would receive (for example an html-only clipboard converted to text) |
| ... | @@ -1384,6 +1446,7 @@ export class GhosttyTerminalSurface { | ... | @@ -1384,6 +1446,7 @@ export class GhosttyTerminalSurface { |
| 1384 | this.input.addEventListener("blur", this.onBlur); | 1446 | this.input.addEventListener("blur", this.onBlur); |
| 1385 | this.input.addEventListener("input", this.onInput); | 1447 | this.input.addEventListener("input", this.onInput); |
| 1386 | this.input.addEventListener("paste", this.onPaste); | 1448 | this.input.addEventListener("paste", this.onPaste); |
| 1449 | this.input.addEventListener("copy", this.onCopyEvent); | ||
| 1387 | this.input.addEventListener("compositionstart", this.onCompositionStart); | 1450 | this.input.addEventListener("compositionstart", this.onCompositionStart); |
| 1388 | this.input.addEventListener("compositionend", this.onCompositionEnd); | 1451 | this.input.addEventListener("compositionend", this.onCompositionEnd); |
| 1389 | this.canvas.addEventListener("pointerdown", this.onPointerDown); | 1452 | this.canvas.addEventListener("pointerdown", this.onPointerDown); |
| ... | @@ -1408,6 +1471,7 @@ export class GhosttyTerminalSurface { | ... | @@ -1408,6 +1471,7 @@ export class GhosttyTerminalSurface { |
| 1408 | this.input.removeEventListener("blur", this.onBlur); | 1471 | this.input.removeEventListener("blur", this.onBlur); |
| 1409 | this.input.removeEventListener("input", this.onInput); | 1472 | this.input.removeEventListener("input", this.onInput); |
| 1410 | this.input.removeEventListener("paste", this.onPaste); | 1473 | this.input.removeEventListener("paste", this.onPaste); |
| 1474 | this.input.removeEventListener("copy", this.onCopyEvent); | ||
| 1411 | this.input.removeEventListener("compositionstart", this.onCompositionStart); | 1475 | this.input.removeEventListener("compositionstart", this.onCompositionStart); |
| 1412 | this.input.removeEventListener("compositionend", this.onCompositionEnd); | 1476 | this.input.removeEventListener("compositionend", this.onCompositionEnd); |
| 1413 | this.canvas.removeEventListener("pointerdown", this.onPointerDown); | 1477 | this.canvas.removeEventListener("pointerdown", this.onPointerDown); |
packages/contracts/src/ipc.ts+1| ... | @@ -1189,6 +1189,7 @@ export interface LocalApi { | ... | @@ -1189,6 +1189,7 @@ export interface LocalApi { |
| 1189 | items: readonly ContextMenuItem<T>[], | 1189 | items: readonly ContextMenuItem<T>[], |
| 1190 | position?: { x: number; y: number }, | 1190 | position?: { x: number; y: number }, |
| 1191 | ) => Promise<T | null>; | 1191 | ) => Promise<T | null>; |
| 1192 | close: () => Promise<void>; | ||
| 1192 | }; | 1193 | }; |
| 1193 | persistence: { | 1194 | persistence: { |
| 1194 | getClientSettings: () => Promise<ClientSettings | null>; | 1195 | getClientSettings: () => Promise<ClientSettings | null>; |