-
Notifications
You must be signed in to change notification settings - Fork 10
fix(web): preserve terminal selection copy #322
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
12664b7
d66d8f2
dad6b82
fbb213c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| export type TerminalMouseTrackingMode = "any" | "drag" | "none" | "vt200" | "x10" | ||
|
|
||
| type TerminalSelectionTarget = { | ||
| readonly getSelection: () => string | ||
| readonly hasSelection: () => boolean | ||
| } | ||
|
|
||
| export type TerminalCopyInteractionTerminal = TerminalSelectionTarget & { | ||
| readonly modes: { | ||
| readonly mouseTrackingMode: TerminalMouseTrackingMode | ||
| } | ||
| } | ||
|
|
||
| type TerminalMouseButtonEvent = { | ||
| readonly button: number | ||
| } | ||
|
|
||
| type TerminalSelectionModifierEvent = { | ||
| readonly altKey: boolean | ||
| readonly shiftKey: boolean | ||
| } | ||
|
|
||
| type TerminalCopyClipboardData = { | ||
| readonly setData: (format: string, data: string) => void | ||
| } | ||
|
|
||
| type TerminalCopyClipboardEvent = { | ||
| readonly clipboardData: TerminalCopyClipboardData | null | ||
| readonly preventDefault: () => void | ||
| readonly stopPropagation: () => void | ||
| } | ||
|
|
||
| type TerminalCopyMouseEvent = TerminalMouseButtonEvent & TerminalSelectionModifierEvent | ||
|
|
||
| type TerminalCopyInteractionHost = { | ||
| readonly addEventListener: { | ||
| (type: "copy", listener: (event: TerminalCopyClipboardEvent) => void, options: true): void | ||
| (type: "mousedown", listener: (event: TerminalCopyMouseEvent) => void, options: true): void | ||
| } | ||
| readonly removeEventListener: { | ||
| (type: "copy", listener: (event: TerminalCopyClipboardEvent) => void, options: true): void | ||
| (type: "mousedown", listener: (event: TerminalCopyMouseEvent) => void, options: true): void | ||
| } | ||
| } | ||
|
|
||
| type TerminalCopyInteractionArgs = { | ||
| readonly host: TerminalCopyInteractionHost | ||
| readonly terminal: TerminalCopyInteractionTerminal | ||
| } | ||
|
|
||
| const primaryMouseButton = 0 | ||
| const secondaryMouseButton = 2 | ||
|
|
||
| const macPlatformNames = new Set(["Mac68K", "MacIntel", "Macintosh", "MacPPC"]) | ||
|
|
||
| const currentNavigatorPlatform = (): string => { | ||
| if (typeof navigator === "undefined") { | ||
| return "" | ||
| } | ||
| return navigator.platform | ||
| } | ||
|
|
||
| const isPrimaryMouseButton = (event: TerminalMouseButtonEvent): boolean => event.button === primaryMouseButton | ||
|
|
||
| const isSecondaryMouseButton = (event: TerminalMouseButtonEvent): boolean => event.button === secondaryMouseButton | ||
|
|
||
| const hasActiveMouseTracking = (terminal: TerminalCopyInteractionTerminal): boolean => | ||
| terminal.modes.mouseTrackingMode !== "none" | ||
|
|
||
| export const shouldForceBrowserTerminalSelection = ( | ||
| event: TerminalMouseButtonEvent, | ||
| terminal: TerminalCopyInteractionTerminal | ||
| ): boolean => isPrimaryMouseButton(event) && hasActiveMouseTracking(terminal) | ||
|
|
||
| export const shouldForceTerminalSelectionContext = ( | ||
| event: TerminalMouseButtonEvent, | ||
| terminal: TerminalCopyInteractionTerminal | ||
| ): boolean => isSecondaryMouseButton(event) && terminal.hasSelection() | ||
|
|
||
|
Comment on lines
+75
to
+79
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win Ограничьте форсирование context-selection только активным mouse tracking. На Line 78 сейчас условие не учитывает Предлагаемое изменение export const shouldForceTerminalSelectionContext = (
event: TerminalMouseButtonEvent,
terminal: TerminalCopyInteractionTerminal
-): boolean => isSecondaryMouseButton(event) && terminal.hasSelection()
+): boolean =>
+ isSecondaryMouseButton(event) &&
+ hasActiveMouseTracking(terminal) &&
+ terminal.hasSelection()🤖 Prompt for AI Agents |
||
| const terminalSelectionModifier = (platform: string): keyof TerminalSelectionModifierEvent => | ||
| macPlatformNames.has(platform) ? "altKey" : "shiftKey" | ||
|
|
||
| export const forceTerminalSelectionModifier = ( | ||
| event: TerminalSelectionModifierEvent, | ||
| platform: string = currentNavigatorPlatform() | ||
| ): boolean => | ||
| Reflect.defineProperty(event, terminalSelectionModifier(platform), { | ||
| configurable: true, | ||
| value: true | ||
| }) | ||
|
|
||
| export const writeTerminalSelectionToClipboardData = ( | ||
| terminal: TerminalSelectionTarget, | ||
| clipboardData: TerminalCopyClipboardData | null | ||
| ): boolean => { | ||
| if (clipboardData === null || !terminal.hasSelection()) { | ||
| return false | ||
| } | ||
| const selection = terminal.getSelection() | ||
| if (selection.length === 0) { | ||
| return false | ||
| } | ||
| clipboardData.setData("text/plain", selection) | ||
| return true | ||
| } | ||
|
|
||
| export const attachTerminalCopyInteraction = ( | ||
| args: TerminalCopyInteractionArgs | ||
| ): { readonly dispose: () => void } => { | ||
| const onMouseDown = (event: TerminalCopyMouseEvent): void => { | ||
| if ( | ||
| !shouldForceBrowserTerminalSelection(event, args.terminal) && | ||
| !shouldForceTerminalSelectionContext(event, args.terminal) | ||
| ) { | ||
| return | ||
| } | ||
| forceTerminalSelectionModifier(event) | ||
| } | ||
| const onCopy = (event: TerminalCopyClipboardEvent): void => { | ||
| if (!writeTerminalSelectionToClipboardData(args.terminal, event.clipboardData)) { | ||
| return | ||
| } | ||
| event.preventDefault() | ||
| event.stopPropagation() | ||
| } | ||
|
|
||
| args.host.addEventListener("mousedown", onMouseDown, true) | ||
| args.host.addEventListener("copy", onCopy, true) | ||
|
|
||
| return { | ||
| dispose: () => { | ||
| args.host.removeEventListener("mousedown", onMouseDown, true) | ||
| args.host.removeEventListener("copy", onCopy, true) | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import type { TerminalPasteGuard } from "./terminal-panel-runtime-types.js" | ||
|
|
||
| export type TerminalClientMessage = | ||
| | { readonly data: string; readonly type: "input" } | ||
| | { readonly cols: number; readonly rows: number; readonly type: "resize" } | ||
|
|
||
| type TerminalClientSocket = { | ||
| readonly readyState: number | ||
| readonly send: (data: string) => void | ||
| } | ||
|
|
||
| export type TerminalClientSocketRef = { readonly current: TerminalClientSocket | null } | ||
|
|
||
| type TerminalInputTarget = { | ||
| readonly onData: (handler: (data: string) => void) => { readonly dispose: () => void } | ||
| readonly scrollToBottom: () => void | ||
| } | ||
|
|
||
| const csiPrefix = "\u001B[" | ||
| const x10MouseReportPrefix = `${csiPrefix}M` | ||
| const x10MouseReportLength = 6 | ||
| const sgrMouseReportBodyPattern = /^<\d+;\d+;\d+[Mm]$/u | ||
| const urxvtMouseReportBodyPattern = /^\d+;\d+;\d+M$/u | ||
|
|
||
| export const isTerminalMouseReportInput = (data: string): boolean => { | ||
| if (data.startsWith(x10MouseReportPrefix)) { | ||
| return data.length === x10MouseReportLength | ||
| } | ||
| if (!data.startsWith(csiPrefix)) { | ||
| return false | ||
| } | ||
| const body = data.slice(csiPrefix.length) | ||
| return sgrMouseReportBodyPattern.test(body) || urxvtMouseReportBodyPattern.test(body) | ||
| } | ||
|
|
||
| export const sendTerminalClientMessage = ( | ||
| socketRef: TerminalClientSocketRef, | ||
| message: TerminalClientMessage | ||
| ): void => { | ||
| const socket = socketRef.current | ||
| if (socket === null || socket.readyState !== WebSocket.OPEN) { | ||
| return | ||
| } | ||
| socket.send(JSON.stringify(message)) | ||
| } | ||
|
|
||
| export const attachTerminalInput = ( | ||
| terminal: TerminalInputTarget, | ||
| socketRef: TerminalClientSocketRef, | ||
| pasteGuard: TerminalPasteGuard | ||
| ) => | ||
| terminal.onData((data) => { | ||
| if (pasteGuard.shouldSuppressTerminalInput(data)) { | ||
| return | ||
| } | ||
| if (!isTerminalMouseReportInput(data)) { | ||
| terminal.scrollToBottom() | ||
| } | ||
| sendTerminalClientMessage(socketRef, { data, type: "input" }) | ||
| }) | ||
|
Comment on lines
+36
to
+60
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Проверка, что в модуле есть прямые IO-вызовы без Effect-композиции
rg -n "socket\.send\(|terminal\.onData\(|scrollToBottom\(|Effect\." packages/app/src/web/terminal-panel-input.tsRepository: ProverCoderAI/docker-git Length of output: 178 🏁 Script executed: # Проверка полного содержимого файла
cat -n packages/app/src/web/terminal-panel-input.tsRepository: ProverCoderAI/docker-git Length of output: 2352 🏁 Script executed: # Поиск использования Effect и Layer в веб-части приложения
rg -l "Effect\.|Context\.Tag|Layer\." packages/app/src/web/ | head -10Repository: ProverCoderAI/docker-git Length of output: 539 🏁 Script executed: # Поиск паттернов Effect для WebSocket/сокетов в кодовой базе
rg -B2 -A5 "Context\.Tag|Layer\.effect" packages/app/src/web/ | head -50Repository: ProverCoderAI/docker-git Length of output: 50 🏁 Script executed: # Поиск использования sendTerminalClientMessage и attachTerminalInput
rg -B3 -A3 "sendTerminalClientMessage|attachTerminalInput" packages/app/src/web/ --type tsRepository: ProverCoderAI/docker-git Length of output: 4947 🏁 Script executed: # Проверка terminal-panel-runtime-core.ts на предмет Effect паттернов
cat packages/app/src/web/terminal-panel-runtime-core.ts | head -100Repository: ProverCoderAI/docker-git Length of output: 3232 🏁 Script executed: # Поиск Context.Tag или Layer определений в веб приложении
rg "extends Context\.Tag|Layer\." packages/app/src/web/ -A3Repository: ProverCoderAI/docker-git Length of output: 50 🏁 Script executed: # Посмотреть как используется attachTerminalInput в terminal-panel-runtime.ts
rg -B5 -A10 "inputDisposable.*attachTerminalInput" packages/app/src/web/terminal-panel-runtime.tsRepository: ProverCoderAI/docker-git Length of output: 489 🏁 Script executed: # Полный контекст использования в terminal-panel-runtime.ts
cat packages/app/src/web/terminal-panel-runtime.ts | head -150Repository: ProverCoderAI/docker-git Length of output: 4793 🏁 Script executed: # Проверить структуру проекта и наличие Effect-based сервисов
find packages/app/src -name "*.ts" -type f | xargs rg "extends Context\.Tag" 2>/dev/null | head -20Repository: ProverCoderAI/docker-git Length of output: 50 🏁 Script executed: # Проверить git статус файла - новый или существующий
git -C packages/app/src/web log --oneline terminal-panel-input.ts 2>/dev/null | head -5Repository: ProverCoderAI/docker-git Length of output: 119 🏁 Script executed: # Проверить если есть PR description или сообщение о коммите
git -C packages/app log --oneline -20 --all 2>/dev/null | head -20Repository: ProverCoderAI/docker-git Length of output: 1037 Рефакторинг под Effect + Layer необходимо выполнить в отдельной архитектурной задаче, но текущий fix содержит нарушения. Функции
Согласно кодовым гайдлайнам, все внешние зависимости должны быть обёрнуты через Effect-TS Layer с типизированными Context.Tag. Текущая реализация нарушает контракт FCIS-паттерна: IO-логика должна находиться в SHELL слое через Effect-композицию, оставляя здесь только чистые функции и монадическую композицию. Этот рефакторинг требует создания типизированных сервисов (WebSocketService, TerminalIOService) через Context.Tag и их инъекции через Layer.effect. Рекомендуется выполнить как отдельную архитектурную задачу для всего модуля, а не в текущем fix-коммите. 🤖 Prompt for AI Agents |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Добавьте функциональные комментарии для документирования изменения схемы.
Согласно guidelines, все изменения должны быть документированы функциональными комментариями с метками CHANGE (что изменилось), WHY (почему это необходимо для backward compatibility), QUOTE(ТЗ) или REF (ссылка на требование/issue), и INVARIANT (гарантия дефолтных значений при отсутствии полей).
Кроме того, это изменение не упомянуто в описании PR
#322, которое посвящено terminal copy interaction. В соответствии с принципом spec-driven development, каждое изменение поведения должно быть явно задокументировано в спецификации PR или связанном issue.📝 Предлагаемые функциональные комментарии
Согласно coding guidelines: "Use functional comment markers for code clarity: CHANGE (brief description), WHY (mathematical/architectural justification), QUOTE(ТЗ) (requirement citation), REF (RTM or message ID)".
🤖 Prompt for AI Agents