refactor: codebase audit cleanup — dead code, dedup, module splits
Multi-agent audit + aggressive cleanup across server/web/coder/booterm, delivered behind a DEFER discipline so none of the in-flight files were touched. Removes dead code/deps/columns, dedups server + coder helpers, and splits the oversized modules (tools.ts, opencode-server.ts, sentinel-summaries, turn.ts, TerminalPane.tsx) behind stable contracts. Adds 78 parity/unit tests (server 587, coder 323); fixes two latent bugs (ChatPane queue keys, FileViewerOverlay blank-line parity). Intended tag: v2.7.12-audit-cleanup.
This commit is contained in:
349
apps/web/src/hooks/terminal/useTerminalSelection.ts
Normal file
349
apps/web/src/hooks/terminal/useTerminalSelection.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
chatInputsRegistry,
|
||||
sendToChat,
|
||||
terminalsRegistry,
|
||||
type ChatInputRegistration,
|
||||
} from '@/lib/events';
|
||||
import { cellSize } from './useTerminalFit';
|
||||
|
||||
// useTerminalSelection — the touch long-press selection + word-range gesture
|
||||
// subsystem, the document pointer/contextmenu handlers, clipboard custom keys,
|
||||
// the terminal + chat-input registries, and the floating-menu actions. All of
|
||||
// it is independent of the WS/fit path and was kept verbatim from v1.10.4.
|
||||
|
||||
const LONG_PRESS_MS = 500;
|
||||
const LONG_PRESS_TOLERANCE_PX = 10;
|
||||
|
||||
function pointToCell(
|
||||
term: Terminal,
|
||||
container: HTMLElement,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): { col: number; bufferRow: number } {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const { w, h } = cellSize(term, container);
|
||||
const localX = Math.max(0, clientX - rect.left);
|
||||
const localY = Math.max(0, clientY - rect.top);
|
||||
const col = Math.min(term.cols - 1, Math.floor(localX / Math.max(w, 1)));
|
||||
const screenRow = Math.min(term.rows - 1, Math.floor(localY / Math.max(h, 1)));
|
||||
const bufferRow = term.buffer.active.viewportY + screenRow;
|
||||
return { col, bufferRow };
|
||||
}
|
||||
|
||||
const WORD_RE = /[\w.~$\-/]+/g;
|
||||
function wordRangeAt(line: string, col: number): { start: number; end: number } | null {
|
||||
for (const m of line.matchAll(WORD_RE)) {
|
||||
const start = m.index ?? 0;
|
||||
const end = start + m[0].length;
|
||||
if (col >= start && col < end) return { start, end };
|
||||
if (start > col) return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface SelectionDeps {
|
||||
termRef: React.MutableRefObject<Terminal | null>;
|
||||
containerRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
sessionId: string;
|
||||
paneId: string;
|
||||
label: string;
|
||||
send: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface TerminalSelectionActions {
|
||||
copy: () => void;
|
||||
paste: () => void;
|
||||
selectAll: () => void;
|
||||
search: () => void;
|
||||
sendToChat: (chatId: string) => void;
|
||||
}
|
||||
|
||||
export interface TerminalSelection {
|
||||
menu: { x: number; y: number } | null;
|
||||
setMenu: React.Dispatch<React.SetStateAction<{ x: number; y: number } | null>>;
|
||||
searchOpen: boolean;
|
||||
setSearchOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
chatInputs: ChatInputRegistration[];
|
||||
hasSelection: boolean;
|
||||
actions: TerminalSelectionActions;
|
||||
}
|
||||
|
||||
export function useTerminalSelection({
|
||||
termRef,
|
||||
containerRef,
|
||||
sessionId,
|
||||
paneId,
|
||||
label,
|
||||
send,
|
||||
}: SelectionDeps): TerminalSelection {
|
||||
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [chatInputs, setChatInputs] = useState<ChatInputRegistration[]>([]);
|
||||
|
||||
const pasteFromClipboard = useCallback((): void => {
|
||||
if (!navigator.clipboard || typeof navigator.clipboard.readText !== 'function') {
|
||||
toast.error('Paste blocked — long-press input area instead');
|
||||
return;
|
||||
}
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
.then((text) => {
|
||||
if (!text) return;
|
||||
send(text);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Paste blocked — long-press input area instead');
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
// ============================================================
|
||||
// v1.10.4 features (long-press menu, right-click, custom keys)
|
||||
// Kept verbatim — independent of the WS/fit path that v1.10.8c fixes.
|
||||
// Re-bound on session/pane change so the gesture closures reference the
|
||||
// recreated terminal.
|
||||
// ============================================================
|
||||
useEffect(() => {
|
||||
const termInit = termRef.current;
|
||||
const ctrInit = containerRef.current;
|
||||
if (!termInit || !ctrInit) return;
|
||||
// Non-null bindings so the gesture closures below keep the narrowing.
|
||||
const term: Terminal = termInit;
|
||||
const ctr: HTMLDivElement = ctrInit;
|
||||
|
||||
let disposed = false;
|
||||
|
||||
term.attachCustomKeyEventHandler((e) => {
|
||||
if (e.type !== 'keydown') return true;
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (!mod) return true;
|
||||
const isC = e.key === 'c' || e.key === 'C';
|
||||
const isV = e.key === 'v' || e.key === 'V';
|
||||
const isF = e.key === 'f' || e.key === 'F';
|
||||
if (isC) {
|
||||
if (term.hasSelection()) {
|
||||
const sel = term.getSelection();
|
||||
if (sel) {
|
||||
navigator.clipboard.writeText(sel).catch(() => {
|
||||
toast.error('Clipboard write failed');
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return !e.shiftKey;
|
||||
}
|
||||
if (isV) {
|
||||
pasteFromClipboard();
|
||||
return false;
|
||||
}
|
||||
if (isF) {
|
||||
setSearchOpen(true);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Long-press selection + floating menu (touch).
|
||||
let lpTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lpStart: { x: number; y: number } | null = null;
|
||||
let lpAnchor: { col: number; bufferRow: number } | null = null;
|
||||
let inSelection = false;
|
||||
function clearLp(): void {
|
||||
if (lpTimer !== null) {
|
||||
clearTimeout(lpTimer);
|
||||
lpTimer = null;
|
||||
}
|
||||
lpStart = null;
|
||||
}
|
||||
function selectWord(col: number, bufferRow: number): boolean {
|
||||
const line = term.buffer.active.getLine(bufferRow);
|
||||
if (!line) return false;
|
||||
const text = line.translateToString(true);
|
||||
const range = wordRangeAt(text, col);
|
||||
if (!range) return false;
|
||||
term.select(range.start, bufferRow, range.end - range.start);
|
||||
return true;
|
||||
}
|
||||
function extendSelection(
|
||||
anchor: { col: number; bufferRow: number },
|
||||
to: { col: number; bufferRow: number },
|
||||
): void {
|
||||
const a = anchor;
|
||||
const b = to;
|
||||
let s: { col: number; row: number };
|
||||
let e: { col: number; row: number };
|
||||
if (a.bufferRow < b.bufferRow || (a.bufferRow === b.bufferRow && a.col <= b.col)) {
|
||||
s = { col: a.col, row: a.bufferRow };
|
||||
e = { col: b.col, row: b.bufferRow };
|
||||
} else {
|
||||
s = { col: b.col, row: b.bufferRow };
|
||||
e = { col: a.col, row: a.bufferRow };
|
||||
}
|
||||
const rowsBetween = e.row - s.row;
|
||||
const length = rowsBetween * term.cols + (e.col - s.col) + 1;
|
||||
term.select(s.col, s.row, length);
|
||||
}
|
||||
function onTouchStart(e: TouchEvent): void {
|
||||
if (e.touches.length !== 1) return;
|
||||
const t = e.touches[0]!;
|
||||
if ((e.target as Element | null)?.closest('[data-term-menu]')) return;
|
||||
lpStart = { x: t.clientX, y: t.clientY };
|
||||
lpAnchor = pointToCell(term, ctr, t.clientX, t.clientY);
|
||||
inSelection = false;
|
||||
lpTimer = setTimeout(() => {
|
||||
if (disposed || !lpAnchor) return;
|
||||
const ok = selectWord(lpAnchor.col, lpAnchor.bufferRow);
|
||||
if (!ok) return;
|
||||
inSelection = true;
|
||||
setMenu({ x: t.clientX, y: Math.max(t.clientY - 50, 8) });
|
||||
}, LONG_PRESS_MS);
|
||||
}
|
||||
function onTouchMove(e: TouchEvent): void {
|
||||
if (e.touches.length !== 1) return;
|
||||
const t = e.touches[0]!;
|
||||
if (inSelection && lpAnchor) {
|
||||
e.preventDefault();
|
||||
const to = pointToCell(term, ctr, t.clientX, t.clientY);
|
||||
extendSelection(lpAnchor, to);
|
||||
return;
|
||||
}
|
||||
if (!lpStart) return;
|
||||
const dx = t.clientX - lpStart.x;
|
||||
const dy = t.clientY - lpStart.y;
|
||||
if (Math.abs(dx) > LONG_PRESS_TOLERANCE_PX || Math.abs(dy) > LONG_PRESS_TOLERANCE_PX) {
|
||||
clearLp();
|
||||
}
|
||||
}
|
||||
function onTouchEnd(): void {
|
||||
if (!inSelection) clearLp();
|
||||
inSelection = false;
|
||||
}
|
||||
function onTouchCancel(): void {
|
||||
clearLp();
|
||||
inSelection = false;
|
||||
}
|
||||
ctr.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
ctr.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
ctr.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
ctr.addEventListener('touchcancel', onTouchCancel, { passive: true });
|
||||
|
||||
function onContextMenu(e: MouseEvent): void {
|
||||
e.preventDefault();
|
||||
setMenu({ x: e.clientX, y: e.clientY });
|
||||
}
|
||||
ctr.addEventListener('contextmenu', onContextMenu);
|
||||
|
||||
function onDocPointerDown(e: PointerEvent): void {
|
||||
const t = e.target as Element | null;
|
||||
if (t && t.closest('[data-term-menu]')) return;
|
||||
setMenu(null);
|
||||
}
|
||||
document.addEventListener('pointerdown', onDocPointerDown);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (lpTimer !== null) clearTimeout(lpTimer);
|
||||
document.removeEventListener('pointerdown', onDocPointerDown);
|
||||
ctr.removeEventListener('touchstart', onTouchStart);
|
||||
ctr.removeEventListener('touchmove', onTouchMove);
|
||||
ctr.removeEventListener('touchend', onTouchEnd);
|
||||
ctr.removeEventListener('touchcancel', onTouchCancel);
|
||||
ctr.removeEventListener('contextmenu', onContextMenu);
|
||||
// attachCustomKeyEventHandler has no disposable; term.dispose() drops it.
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, paneId, send, pasteFromClipboard]);
|
||||
|
||||
// Terminal registry: focus / openSearch / paste hooks for keyboard shortcuts
|
||||
// (Session.tsx) and the pane-header paste button (Workspace.tsx). Keyed on
|
||||
// [paneId, label] ONLY — NOT on sessionId — so a positional `label` renumber
|
||||
// ("Terminal N" after a pane add/remove) refreshes the registry entry
|
||||
// WITHOUT tearing down + reconnecting the live terminal. The focus closure
|
||||
// reads termRef lazily, so it tracks a terminal recreated on a session change
|
||||
// without needing this effect to re-run. (v2 Phase 9 latent-bug fix: `label`
|
||||
// was previously in the xterm-init effect's dep array.)
|
||||
useEffect(() => {
|
||||
const unregister = terminalsRegistry.register(
|
||||
paneId,
|
||||
label,
|
||||
() => {
|
||||
try {
|
||||
termRef.current?.focus();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
() => setSearchOpen(true),
|
||||
() => pasteFromClipboard(),
|
||||
);
|
||||
return () => unregister();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [paneId, label, pasteFromClipboard]);
|
||||
|
||||
// Chat-input registry: populates the floating menu's "Send to chat" submenu.
|
||||
// Independent of session/pane/term — subscribe once.
|
||||
useEffect(() => {
|
||||
setChatInputs(chatInputsRegistry.list());
|
||||
const unsubChats = chatInputsRegistry.subscribe(() => {
|
||||
setChatInputs(chatInputsRegistry.list());
|
||||
});
|
||||
return () => unsubChats();
|
||||
}, []);
|
||||
|
||||
function actCopy(): void {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const sel = term.getSelection();
|
||||
if (!sel) {
|
||||
setMenu(null);
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(sel).catch(() => toast.error('Clipboard write failed'));
|
||||
term.clearSelection();
|
||||
setMenu(null);
|
||||
}
|
||||
function actPaste(): void {
|
||||
const reg = terminalsRegistry.get(paneId);
|
||||
reg?.paste();
|
||||
setMenu(null);
|
||||
}
|
||||
function actSelectAll(): void {
|
||||
termRef.current?.selectAll();
|
||||
setMenu(null);
|
||||
}
|
||||
function actSearch(): void {
|
||||
setSearchOpen(true);
|
||||
setMenu(null);
|
||||
}
|
||||
function actSendToChat(chatId: string): void {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const sel = term.getSelection();
|
||||
if (!sel) {
|
||||
setMenu(null);
|
||||
return;
|
||||
}
|
||||
sendToChat.emit({ chat_id: chatId, text: sel });
|
||||
setMenu(null);
|
||||
}
|
||||
|
||||
const hasSelection = !!termRef.current?.getSelection();
|
||||
|
||||
return {
|
||||
menu,
|
||||
setMenu,
|
||||
searchOpen,
|
||||
setSearchOpen,
|
||||
chatInputs,
|
||||
hasSelection,
|
||||
actions: {
|
||||
copy: actCopy,
|
||||
paste: actPaste,
|
||||
selectAll: actSelectAll,
|
||||
search: actSearch,
|
||||
sendToChat: actSendToChat,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user