返回 DeepSeek-Reasonix
terminalSelection.ts
根目录 / desktop / frontend / src / lib / terminalSelection.ts
1 import { desktopHost } from "./desktopHost";
2
3 export type TerminalSelectionPoint = { left: number; top: number };
4
5 export type TerminalSelectionOperation<T> = {
6 generation: number;
7 terminal: T;
8 };
9
10 export type TerminalSelectionSnapshot<T> = TerminalSelectionOperation<T> & {
11 selectionRevision: number;
12 };
13
14 export type TerminalSelectionLifecycle<T> = {
15 activate: (terminal: T) => TerminalSelectionOperation<T>;
16 capture: () => TerminalSelectionOperation<T> | null;
17 captureSelection: () => TerminalSelectionSnapshot<T> | null;
18 deactivate: (terminal: T) => void;
19 isCurrent: (operation: TerminalSelectionOperation<T>) => boolean;
20 isCurrentSelection: (snapshot: TerminalSelectionSnapshot<T>) => boolean;
21 noteSelectionChange: () => void;
22 };
23
24 type RectLike = Pick<DOMRect, "left" | "top" | "right" | "bottom" | "width" | "height">;
25
26 function pickBottomMost(paints: readonly RectLike[]): RectLike {
27 let anchor = paints[0];
28 for (const rect of paints.slice(1)) {
29 if (rect.bottom > anchor.bottom) {
30 anchor = rect;
31 continue;
32 }
33 if (rect.bottom === anchor.bottom && rect.right > anchor.right) anchor = rect;
34 }
35 return anchor;
36 }
37
38 // xterm paints the active selection as absolutely positioned divs inside
39 // `.xterm-selection`. Anchor the floating action to the bottom-most real paint
40 // row so it sits next to the selected text, not on the panel's far edge.
41 export function terminalSelectionPointFromHost(
42 host: HTMLElement,
43 toolbarWidth = 160,
44 ): TerminalSelectionPoint | null {
45 const hostRect = host.getBoundingClientRect();
46 const paints = Array.from(host.querySelectorAll(".xterm-selection div"))
47 .map((node) => node.getBoundingClientRect())
48 .filter((rect) => rect.width > 0 || rect.height > 0);
49 if (paints.length === 0) return null;
50 // xterm occasionally leaves near-full-width spacer rows in the selection
51 // layer; those would park the toolbar on the terminal's right edge.
52 const usable = paints.filter((rect) => rect.width < hostRect.width * 0.92);
53 const anchor = pickBottomMost(usable.length > 0 ? usable : paints);
54 const maxLeft = Math.max(hostRect.left + 8, hostRect.right - toolbarWidth - 8);
55 const left = Math.min(Math.max(hostRect.left + 8, anchor.right + 4), maxLeft);
56 const top = Math.min(
57 Math.max(hostRect.top + 8, anchor.bottom + 6),
58 Math.max(hostRect.top + 8, hostRect.bottom - 40),
59 );
60 return { left, top };
61 }
62
63 export function clampTerminalSelectionPointToHost(
64 point: TerminalSelectionPoint,
65 host: HTMLElement,
66 toolbarWidth = 160,
67 toolbarHeight = 40,
68 ): TerminalSelectionPoint {
69 const hostRect = host.getBoundingClientRect();
70 const maxLeft = Math.max(hostRect.left + 8, hostRect.right - toolbarWidth - 8);
71 const maxTop = Math.max(hostRect.top + 8, hostRect.bottom - toolbarHeight - 8);
72 return {
73 left: Math.min(Math.max(hostRect.left + 8, point.left), maxLeft),
74 top: Math.min(Math.max(hostRect.top + 8, point.top), maxTop),
75 };
76 }
77
78 // Clipboard reads and writes can settle after the active terminal changes.
79 // Keep the terminal identity and generation together so stale completions can
80 // never clear or paste into the replacement session.
81 export function createTerminalSelectionLifecycle<T>(): TerminalSelectionLifecycle<T> {
82 let generation = 0;
83 let selectionRevision = 0;
84 let terminal: T | null = null;
85 return {
86 activate(next) {
87 generation += 1;
88 selectionRevision += 1;
89 terminal = next;
90 return { generation, terminal: next };
91 },
92 capture() {
93 return terminal === null ? null : { generation, terminal };
94 },
95 captureSelection() {
96 return terminal === null ? null : { generation, selectionRevision, terminal };
97 },
98 deactivate(target) {
99 if (terminal !== target) return;
100 generation += 1;
101 selectionRevision += 1;
102 terminal = null;
103 },
104 isCurrent(operation) {
105 return operation.generation === generation && operation.terminal === terminal;
106 },
107 isCurrentSelection(snapshot) {
108 return snapshot.generation === generation
109 && snapshot.selectionRevision === selectionRevision
110 && snapshot.terminal === terminal;
111 },
112 noteSelectionChange() {
113 if (terminal !== null) selectionRevision += 1;
114 },
115 };
116 }
117
118 // Pure decision for Ctrl+C / Ctrl+Shift+C / Cmd+C: a live selection
119 // copies and swallows the chord, otherwise the key keeps flowing to the PTY
120 // (Ctrl+C then stays SIGINT). Preserve xterm's selection byte-for-byte,
121 // including whitespace, line endings, and escape-looking text. Meta+C mirrors
122 // the macOS copy convention.
123 export function handleTerminalCopyKey(input: {
124 key: string;
125 ctrlKey: boolean;
126 metaKey: boolean;
127 altKey: boolean;
128 hasSelection: () => boolean;
129 getSelection: () => string;
130 }): { intercepted: boolean; text: string } {
131 const lowerKey = input.key.toLowerCase();
132 if (lowerKey !== "c") return { intercepted: false, text: "" };
133 const isCtrlC = input.ctrlKey && !input.altKey;
134 const isMetaC = input.metaKey && !input.altKey;
135 if (!isCtrlC && !isMetaC) return { intercepted: false, text: "" };
136 if (!input.hasSelection()) return { intercepted: false, text: "" };
137 return { intercepted: true, text: input.getSelection() };
138 }
139
140 // Async clipboard reads need the webview's Clipboard API permission; the
141 // desktop bridge is the fallback, mirroring writeClipboardText's ladder.
142 export async function readTerminalClipboardText(): Promise<string> {
143 try {
144 if (typeof navigator !== "undefined" && navigator.clipboard?.readText) {
145 return await navigator.clipboard.readText();
146 }
147 } catch {
148 // Permission denied or unavailable — try the bridge.
149 }
150 try {
151 const host = desktopHost();
152 if (host.kind !== "none") return await host.native.clipboardReadText();
153 } catch {
154 // Bridge missing or failed.
155 }
156 return "";
157 }
158
158 lines TYPESCRIPT