@wong2kim/wmux 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -0
- package/assets/icon.ico +0 -0
- package/assets/icon.svg +6 -0
- package/dist/cli/cli/client.js +102 -0
- package/dist/cli/cli/commands/browser.js +137 -0
- package/dist/cli/cli/commands/input.js +80 -0
- package/dist/cli/cli/commands/notify.js +28 -0
- package/dist/cli/cli/commands/pane.js +88 -0
- package/dist/cli/cli/commands/surface.js +98 -0
- package/dist/cli/cli/commands/system.js +98 -0
- package/dist/cli/cli/commands/workspace.js +117 -0
- package/dist/cli/cli/index.js +140 -0
- package/dist/cli/cli/utils.js +47 -0
- package/dist/cli/shared/constants.js +54 -0
- package/dist/cli/shared/rpc.js +33 -0
- package/dist/cli/shared/types.js +79 -0
- package/dist/mcp/mcp/index.js +60 -0
- package/dist/mcp/mcp/wmux-client.js +146 -0
- package/dist/mcp/shared/constants.js +54 -0
- package/dist/mcp/shared/rpc.js +33 -0
- package/dist/mcp/shared/types.js +79 -0
- package/forge.config.ts +61 -0
- package/index.html +12 -0
- package/package.json +84 -0
- package/postcss.config.js +6 -0
- package/src/cli/client.ts +76 -0
- package/src/cli/commands/browser.ts +128 -0
- package/src/cli/commands/input.ts +72 -0
- package/src/cli/commands/notify.ts +29 -0
- package/src/cli/commands/pane.ts +90 -0
- package/src/cli/commands/surface.ts +102 -0
- package/src/cli/commands/system.ts +95 -0
- package/src/cli/commands/workspace.ts +116 -0
- package/src/cli/index.ts +145 -0
- package/src/cli/utils.ts +44 -0
- package/src/main/index.ts +86 -0
- package/src/main/ipc/handlers/clipboard.handler.ts +20 -0
- package/src/main/ipc/handlers/metadata.handler.ts +56 -0
- package/src/main/ipc/handlers/pty.handler.ts +69 -0
- package/src/main/ipc/handlers/session.handler.ts +17 -0
- package/src/main/ipc/handlers/shell.handler.ts +11 -0
- package/src/main/ipc/registerHandlers.ts +31 -0
- package/src/main/mcp/McpRegistrar.ts +156 -0
- package/src/main/metadata/MetadataCollector.ts +58 -0
- package/src/main/notification/ToastManager.ts +32 -0
- package/src/main/pipe/PipeServer.ts +190 -0
- package/src/main/pipe/RpcRouter.ts +46 -0
- package/src/main/pipe/handlers/_bridge.ts +40 -0
- package/src/main/pipe/handlers/browser.rpc.ts +132 -0
- package/src/main/pipe/handlers/input.rpc.ts +120 -0
- package/src/main/pipe/handlers/meta.rpc.ts +59 -0
- package/src/main/pipe/handlers/notify.rpc.ts +53 -0
- package/src/main/pipe/handlers/pane.rpc.ts +39 -0
- package/src/main/pipe/handlers/surface.rpc.ts +43 -0
- package/src/main/pipe/handlers/system.rpc.ts +36 -0
- package/src/main/pipe/handlers/workspace.rpc.ts +52 -0
- package/src/main/pty/AgentDetector.ts +247 -0
- package/src/main/pty/OscParser.ts +81 -0
- package/src/main/pty/PTYBridge.ts +88 -0
- package/src/main/pty/PTYManager.ts +104 -0
- package/src/main/pty/ShellDetector.ts +63 -0
- package/src/main/session/SessionManager.ts +53 -0
- package/src/main/updater/AutoUpdater.ts +132 -0
- package/src/main/window/createWindow.ts +71 -0
- package/src/mcp/README.md +56 -0
- package/src/mcp/index.ts +153 -0
- package/src/mcp/wmux-client.ts +127 -0
- package/src/preload/index.ts +111 -0
- package/src/preload/preload.ts +108 -0
- package/src/renderer/App.tsx +5 -0
- package/src/renderer/components/Browser/BrowserPanel.tsx +219 -0
- package/src/renderer/components/Browser/BrowserToolbar.tsx +253 -0
- package/src/renderer/components/Company/ApprovalDialog.tsx +3 -0
- package/src/renderer/components/Company/CompanyView.tsx +7 -0
- package/src/renderer/components/Company/MessageFeedPanel.tsx +3 -0
- package/src/renderer/components/Layout/AppLayout.tsx +234 -0
- package/src/renderer/components/Notification/NotificationPanel.tsx +129 -0
- package/src/renderer/components/Palette/CommandPalette.tsx +409 -0
- package/src/renderer/components/Palette/PaletteItem.tsx +55 -0
- package/src/renderer/components/Pane/Pane.tsx +122 -0
- package/src/renderer/components/Pane/PaneContainer.tsx +41 -0
- package/src/renderer/components/Pane/SurfaceTabs.tsx +46 -0
- package/src/renderer/components/Settings/SettingsPanel.tsx +886 -0
- package/src/renderer/components/Sidebar/MiniSidebar.tsx +67 -0
- package/src/renderer/components/Sidebar/Sidebar.tsx +84 -0
- package/src/renderer/components/Sidebar/WorkspaceItem.tsx +241 -0
- package/src/renderer/components/StatusBar/StatusBar.tsx +93 -0
- package/src/renderer/components/Terminal/SearchBar.tsx +126 -0
- package/src/renderer/components/Terminal/Terminal.tsx +102 -0
- package/src/renderer/components/Terminal/ViCopyMode.tsx +104 -0
- package/src/renderer/hooks/useKeyboard.ts +310 -0
- package/src/renderer/hooks/useNotificationListener.ts +80 -0
- package/src/renderer/hooks/useNotificationSound.ts +75 -0
- package/src/renderer/hooks/useRpcBridge.ts +451 -0
- package/src/renderer/hooks/useT.ts +11 -0
- package/src/renderer/hooks/useTerminal.ts +349 -0
- package/src/renderer/hooks/useViCopyMode.ts +320 -0
- package/src/renderer/i18n/index.ts +69 -0
- package/src/renderer/i18n/locales/en.ts +157 -0
- package/src/renderer/i18n/locales/ja.ts +155 -0
- package/src/renderer/i18n/locales/ko.ts +155 -0
- package/src/renderer/i18n/locales/zh.ts +155 -0
- package/src/renderer/index.tsx +6 -0
- package/src/renderer/stores/index.ts +19 -0
- package/src/renderer/stores/slices/notificationSlice.ts +56 -0
- package/src/renderer/stores/slices/paneSlice.ts +141 -0
- package/src/renderer/stores/slices/surfaceSlice.ts +122 -0
- package/src/renderer/stores/slices/uiSlice.ts +247 -0
- package/src/renderer/stores/slices/workspaceSlice.ts +120 -0
- package/src/renderer/styles/globals.css +150 -0
- package/src/renderer/themes.ts +99 -0
- package/src/shared/constants.ts +53 -0
- package/src/shared/electron.d.ts +11 -0
- package/src/shared/rpc.ts +71 -0
- package/src/shared/types.ts +176 -0
- package/tailwind.config.js +11 -0
- package/tsconfig.cli.json +24 -0
- package/tsconfig.json +21 -0
- package/tsconfig.mcp.json +25 -0
- package/vite.main.config.ts +14 -0
- package/vite.preload.config.ts +9 -0
- package/vite.renderer.config.ts +6 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { useEffect, useRef, useCallback } from 'react';
|
|
2
|
+
import { Terminal } from '@xterm/xterm';
|
|
3
|
+
import { FitAddon } from '@xterm/addon-fit';
|
|
4
|
+
import { WebglAddon } from '@xterm/addon-webgl';
|
|
5
|
+
import { SearchAddon } from '@xterm/addon-search';
|
|
6
|
+
import { useStore } from '../stores';
|
|
7
|
+
import { t } from '../i18n';
|
|
8
|
+
import { XTERM_THEMES, type ThemeId } from '../themes';
|
|
9
|
+
|
|
10
|
+
// Lightweight copy feedback toast — injects/removes a DOM element
|
|
11
|
+
let copyToastTimer: ReturnType<typeof setTimeout> | null = null;
|
|
12
|
+
function showCopyToast() {
|
|
13
|
+
let el = document.getElementById('wmux-copy-toast');
|
|
14
|
+
if (!el) {
|
|
15
|
+
el = document.createElement('div');
|
|
16
|
+
el.id = 'wmux-copy-toast';
|
|
17
|
+
el.style.cssText = 'position:fixed;bottom:28px;left:50%;transform:translateX(-50%);background:#a6e3a1;color:#1e1e2e;font-family:monospace;font-size:11px;font-weight:600;padding:3px 12px;border-radius:4px;z-index:9999;pointer-events:none;opacity:0;transition:opacity 0.2s';
|
|
18
|
+
document.body.appendChild(el);
|
|
19
|
+
}
|
|
20
|
+
el.textContent = t('terminal.copied');
|
|
21
|
+
el.style.opacity = '1';
|
|
22
|
+
if (copyToastTimer) clearTimeout(copyToastTimer);
|
|
23
|
+
copyToastTimer = setTimeout(() => { el!.style.opacity = '0'; }, 1200);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface UseTerminalOptions {
|
|
27
|
+
ptyId: string | null;
|
|
28
|
+
/** Combined visibility flag: true only when the terminal's workspace AND surface tab are both active.
|
|
29
|
+
* When false the terminal DOM container may be hidden (display:none / zero-size). */
|
|
30
|
+
isVisible?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useTerminal(containerRef: React.RefObject<HTMLDivElement | null>, options: UseTerminalOptions) {
|
|
34
|
+
const terminalRef = useRef<Terminal | null>(null);
|
|
35
|
+
const fitAddonRef = useRef<FitAddon | null>(null);
|
|
36
|
+
const searchAddonRef = useRef<SearchAddon | null>(null);
|
|
37
|
+
const { ptyId, isVisible = true } = options;
|
|
38
|
+
const terminalFontSize = useStore((s) => s.terminalFontSize);
|
|
39
|
+
const terminalFontFamily = useStore((s) => s.terminalFontFamily);
|
|
40
|
+
const scrollbackLines = useStore((s) => s.scrollbackLines);
|
|
41
|
+
const theme = useStore((s) => s.theme) as ThemeId;
|
|
42
|
+
const xtermTheme = XTERM_THEMES[theme] ?? XTERM_THEMES.catppuccin;
|
|
43
|
+
|
|
44
|
+
const fit = useCallback(() => {
|
|
45
|
+
const container = containerRef.current;
|
|
46
|
+
if (!fitAddonRef.current || !terminalRef.current || !container) return;
|
|
47
|
+
// Guard: skip fit entirely when the container is hidden (zero dimensions).
|
|
48
|
+
// Calling fit() on a display:none element produces 0 cols/rows which
|
|
49
|
+
// corrupts the xterm buffer and causes the "infinite copy downward" bug.
|
|
50
|
+
if (container.offsetWidth === 0 || container.offsetHeight === 0) return;
|
|
51
|
+
try {
|
|
52
|
+
fitAddonRef.current.fit();
|
|
53
|
+
if (ptyId) {
|
|
54
|
+
const { cols, rows } = terminalRef.current;
|
|
55
|
+
// Never send 0-size resize to PTY — that corrupts the terminal buffer.
|
|
56
|
+
if (cols > 0 && rows > 0) {
|
|
57
|
+
window.electronAPI.pty.resize(ptyId, cols, rows);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// ignore fit errors during unmount
|
|
62
|
+
}
|
|
63
|
+
}, [ptyId, containerRef]);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const container = containerRef.current;
|
|
67
|
+
if (!container || !ptyId) return;
|
|
68
|
+
|
|
69
|
+
const terminal = new Terminal({
|
|
70
|
+
cursorBlink: true,
|
|
71
|
+
fontSize: terminalFontSize,
|
|
72
|
+
scrollback: scrollbackLines,
|
|
73
|
+
scrollOnUserInput: false,
|
|
74
|
+
fontFamily: `'${terminalFontFamily}', 'Consolas', 'Courier New', monospace`,
|
|
75
|
+
theme: xtermTheme,
|
|
76
|
+
allowProposedApi: true,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const fitAddon = new FitAddon();
|
|
80
|
+
const searchAddon = new SearchAddon();
|
|
81
|
+
terminal.loadAddon(fitAddon);
|
|
82
|
+
terminal.loadAddon(searchAddon);
|
|
83
|
+
terminal.open(container);
|
|
84
|
+
|
|
85
|
+
// Try WebGL, fall back to canvas
|
|
86
|
+
try {
|
|
87
|
+
const webglAddon = new WebglAddon();
|
|
88
|
+
webglAddon.onContextLoss(() => {
|
|
89
|
+
webglAddon.dispose();
|
|
90
|
+
});
|
|
91
|
+
terminal.loadAddon(webglAddon);
|
|
92
|
+
} catch {
|
|
93
|
+
console.warn('WebGL addon failed, using canvas renderer');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Only fit immediately if the container is actually visible (non-zero size).
|
|
97
|
+
// If the workspace starts hidden (display:none), skip the initial fit so we
|
|
98
|
+
// don't corrupt the terminal with 0 cols/rows. The visibility-watcher effect
|
|
99
|
+
// below will trigger a proper fit when the workspace is shown.
|
|
100
|
+
if (container.offsetWidth > 0 && container.offsetHeight > 0) {
|
|
101
|
+
fitAddon.fit();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Clipboard + shortcut handling
|
|
105
|
+
terminal.attachCustomKeyEventHandler((e) => {
|
|
106
|
+
if (e.type !== 'keydown') return true;
|
|
107
|
+
|
|
108
|
+
// Pass app shortcuts through to useKeyboard (don't let xterm consume them)
|
|
109
|
+
if (e.ctrlKey && !e.shiftKey && [',', 'b', 'k', 'i', 'n', 't'].includes(e.key)) {
|
|
110
|
+
return false; // let DOM bubble to useKeyboard
|
|
111
|
+
}
|
|
112
|
+
if (e.ctrlKey && e.shiftKey) {
|
|
113
|
+
return false; // all Ctrl+Shift combos → app shortcuts
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Custom keybindings: let function keys and matched combos pass through to useKeyboard
|
|
117
|
+
const { customKeybindings } = useStore.getState();
|
|
118
|
+
if (customKeybindings.length > 0) {
|
|
119
|
+
const parts: string[] = [];
|
|
120
|
+
if (e.ctrlKey) parts.push('Ctrl');
|
|
121
|
+
if (e.shiftKey) parts.push('Shift');
|
|
122
|
+
if (e.altKey) parts.push('Alt');
|
|
123
|
+
let k = e.key;
|
|
124
|
+
if (k.length === 1) k = k.toUpperCase();
|
|
125
|
+
parts.push(k);
|
|
126
|
+
const combo = parts.join('+');
|
|
127
|
+
if (customKeybindings.some((kb) => kb.key === combo)) {
|
|
128
|
+
return false; // let useKeyboard handle it
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Ctrl+C: copy if selection exists, otherwise send SIGINT
|
|
133
|
+
if (e.ctrlKey && !e.shiftKey && e.key === 'c') {
|
|
134
|
+
const sel = terminal.getSelection();
|
|
135
|
+
if (sel) {
|
|
136
|
+
void window.clipboardAPI.writeText(sel);
|
|
137
|
+
terminal.clearSelection();
|
|
138
|
+
showCopyToast();
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return true; // no selection → SIGINT
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Ctrl+V: paste from clipboard (use our IPC clipboard, block event
|
|
145
|
+
// so xterm doesn't also paste via browser's native paste event)
|
|
146
|
+
if (e.ctrlKey && !e.shiftKey && e.key === 'v') {
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
void window.clipboardAPI.readText().then((text) => {
|
|
149
|
+
if (text) window.electronAPI.pty.write(ptyId, text);
|
|
150
|
+
}).catch(() => {});
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Ctrl+Shift+C: copy fallback
|
|
155
|
+
if (e.ctrlKey && e.shiftKey && e.key === 'C') {
|
|
156
|
+
const sel = terminal.getSelection();
|
|
157
|
+
if (sel) {
|
|
158
|
+
void window.clipboardAPI.writeText(sel);
|
|
159
|
+
showCopyToast();
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
// Ctrl+Shift+V: paste fallback
|
|
164
|
+
if (e.ctrlKey && e.shiftKey && e.key === 'V') {
|
|
165
|
+
e.preventDefault();
|
|
166
|
+
void window.clipboardAPI.readText().then((text) => {
|
|
167
|
+
if (text) window.electronAPI.pty.write(ptyId, text);
|
|
168
|
+
}).catch(() => {});
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return true;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Right-click: always paste
|
|
176
|
+
terminal.element?.addEventListener('contextmenu', (e) => {
|
|
177
|
+
e.preventDefault();
|
|
178
|
+
void window.clipboardAPI.readText().then((text) => {
|
|
179
|
+
console.log('[wmux:clipboard] right-click paste len=', text?.length ?? 0);
|
|
180
|
+
if (text) window.electronAPI.pty.write(ptyId, text);
|
|
181
|
+
}).catch((err) => console.error('[wmux:clipboard] right-click error:', err));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Drag-and-drop: paste file paths into terminal
|
|
185
|
+
// Use the xterm element + capture phase — xterm's internal canvas blocks
|
|
186
|
+
// normal event propagation on the container div.
|
|
187
|
+
const xtermEl = terminal.element;
|
|
188
|
+
const handleDragOver = (e: Event) => {
|
|
189
|
+
e.preventDefault();
|
|
190
|
+
e.stopPropagation();
|
|
191
|
+
const de = e as DragEvent;
|
|
192
|
+
if (de.dataTransfer) de.dataTransfer.dropEffect = 'copy';
|
|
193
|
+
};
|
|
194
|
+
const handleDrop = (e: Event) => {
|
|
195
|
+
e.preventDefault();
|
|
196
|
+
e.stopPropagation();
|
|
197
|
+
const de = e as DragEvent;
|
|
198
|
+
const files = de.dataTransfer?.files;
|
|
199
|
+
if (!files || files.length === 0) return;
|
|
200
|
+
const paths: string[] = [];
|
|
201
|
+
for (let i = 0; i < files.length; i++) {
|
|
202
|
+
paths.push((files[i] as File & { path: string }).path);
|
|
203
|
+
}
|
|
204
|
+
const text = paths.map((p) => (p.includes(' ') ? `"${p}"` : p)).join(' ');
|
|
205
|
+
window.electronAPI.pty.write(ptyId, text);
|
|
206
|
+
};
|
|
207
|
+
if (xtermEl) {
|
|
208
|
+
xtermEl.addEventListener('dragenter', handleDragOver, true);
|
|
209
|
+
xtermEl.addEventListener('dragover', handleDragOver, true);
|
|
210
|
+
xtermEl.addEventListener('drop', handleDrop, true);
|
|
211
|
+
}
|
|
212
|
+
// Also on container as fallback
|
|
213
|
+
container.addEventListener('dragenter', handleDragOver, true);
|
|
214
|
+
container.addEventListener('dragover', handleDragOver, true);
|
|
215
|
+
container.addEventListener('drop', handleDrop, true);
|
|
216
|
+
|
|
217
|
+
// Forward user input to PTY
|
|
218
|
+
terminal.onData((data) => {
|
|
219
|
+
window.electronAPI.pty.write(ptyId, data);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Receive PTY output
|
|
223
|
+
const removeDataListener = window.electronAPI.pty.onData((id, data) => {
|
|
224
|
+
if (id === ptyId) {
|
|
225
|
+
terminal.write(data);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Handle PTY exit
|
|
230
|
+
const removeExitListener = window.electronAPI.pty.onExit((id, exitCode) => {
|
|
231
|
+
if (id === ptyId) {
|
|
232
|
+
terminal.writeln(`\r\n${t('terminal.exitedBracket', { code: exitCode })}`);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Resize PTY on initial fit — only when we actually have valid dimensions.
|
|
237
|
+
const { cols, rows } = terminal;
|
|
238
|
+
if (cols > 0 && rows > 0) {
|
|
239
|
+
window.electronAPI.pty.resize(ptyId, cols, rows);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
terminalRef.current = terminal;
|
|
243
|
+
fitAddonRef.current = fitAddon;
|
|
244
|
+
searchAddonRef.current = searchAddon;
|
|
245
|
+
|
|
246
|
+
// ResizeObserver for auto-fit — preserves user scroll position across resize.
|
|
247
|
+
// IMPORTANT: skip when the container has zero dimensions (display:none workspace).
|
|
248
|
+
// Fitting a hidden terminal produces 0 cols/rows, which corrupts the PTY buffer
|
|
249
|
+
// and manifests as "infinite content duplication" when switching back to it.
|
|
250
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
251
|
+
requestAnimationFrame(() => {
|
|
252
|
+
try {
|
|
253
|
+
const term = terminalRef.current;
|
|
254
|
+
if (!term) return;
|
|
255
|
+
|
|
256
|
+
// Skip entirely if container is hidden/zero-size
|
|
257
|
+
if (container.offsetWidth === 0 || container.offsetHeight === 0) return;
|
|
258
|
+
|
|
259
|
+
// Snapshot scroll state before fit() reflows the buffer
|
|
260
|
+
const prevYBase = term.buffer.active.baseY;
|
|
261
|
+
const prevYDisp = term.buffer.active.viewportY;
|
|
262
|
+
const wasScrolledUp = prevYDisp < prevYBase;
|
|
263
|
+
// How many lines from the bottom was the user?
|
|
264
|
+
const distFromBottom = prevYBase - prevYDisp;
|
|
265
|
+
|
|
266
|
+
fitAddon.fit();
|
|
267
|
+
|
|
268
|
+
// Restore scroll position if user was not at the bottom
|
|
269
|
+
if (wasScrolledUp) {
|
|
270
|
+
const newYBase = term.buffer.active.baseY;
|
|
271
|
+
const targetYDisp = Math.max(0, newYBase - distFromBottom);
|
|
272
|
+
term.scrollToLine(targetYDisp);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const { cols, rows } = term;
|
|
276
|
+
// Never send 0-size resize to PTY
|
|
277
|
+
if (cols > 0 && rows > 0) {
|
|
278
|
+
window.electronAPI.pty.resize(ptyId, cols, rows);
|
|
279
|
+
}
|
|
280
|
+
} catch {
|
|
281
|
+
// ignore fit errors during unmount
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
resizeObserver.observe(container);
|
|
286
|
+
|
|
287
|
+
return () => {
|
|
288
|
+
if (xtermEl) {
|
|
289
|
+
xtermEl.removeEventListener('dragenter', handleDragOver, true);
|
|
290
|
+
xtermEl.removeEventListener('dragover', handleDragOver, true);
|
|
291
|
+
xtermEl.removeEventListener('drop', handleDrop, true);
|
|
292
|
+
}
|
|
293
|
+
container.removeEventListener('dragenter', handleDragOver, true);
|
|
294
|
+
container.removeEventListener('dragover', handleDragOver, true);
|
|
295
|
+
container.removeEventListener('drop', handleDrop, true);
|
|
296
|
+
resizeObserver.disconnect();
|
|
297
|
+
removeDataListener();
|
|
298
|
+
removeExitListener();
|
|
299
|
+
terminal.dispose();
|
|
300
|
+
terminalRef.current = null;
|
|
301
|
+
fitAddonRef.current = null;
|
|
302
|
+
searchAddonRef.current = null;
|
|
303
|
+
};
|
|
304
|
+
}, [ptyId, containerRef, terminalFontSize, terminalFontFamily, scrollbackLines, xtermTheme]);
|
|
305
|
+
|
|
306
|
+
// Re-fit when the terminal becomes visible (workspace switch or surface tab switch).
|
|
307
|
+
// Without this, a terminal that was initialized while hidden (0-size) will display
|
|
308
|
+
// at the wrong size until the next manual resize.
|
|
309
|
+
useEffect(() => {
|
|
310
|
+
if (!isVisible) return;
|
|
311
|
+
// Defer slightly to allow the CSS display change to take effect before measuring
|
|
312
|
+
const id = requestAnimationFrame(() => {
|
|
313
|
+
fit();
|
|
314
|
+
});
|
|
315
|
+
return () => cancelAnimationFrame(id);
|
|
316
|
+
}, [isVisible, fit]);
|
|
317
|
+
|
|
318
|
+
const findNext = useCallback((text: string) => {
|
|
319
|
+
searchAddonRef.current?.findNext(text, {
|
|
320
|
+
decorations: {
|
|
321
|
+
matchBackground: '#f9e2af40',
|
|
322
|
+
matchBorder: '#f9e2af',
|
|
323
|
+
matchOverviewRuler: '#f9e2af',
|
|
324
|
+
activeMatchBackground: '#f9e2af80',
|
|
325
|
+
activeMatchBorder: '#f9e2af',
|
|
326
|
+
activeMatchColorOverviewRuler: '#f9e2af',
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
}, []);
|
|
330
|
+
|
|
331
|
+
const findPrevious = useCallback((text: string) => {
|
|
332
|
+
searchAddonRef.current?.findPrevious(text, {
|
|
333
|
+
decorations: {
|
|
334
|
+
matchBackground: '#f9e2af40',
|
|
335
|
+
matchBorder: '#f9e2af',
|
|
336
|
+
matchOverviewRuler: '#f9e2af',
|
|
337
|
+
activeMatchBackground: '#f9e2af80',
|
|
338
|
+
activeMatchBorder: '#f9e2af',
|
|
339
|
+
activeMatchColorOverviewRuler: '#f9e2af',
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
}, []);
|
|
343
|
+
|
|
344
|
+
const clearSearch = useCallback(() => {
|
|
345
|
+
searchAddonRef.current?.clearDecorations();
|
|
346
|
+
}, []);
|
|
347
|
+
|
|
348
|
+
return { terminal: terminalRef, fit, searchAddonRef, findNext, findPrevious, clearSearch };
|
|
349
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
2
|
+
import type { Terminal } from '@xterm/xterm';
|
|
3
|
+
|
|
4
|
+
export interface CursorPosition {
|
|
5
|
+
row: number;
|
|
6
|
+
col: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface UseViCopyModeOptions {
|
|
10
|
+
terminal: Terminal | null;
|
|
11
|
+
isActive: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface UseViCopyModeReturn {
|
|
15
|
+
cursorRow: number;
|
|
16
|
+
cursorCol: number;
|
|
17
|
+
selectionStart: CursorPosition | null;
|
|
18
|
+
selectionEnd: CursorPosition | null;
|
|
19
|
+
isVisual: boolean;
|
|
20
|
+
handleKey: (key: string, ctrlKey?: boolean) => void;
|
|
21
|
+
exit: () => void;
|
|
22
|
+
copySelection: () => Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Buffer helpers
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
function getLineText(terminal: Terminal, row: number): string {
|
|
30
|
+
const line = terminal.buffer.active.getLine(row);
|
|
31
|
+
if (!line) return '';
|
|
32
|
+
return line.translateToString(true);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function totalRows(terminal: Terminal): number {
|
|
36
|
+
return terminal.buffer.active.length;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function clampRow(row: number, terminal: Terminal): number {
|
|
40
|
+
return Math.max(0, Math.min(totalRows(terminal) - 1, row));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function clampCol(col: number, text: string): number {
|
|
44
|
+
const max = Math.max(0, text.length - 1);
|
|
45
|
+
return Math.max(0, Math.min(max, col));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function nextWordStart(text: string, col: number): number {
|
|
49
|
+
let i = col + 1;
|
|
50
|
+
while (i < text.length && text[i] !== ' ') i++;
|
|
51
|
+
while (i < text.length && text[i] === ' ') i++;
|
|
52
|
+
return Math.max(0, Math.min(i, text.length - 1));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function prevWordStart(text: string, col: number): number {
|
|
56
|
+
let i = col - 1;
|
|
57
|
+
while (i > 0 && text[i] === ' ') i--;
|
|
58
|
+
while (i > 0 && text[i - 1] !== ' ') i--;
|
|
59
|
+
return Math.max(0, i);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function linearOffset(terminal: Terminal, row: number, col: number): number {
|
|
63
|
+
let off = 0;
|
|
64
|
+
for (let r = 0; r < row; r++) off += getLineText(terminal, r).length + 1;
|
|
65
|
+
return off + col;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeRange(
|
|
69
|
+
terminal: Terminal,
|
|
70
|
+
a: CursorPosition,
|
|
71
|
+
b: CursorPosition,
|
|
72
|
+
): [CursorPosition, CursorPosition] {
|
|
73
|
+
if (linearOffset(terminal, a.row, a.col) <= linearOffset(terminal, b.row, b.col)) {
|
|
74
|
+
return [a, b];
|
|
75
|
+
}
|
|
76
|
+
return [b, a];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildSelectionText(
|
|
80
|
+
terminal: Terminal,
|
|
81
|
+
start: CursorPosition,
|
|
82
|
+
end: CursorPosition,
|
|
83
|
+
): string {
|
|
84
|
+
const [s, e] = normalizeRange(terminal, start, end);
|
|
85
|
+
const lines: string[] = [];
|
|
86
|
+
for (let r = s.row; r <= e.row; r++) {
|
|
87
|
+
const text = getLineText(terminal, r);
|
|
88
|
+
if (r === s.row && r === e.row) {
|
|
89
|
+
lines.push(text.slice(s.col, e.col + 1));
|
|
90
|
+
} else if (r === s.row) {
|
|
91
|
+
lines.push(text.slice(s.col));
|
|
92
|
+
} else if (r === e.row) {
|
|
93
|
+
lines.push(text.slice(0, e.col + 1));
|
|
94
|
+
} else {
|
|
95
|
+
lines.push(text);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return lines.join('\n');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function applyXtermSelection(
|
|
102
|
+
terminal: Terminal,
|
|
103
|
+
start: CursorPosition,
|
|
104
|
+
end: CursorPosition,
|
|
105
|
+
): void {
|
|
106
|
+
const [s, e] = normalizeRange(terminal, start, end);
|
|
107
|
+
if (s.row === e.row) {
|
|
108
|
+
terminal.select(s.col, s.row, e.col - s.col + 1);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const startText = getLineText(terminal, s.row);
|
|
112
|
+
let length = startText.length - s.col + 1; // +1 for implicit newline
|
|
113
|
+
for (let r = s.row + 1; r < e.row; r++) {
|
|
114
|
+
length += getLineText(terminal, r).length + 1;
|
|
115
|
+
}
|
|
116
|
+
length += e.col + 1;
|
|
117
|
+
terminal.select(s.col, s.row, length);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Hook
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
export function useViCopyMode(
|
|
125
|
+
options: UseViCopyModeOptions,
|
|
126
|
+
onExit: () => void,
|
|
127
|
+
): UseViCopyModeReturn {
|
|
128
|
+
const { terminal, isActive } = options;
|
|
129
|
+
|
|
130
|
+
const [cursor, setCursor] = useState<CursorPosition>({ row: 0, col: 0 });
|
|
131
|
+
const [isVisual, setIsVisual] = useState(false);
|
|
132
|
+
const [visualStart, setVisualStart] = useState<CursorPosition | null>(null);
|
|
133
|
+
|
|
134
|
+
// Pending 'g' for 'gg' sequence
|
|
135
|
+
const ggPending = useRef(false);
|
|
136
|
+
|
|
137
|
+
// Stable refs so callbacks don't stale-close over state
|
|
138
|
+
const cursorRef = useRef(cursor);
|
|
139
|
+
cursorRef.current = cursor;
|
|
140
|
+
const isVisualRef = useRef(isVisual);
|
|
141
|
+
isVisualRef.current = isVisual;
|
|
142
|
+
const visualStartRef = useRef(visualStart);
|
|
143
|
+
visualStartRef.current = visualStart;
|
|
144
|
+
|
|
145
|
+
// Initialize cursor when copy mode becomes active
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (!isActive || !terminal) return;
|
|
148
|
+
const buf = terminal.buffer.active;
|
|
149
|
+
const row = clampRow(buf.viewportY + buf.cursorY, terminal);
|
|
150
|
+
const col = Math.max(0, buf.cursorX);
|
|
151
|
+
setCursor({ row, col });
|
|
152
|
+
setIsVisual(false);
|
|
153
|
+
setVisualStart(null);
|
|
154
|
+
ggPending.current = false;
|
|
155
|
+
terminal.clearSelection();
|
|
156
|
+
}, [isActive, terminal]);
|
|
157
|
+
|
|
158
|
+
const exit = useCallback(() => {
|
|
159
|
+
if (terminal) terminal.clearSelection();
|
|
160
|
+
setIsVisual(false);
|
|
161
|
+
setVisualStart(null);
|
|
162
|
+
ggPending.current = false;
|
|
163
|
+
onExit();
|
|
164
|
+
}, [terminal, onExit]);
|
|
165
|
+
|
|
166
|
+
const copySelection = useCallback(async () => {
|
|
167
|
+
if (!terminal) return;
|
|
168
|
+
const text = terminal.getSelection() || getLineText(terminal, cursorRef.current.row);
|
|
169
|
+
if (text) await navigator.clipboard.writeText(text);
|
|
170
|
+
exit();
|
|
171
|
+
}, [terminal, exit]);
|
|
172
|
+
|
|
173
|
+
const handleKey = useCallback(
|
|
174
|
+
(key: string, ctrlKey = false) => {
|
|
175
|
+
if (!terminal || !isActive) return;
|
|
176
|
+
|
|
177
|
+
const viewportRows = terminal.rows;
|
|
178
|
+
|
|
179
|
+
// ESC / q always exit
|
|
180
|
+
if (!ctrlKey && (key === 'Escape' || key === 'q')) {
|
|
181
|
+
exit();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// y: yank then exit
|
|
186
|
+
if (!ctrlKey && key === 'y') {
|
|
187
|
+
const cur = cursorRef.current;
|
|
188
|
+
const vs = visualStartRef.current;
|
|
189
|
+
const iv = isVisualRef.current;
|
|
190
|
+
let text: string;
|
|
191
|
+
if (iv && vs) {
|
|
192
|
+
text = buildSelectionText(terminal, vs, cur);
|
|
193
|
+
} else {
|
|
194
|
+
text = getLineText(terminal, cur.row);
|
|
195
|
+
}
|
|
196
|
+
navigator.clipboard.writeText(text).then(() => exit());
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// v: toggle visual mode
|
|
201
|
+
if (!ctrlKey && key === 'v') {
|
|
202
|
+
if (isVisualRef.current) {
|
|
203
|
+
terminal.clearSelection();
|
|
204
|
+
setIsVisual(false);
|
|
205
|
+
setVisualStart(null);
|
|
206
|
+
} else {
|
|
207
|
+
setIsVisual(true);
|
|
208
|
+
setVisualStart({ ...cursorRef.current });
|
|
209
|
+
}
|
|
210
|
+
ggPending.current = false;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
setCursor((prev) => {
|
|
215
|
+
let { row, col } = prev;
|
|
216
|
+
|
|
217
|
+
if (ctrlKey) {
|
|
218
|
+
if (key === 'u') row = clampRow(row - Math.floor(viewportRows / 2), terminal);
|
|
219
|
+
else if (key === 'd') row = clampRow(row + Math.floor(viewportRows / 2), terminal);
|
|
220
|
+
else return prev;
|
|
221
|
+
|
|
222
|
+
const lt = getLineText(terminal, row);
|
|
223
|
+
col = clampCol(col, lt);
|
|
224
|
+
terminal.scrollToLine(row);
|
|
225
|
+
const next = { row, col };
|
|
226
|
+
if (isVisualRef.current && visualStartRef.current) {
|
|
227
|
+
applyXtermSelection(terminal, visualStartRef.current, next);
|
|
228
|
+
}
|
|
229
|
+
ggPending.current = false;
|
|
230
|
+
return next;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const lineText = getLineText(terminal, row);
|
|
234
|
+
|
|
235
|
+
switch (key) {
|
|
236
|
+
case 'h': {
|
|
237
|
+
col = Math.max(0, col - 1);
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
case 'l': {
|
|
241
|
+
col = Math.min(Math.max(0, lineText.length - 1), col + 1);
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
case 'j': {
|
|
245
|
+
row = clampRow(row + 1, terminal);
|
|
246
|
+
col = clampCol(col, getLineText(terminal, row));
|
|
247
|
+
terminal.scrollToLine(row);
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
case 'k': {
|
|
251
|
+
row = clampRow(row - 1, terminal);
|
|
252
|
+
col = clampCol(col, getLineText(terminal, row));
|
|
253
|
+
terminal.scrollToLine(row);
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
case 'w': {
|
|
257
|
+
col = nextWordStart(lineText, col);
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
case 'b': {
|
|
261
|
+
col = prevWordStart(lineText, col);
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
case '0': {
|
|
265
|
+
col = 0;
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
case '$': {
|
|
269
|
+
col = Math.max(0, lineText.length - 1);
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
case 'g': {
|
|
273
|
+
if (ggPending.current) {
|
|
274
|
+
// gg: go to buffer start
|
|
275
|
+
row = 0;
|
|
276
|
+
col = 0;
|
|
277
|
+
terminal.scrollToLine(0);
|
|
278
|
+
ggPending.current = false;
|
|
279
|
+
} else {
|
|
280
|
+
ggPending.current = true;
|
|
281
|
+
return prev; // wait for second g
|
|
282
|
+
}
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
case 'G': {
|
|
286
|
+
row = clampRow(totalRows(terminal) - 1, terminal);
|
|
287
|
+
col = Math.max(0, getLineText(terminal, row).length - 1);
|
|
288
|
+
terminal.scrollToLine(row);
|
|
289
|
+
ggPending.current = false;
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
default: {
|
|
293
|
+
if (key !== 'g') ggPending.current = false;
|
|
294
|
+
return prev;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (key !== 'g') ggPending.current = false;
|
|
299
|
+
|
|
300
|
+
const next = { row, col };
|
|
301
|
+
if (isVisualRef.current && visualStartRef.current) {
|
|
302
|
+
applyXtermSelection(terminal, visualStartRef.current, next);
|
|
303
|
+
}
|
|
304
|
+
return next;
|
|
305
|
+
});
|
|
306
|
+
},
|
|
307
|
+
[terminal, isActive, exit],
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
cursorRow: cursor.row,
|
|
312
|
+
cursorCol: cursor.col,
|
|
313
|
+
selectionStart: isVisual ? visualStart : null,
|
|
314
|
+
selectionEnd: isVisual ? cursor : null,
|
|
315
|
+
isVisual,
|
|
316
|
+
handleKey,
|
|
317
|
+
exit,
|
|
318
|
+
copySelection,
|
|
319
|
+
};
|
|
320
|
+
}
|