hop2 0.8.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/.hop-tmux.conf +18 -0
- package/INTEGRATION.md +92 -0
- package/README.md +260 -0
- package/hay/CHANGELOG.md +46 -0
- package/hay/DEV_NOTES.md +39 -0
- package/hay/README.md +290 -0
- package/hay/apps/cli/package.json +25 -0
- package/hay/apps/cli/src/index.ts +1250 -0
- package/hay/apps/cli/tsconfig.json +8 -0
- package/hay/apps/server/package.json +37 -0
- package/hay/apps/server/src/index.ts +103 -0
- package/hay/apps/server/src/lib.ts +161 -0
- package/hay/apps/server/src/pty.ts +68 -0
- package/hay/apps/server/src/rooms.ts +421 -0
- package/hay/apps/server/src/utils.ts +20 -0
- package/hay/apps/server/test/rooms.test.ts +146 -0
- package/hay/apps/server/tsconfig.json +9 -0
- package/hay/apps/web/index.html +12 -0
- package/hay/apps/web/package.json +32 -0
- package/hay/apps/web/scripts/run-e2e.mjs +130 -0
- package/hay/apps/web/src/App.tsx +1302 -0
- package/hay/apps/web/src/components/MobileKeyboard.tsx +701 -0
- package/hay/apps/web/src/main.tsx +13 -0
- package/hay/apps/web/src/setupTests.ts +1 -0
- package/hay/apps/web/src/styles.css +1198 -0
- package/hay/apps/web/src/utils/optimisticEcho.ts +115 -0
- package/hay/apps/web/src/utils/presence.ts +24 -0
- package/hay/apps/web/src/vite-env.d.ts +10 -0
- package/hay/apps/web/test/optimisticEcho.test.ts +39 -0
- package/hay/apps/web/test/presence.test.ts +18 -0
- package/hay/apps/web/tsconfig.json +8 -0
- package/hay/apps/web/vite.config.ts +13 -0
- package/hay/apps/web/vitest.config.ts +12 -0
- package/hay/package-lock.json +5988 -0
- package/hay/package.json +18 -0
- package/hay/packages/shared/package.json +27 -0
- package/hay/packages/shared/src/colors.ts +16 -0
- package/hay/packages/shared/src/index.ts +2 -0
- package/hay/packages/shared/src/protocol.ts +90 -0
- package/hay/packages/shared/test/protocol.test.ts +33 -0
- package/hay/packages/shared/tsconfig.json +7 -0
- package/hay/tsconfig.base.json +15 -0
- package/hay-web/assets/index-CTTzApRp.css +32 -0
- package/hay-web/assets/index-FDfdolCs.js +60 -0
- package/hay-web/assets/index-FDfdolCs.js.map +1 -0
- package/hay-web/index.html +13 -0
- package/hop +3717 -0
- package/package.json +52 -0
- package/sessions.html +243 -0
- package/tunnel-shell.js +254 -0
|
@@ -0,0 +1,1302 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, useCallback, type FormEvent } from "react";
|
|
2
|
+
import { FitAddon } from "@xterm/addon-fit";
|
|
3
|
+
import { Terminal } from "xterm";
|
|
4
|
+
import "xterm/css/xterm.css";
|
|
5
|
+
import {
|
|
6
|
+
safeParseServerMessage,
|
|
7
|
+
type PresenceClient,
|
|
8
|
+
type ClientMessage
|
|
9
|
+
} from "hay-shared";
|
|
10
|
+
import { activityLabel, sortPresence } from "./utils/presence";
|
|
11
|
+
import { createOptimisticEcho } from "./utils/optimisticEcho";
|
|
12
|
+
import { MobileKeyboard } from "./components/MobileKeyboard";
|
|
13
|
+
|
|
14
|
+
const createRoomId = () => `room-${Math.random().toString(36).slice(2, 7)}`;
|
|
15
|
+
|
|
16
|
+
const resolveWsUrl = () => {
|
|
17
|
+
// Check for hop session config (when embedded in hop)
|
|
18
|
+
const hopSession = (window as unknown as { __HOP_SESSION__?: { wsUrl?: string } }).__HOP_SESSION__;
|
|
19
|
+
if (hopSession?.wsUrl) {
|
|
20
|
+
const { protocol, host } = window.location;
|
|
21
|
+
const wsProtocol = protocol === "https:" ? "wss" : "ws";
|
|
22
|
+
return `${wsProtocol}://${host}${hopSession.wsUrl}`;
|
|
23
|
+
}
|
|
24
|
+
if (import.meta.env.VITE_WS_URL) {
|
|
25
|
+
return import.meta.env.VITE_WS_URL as string;
|
|
26
|
+
}
|
|
27
|
+
const { protocol, hostname, host } = window.location;
|
|
28
|
+
const wsProtocol = protocol === "https:" ? "wss" : "ws";
|
|
29
|
+
if (import.meta.env.DEV) {
|
|
30
|
+
return `${wsProtocol}://${hostname}:4001/ws`;
|
|
31
|
+
}
|
|
32
|
+
return `${wsProtocol}://${host}/ws`;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const formatStatus = (client: PresenceClient) => {
|
|
36
|
+
const state = activityLabel(client);
|
|
37
|
+
if (state === "typing") {
|
|
38
|
+
return "typing";
|
|
39
|
+
}
|
|
40
|
+
if (state === "active") {
|
|
41
|
+
return "active";
|
|
42
|
+
}
|
|
43
|
+
return "idle";
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const createShareLink = (room: string) => {
|
|
47
|
+
const url = new URL(window.location.href);
|
|
48
|
+
url.searchParams.set("room", room);
|
|
49
|
+
url.searchParams.delete("name");
|
|
50
|
+
return url.toString();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const terminalTheme = {
|
|
54
|
+
background: "#0b1117",
|
|
55
|
+
foreground: "#e6edf3",
|
|
56
|
+
cursor: "#f97316",
|
|
57
|
+
selection: "#1f2937",
|
|
58
|
+
black: "#0b1117",
|
|
59
|
+
red: "#ef4444",
|
|
60
|
+
green: "#22c55e",
|
|
61
|
+
yellow: "#f59e0b",
|
|
62
|
+
blue: "#3b82f6",
|
|
63
|
+
magenta: "#d946ef",
|
|
64
|
+
cyan: "#14b8a6",
|
|
65
|
+
white: "#e5e7eb"
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const isMobileDevice = () => {
|
|
69
|
+
if (typeof window === "undefined") return false;
|
|
70
|
+
const ua = navigator.userAgent || "";
|
|
71
|
+
return /iPhone|iPad|iPod|Android/i.test(ua) || window.innerWidth < 768;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
type ConnectionStatus = "idle" | "connecting" | "connected" | "disconnected";
|
|
75
|
+
|
|
76
|
+
type SessionInfo = {
|
|
77
|
+
name: string;
|
|
78
|
+
displayName: string;
|
|
79
|
+
active: boolean;
|
|
80
|
+
starting: boolean;
|
|
81
|
+
type?: "terminal" | "port";
|
|
82
|
+
port?: number;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Check if embedded in Hop
|
|
86
|
+
const getHopSession = () => (window as unknown as { __HOP_SESSION__?: { room?: string; wsUrl?: string } }).__HOP_SESSION__;
|
|
87
|
+
const isEmbeddedInHop = () => !!getHopSession()?.room;
|
|
88
|
+
|
|
89
|
+
const App = () => {
|
|
90
|
+
const params = new URLSearchParams(window.location.search);
|
|
91
|
+
const hopSession = getHopSession();
|
|
92
|
+
|
|
93
|
+
const [name, setName] = useState(() => {
|
|
94
|
+
return params.get("name") ?? localStorage.getItem("hay_name") ?? "User";
|
|
95
|
+
});
|
|
96
|
+
const [room, setRoom] = useState(() => {
|
|
97
|
+
if (hopSession?.room) return hopSession.room;
|
|
98
|
+
return params.get("room") ?? createRoomId();
|
|
99
|
+
});
|
|
100
|
+
// Auto-start session when embedded in Hop (skip join page)
|
|
101
|
+
const [session, setSession] = useState<{ name: string; room: string } | null>(() => {
|
|
102
|
+
if (hopSession?.room) {
|
|
103
|
+
const userName = params.get("name") ?? localStorage.getItem("hay_name") ?? "User";
|
|
104
|
+
return { name: userName, room: hopSession.room };
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
});
|
|
108
|
+
const [status, setStatus] = useState<ConnectionStatus>("idle");
|
|
109
|
+
const [presence, setPresence] = useState<PresenceClient[]>([]);
|
|
110
|
+
const [collabMode, setCollabMode] = useState(true);
|
|
111
|
+
const [controllerId, setControllerId] = useState<string | null>(null);
|
|
112
|
+
const [clientId, setClientId] = useState<string | null>(null);
|
|
113
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
114
|
+
const [latencyComp, setLatencyComp] = useState(true);
|
|
115
|
+
const [autoFitOnType, setAutoFitOnType] = useState(true);
|
|
116
|
+
const [terminalReady, setTerminalReady] = useState(false);
|
|
117
|
+
const [reconnectToken, setReconnectToken] = useState(0);
|
|
118
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
119
|
+
const [fabPosition, setFabPosition] = useState({ x: 20, y: window.innerHeight - 80 });
|
|
120
|
+
const fabDragRef = useRef<{ dragging: boolean; startX: number; startY: number; startPosX: number; startPosY: number } | null>(null);
|
|
121
|
+
const [isMobile] = useState(() => isMobileDevice());
|
|
122
|
+
const [keyboardVisible, setKeyboardVisible] = useState(() => isMobileDevice());
|
|
123
|
+
const [selectionMode, setSelectionMode] = useState(() => {
|
|
124
|
+
const saved = localStorage.getItem("hay_selection_mode");
|
|
125
|
+
return saved === "true";
|
|
126
|
+
});
|
|
127
|
+
const [hapticsEnabled, setHapticsEnabled] = useState(() => {
|
|
128
|
+
const saved = localStorage.getItem("hay_haptics_enabled");
|
|
129
|
+
return saved !== "false";
|
|
130
|
+
});
|
|
131
|
+
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
|
132
|
+
const [loadingSessions, setLoadingSessions] = useState(false);
|
|
133
|
+
const [fontSize, setFontSize] = useState(() => {
|
|
134
|
+
const saved = localStorage.getItem("hay_font_size");
|
|
135
|
+
return saved ? parseInt(saved, 10) : 14;
|
|
136
|
+
});
|
|
137
|
+
const [viewMode, setViewMode] = useState<"fit" | "full">(() => {
|
|
138
|
+
const saved = localStorage.getItem("hay_view_mode");
|
|
139
|
+
return saved === "fit" ? "fit" : "full"; // Default to full
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
143
|
+
const termRef = useRef<Terminal | null>(null);
|
|
144
|
+
const fitRef = useRef<FitAddon | null>(null);
|
|
145
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
146
|
+
const optimisticEchoRef = useRef(createOptimisticEcho());
|
|
147
|
+
const optimisticPrevRef = useRef(false);
|
|
148
|
+
const typingTimeout = useRef<number | null>(null);
|
|
149
|
+
const viewportTouchRef = useRef<{
|
|
150
|
+
start?: (ev: TouchEvent) => void;
|
|
151
|
+
move?: (ev: TouchEvent) => boolean;
|
|
152
|
+
} | null>(null);
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
localStorage.setItem("hay_selection_mode", selectionMode ? "true" : "false");
|
|
155
|
+
}, [selectionMode]);
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
localStorage.setItem("hay_haptics_enabled", hapticsEnabled ? "true" : "false");
|
|
158
|
+
}, [hapticsEnabled]);
|
|
159
|
+
|
|
160
|
+
const typingActive = useRef(false);
|
|
161
|
+
const noticeTimeout = useRef<number | null>(null);
|
|
162
|
+
const autoFitOnTypeRef = useRef(autoFitOnType);
|
|
163
|
+
const viewModeRef = useRef(viewMode);
|
|
164
|
+
const reconnectAttemptRef = useRef(0);
|
|
165
|
+
const reconnectTimerRef = useRef<number | null>(null);
|
|
166
|
+
const shouldReconnectRef = useRef(true);
|
|
167
|
+
|
|
168
|
+
const pushNotice = (message: string) => {
|
|
169
|
+
setNotice(message);
|
|
170
|
+
if (noticeTimeout.current) {
|
|
171
|
+
window.clearTimeout(noticeTimeout.current);
|
|
172
|
+
}
|
|
173
|
+
noticeTimeout.current = window.setTimeout(() => {
|
|
174
|
+
setNotice(null);
|
|
175
|
+
}, 3000);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const shareUrl = useMemo(() => {
|
|
179
|
+
return session ? createShareLink(session.room) : createShareLink(room);
|
|
180
|
+
}, [session, room]);
|
|
181
|
+
|
|
182
|
+
const sortedPresence = useMemo(() => sortPresence(presence, clientId), [presence, clientId]);
|
|
183
|
+
|
|
184
|
+
const controllerName = useMemo(() => {
|
|
185
|
+
if (!controllerId) {
|
|
186
|
+
return "";
|
|
187
|
+
}
|
|
188
|
+
return presence.find((client) => client.id === controllerId)?.name ?? "Someone";
|
|
189
|
+
}, [presence, controllerId]);
|
|
190
|
+
|
|
191
|
+
const optimisticActive =
|
|
192
|
+
latencyComp && status === "connected" && !collabMode && controllerId === clientId;
|
|
193
|
+
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
if (optimisticPrevRef.current && !optimisticActive) {
|
|
196
|
+
optimisticEchoRef.current.reset();
|
|
197
|
+
}
|
|
198
|
+
optimisticPrevRef.current = optimisticActive;
|
|
199
|
+
}, [optimisticActive]);
|
|
200
|
+
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
autoFitOnTypeRef.current = autoFitOnType;
|
|
203
|
+
}, [autoFitOnType]);
|
|
204
|
+
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
viewModeRef.current = viewMode;
|
|
207
|
+
localStorage.setItem("hay_view_mode", viewMode);
|
|
208
|
+
// When switching to fit mode, refit terminal
|
|
209
|
+
if (viewMode === "fit" && termRef.current) {
|
|
210
|
+
setTimeout(() => {
|
|
211
|
+
fitToViewport();
|
|
212
|
+
handleResize();
|
|
213
|
+
}, 0);
|
|
214
|
+
}
|
|
215
|
+
}, [viewMode]);
|
|
216
|
+
|
|
217
|
+
const sendMessage = (message: ClientMessage) => {
|
|
218
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
219
|
+
wsRef.current.send(JSON.stringify(message));
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const sendTyping = (active: boolean) => {
|
|
224
|
+
sendMessage({ type: "typing", active });
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const handleUserInput = (data: string) => {
|
|
228
|
+
// Strip focus reporting sequences that can be echoed back as visible text
|
|
229
|
+
const sanitized = data.replace(/\x1b\[I/g, '').replace(/\x1b\[O/g, '');
|
|
230
|
+
if (!sanitized) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const echoed = optimisticEchoRef.current.onInput(sanitized, optimisticActive);
|
|
234
|
+
if (echoed) {
|
|
235
|
+
writeToTerminal(echoed);
|
|
236
|
+
}
|
|
237
|
+
sendMessage({ type: "input", data: sanitized });
|
|
238
|
+
if (autoFitOnTypeRef.current && viewModeRef.current === "fit") {
|
|
239
|
+
fitToViewport();
|
|
240
|
+
handleResize();
|
|
241
|
+
}
|
|
242
|
+
if (!typingActive.current) {
|
|
243
|
+
typingActive.current = true;
|
|
244
|
+
sendTyping(true);
|
|
245
|
+
}
|
|
246
|
+
if (typingTimeout.current) {
|
|
247
|
+
window.clearTimeout(typingTimeout.current);
|
|
248
|
+
}
|
|
249
|
+
typingTimeout.current = window.setTimeout(() => {
|
|
250
|
+
typingActive.current = false;
|
|
251
|
+
sendTyping(false);
|
|
252
|
+
}, 1200);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const handleResize = () => {
|
|
256
|
+
if (!termRef.current) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
sendMessage({ type: "resize", cols: termRef.current.cols, rows: termRef.current.rows });
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// Fit based on the scroll container viewport rather than the terminal element itself.
|
|
263
|
+
// This ensures correct sizing across desktop padding and mobile full-bleed layouts.
|
|
264
|
+
const fitToViewport = () => {
|
|
265
|
+
if (!termRef.current || !containerRef.current) return;
|
|
266
|
+
|
|
267
|
+
const terminal = termRef.current;
|
|
268
|
+
const core = (terminal as any)._core;
|
|
269
|
+
if (!core?._renderService?.dimensions?.css?.cell) return;
|
|
270
|
+
|
|
271
|
+
const cellWidth = core._renderService.dimensions.css.cell.width;
|
|
272
|
+
const cellHeight = core._renderService.dimensions.css.cell.height;
|
|
273
|
+
if (!cellWidth || !cellHeight) return;
|
|
274
|
+
|
|
275
|
+
const scrollContainer = containerRef.current.closest(".terminal-scroll");
|
|
276
|
+
if (!scrollContainer) return;
|
|
277
|
+
|
|
278
|
+
const rect = scrollContainer.getBoundingClientRect();
|
|
279
|
+
const styles = window.getComputedStyle(scrollContainer);
|
|
280
|
+
const paddingLeft = parseFloat(styles.paddingLeft) || 0;
|
|
281
|
+
const paddingRight = parseFloat(styles.paddingRight) || 0;
|
|
282
|
+
const paddingTop = parseFloat(styles.paddingTop) || 0;
|
|
283
|
+
const paddingBottom = parseFloat(styles.paddingBottom) || 0;
|
|
284
|
+
|
|
285
|
+
const availableWidth = rect.width - paddingLeft - paddingRight;
|
|
286
|
+
const availableHeight = rect.height - paddingTop - paddingBottom;
|
|
287
|
+
|
|
288
|
+
const cols = Math.max(2, Math.floor(availableWidth / cellWidth));
|
|
289
|
+
const rows = Math.max(1, Math.floor(availableHeight / cellHeight));
|
|
290
|
+
|
|
291
|
+
if (terminal.cols !== cols || terminal.rows !== rows) {
|
|
292
|
+
terminal.resize(cols, rows);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const writeToTerminal = (data: string) => {
|
|
297
|
+
if (!termRef.current) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// Check if currently at bottom before writing (standard terminal follow behavior)
|
|
301
|
+
const viewport = containerRef.current?.querySelector('.xterm-viewport');
|
|
302
|
+
const wasAtBottom = viewport
|
|
303
|
+
? viewport.scrollTop >= viewport.scrollHeight - viewport.clientHeight - 5
|
|
304
|
+
: true;
|
|
305
|
+
|
|
306
|
+
// Filter focus reporting sequences that can leak as visible text
|
|
307
|
+
const filtered = data.replace(/\x1b\[I/g, '').replace(/\x1b\[O/g, '');
|
|
308
|
+
termRef.current.write(filtered, () => {
|
|
309
|
+
// Only scroll to bottom if we were already at bottom (follow output behavior)
|
|
310
|
+
if (wasAtBottom) {
|
|
311
|
+
termRef.current?.scrollToBottom();
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const scheduleReconnect = (nextSession: { name: string; room: string }) => {
|
|
317
|
+
if (!shouldReconnectRef.current) return;
|
|
318
|
+
|
|
319
|
+
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s
|
|
320
|
+
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptRef.current), 30000);
|
|
321
|
+
reconnectAttemptRef.current += 1;
|
|
322
|
+
|
|
323
|
+
pushNotice(`Reconnecting in ${Math.round(delay / 1000)}s...`);
|
|
324
|
+
|
|
325
|
+
reconnectTimerRef.current = window.setTimeout(() => {
|
|
326
|
+
reconnectTimerRef.current = null;
|
|
327
|
+
if (shouldReconnectRef.current) {
|
|
328
|
+
connect(nextSession);
|
|
329
|
+
}
|
|
330
|
+
}, delay);
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const connect = (nextSession: { name: string; room: string }) => {
|
|
334
|
+
// Clear any pending reconnect
|
|
335
|
+
if (reconnectTimerRef.current) {
|
|
336
|
+
window.clearTimeout(reconnectTimerRef.current);
|
|
337
|
+
reconnectTimerRef.current = null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const wsUrl = resolveWsUrl();
|
|
341
|
+
const cols = termRef.current?.cols ?? 80;
|
|
342
|
+
const rows = termRef.current?.rows ?? 24;
|
|
343
|
+
const url = `${wsUrl}?room=${encodeURIComponent(nextSession.room)}&name=${encodeURIComponent(
|
|
344
|
+
nextSession.name
|
|
345
|
+
)}&cols=${cols}&rows=${rows}`;
|
|
346
|
+
|
|
347
|
+
wsRef.current?.close();
|
|
348
|
+
setStatus("connecting");
|
|
349
|
+
shouldReconnectRef.current = true;
|
|
350
|
+
|
|
351
|
+
const ws = new WebSocket(url);
|
|
352
|
+
wsRef.current = ws;
|
|
353
|
+
|
|
354
|
+
ws.addEventListener("open", () => {
|
|
355
|
+
setStatus("connected");
|
|
356
|
+
reconnectAttemptRef.current = 0; // Reset backoff on successful connection
|
|
357
|
+
handleResize();
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
ws.addEventListener("message", (event) => {
|
|
361
|
+
const message = safeParseServerMessage(event.data);
|
|
362
|
+
if (!message) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
switch (message.type) {
|
|
366
|
+
case "hello":
|
|
367
|
+
setClientId(message.clientId);
|
|
368
|
+
setCollabMode(message.collabMode);
|
|
369
|
+
setControllerId(message.controllerId);
|
|
370
|
+
break;
|
|
371
|
+
case "presence":
|
|
372
|
+
setPresence(message.clients);
|
|
373
|
+
break;
|
|
374
|
+
case "output":
|
|
375
|
+
{
|
|
376
|
+
const reconciled = optimisticEchoRef.current.reconcileOutput(message.data);
|
|
377
|
+
if (reconciled) {
|
|
378
|
+
writeToTerminal(reconciled);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
break;
|
|
382
|
+
case "snapshot":
|
|
383
|
+
optimisticEchoRef.current.reset();
|
|
384
|
+
if (termRef.current) {
|
|
385
|
+
termRef.current.clear();
|
|
386
|
+
}
|
|
387
|
+
writeToTerminal(message.data);
|
|
388
|
+
if (termRef.current) {
|
|
389
|
+
// Respect cursor visibility state after snapshot restore.
|
|
390
|
+
if (typeof message.cursorHidden === "boolean") {
|
|
391
|
+
termRef.current.write(message.cursorHidden ? '\x1b[?25l' : '\x1b[?25h');
|
|
392
|
+
} else if (message.alternateScreen) {
|
|
393
|
+
// Fallback for older servers: alternate screen apps generally hide the cursor.
|
|
394
|
+
termRef.current.write('\x1b[?25l');
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
break;
|
|
398
|
+
case "collab":
|
|
399
|
+
setCollabMode(message.enabled);
|
|
400
|
+
setControllerId(message.controllerId);
|
|
401
|
+
pushNotice(message.enabled ? "Collaborative typing enabled" : "Control locked to a single editor");
|
|
402
|
+
break;
|
|
403
|
+
case "input_rejected":
|
|
404
|
+
pushNotice(message.reason);
|
|
405
|
+
break;
|
|
406
|
+
case "active_size":
|
|
407
|
+
// Resize terminal to match active user's size.
|
|
408
|
+
// This allows overflow/panning when the active user is larger than the viewport.
|
|
409
|
+
if (termRef.current) {
|
|
410
|
+
const currentCols = termRef.current.cols;
|
|
411
|
+
const currentRows = termRef.current.rows;
|
|
412
|
+
if (message.cols !== currentCols || message.rows !== currentRows) {
|
|
413
|
+
termRef.current.resize(message.cols, message.rows);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
break;
|
|
417
|
+
case "session_ended":
|
|
418
|
+
shouldReconnectRef.current = false;
|
|
419
|
+
pushNotice(message.message);
|
|
420
|
+
ws.close();
|
|
421
|
+
setStatus("disconnected");
|
|
422
|
+
break;
|
|
423
|
+
default:
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
ws.addEventListener("close", () => {
|
|
429
|
+
optimisticEchoRef.current.reset();
|
|
430
|
+
setStatus("disconnected");
|
|
431
|
+
scheduleReconnect(nextSession);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
ws.addEventListener("error", () => {
|
|
435
|
+
optimisticEchoRef.current.reset();
|
|
436
|
+
setStatus("disconnected");
|
|
437
|
+
// Error is usually followed by close, so don't double-schedule
|
|
438
|
+
});
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
useEffect(() => {
|
|
442
|
+
if (!session || !terminalReady) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
connect(session);
|
|
446
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
447
|
+
}, [session, terminalReady, reconnectToken]);
|
|
448
|
+
|
|
449
|
+
useEffect(() => {
|
|
450
|
+
if (!session || !containerRef.current || termRef.current) {
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const terminal = new Terminal({
|
|
455
|
+
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
|
|
456
|
+
fontSize,
|
|
457
|
+
lineHeight: 1.3,
|
|
458
|
+
cursorBlink: true,
|
|
459
|
+
theme: terminalTheme
|
|
460
|
+
});
|
|
461
|
+
const fitAddon = new FitAddon();
|
|
462
|
+
terminal.loadAddon(fitAddon);
|
|
463
|
+
terminal.open(containerRef.current);
|
|
464
|
+
fitAddon.fit();
|
|
465
|
+
|
|
466
|
+
termRef.current = terminal;
|
|
467
|
+
fitRef.current = fitAddon;
|
|
468
|
+
if (viewModeRef.current === "fit") {
|
|
469
|
+
setTimeout(() => {
|
|
470
|
+
fitToViewport();
|
|
471
|
+
handleResize();
|
|
472
|
+
}, 0);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Register OSC handlers for sequences xterm.js doesn't fully support
|
|
476
|
+
// These swallow the sequences so they don't appear as visible text
|
|
477
|
+
const oscIds = [
|
|
478
|
+
4, // Color palette query/set
|
|
479
|
+
10, // Foreground color query/set
|
|
480
|
+
11, // Background color query/set
|
|
481
|
+
12, // Cursor color query/set
|
|
482
|
+
52, // Clipboard operations
|
|
483
|
+
104, // Reset color palette
|
|
484
|
+
110, // Reset foreground color
|
|
485
|
+
111, // Reset background color
|
|
486
|
+
112 // Reset cursor color
|
|
487
|
+
];
|
|
488
|
+
for (const id of oscIds) {
|
|
489
|
+
terminal.parser.registerOscHandler(id, () => true);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Register CSI handlers for focus reporting sequences
|
|
493
|
+
// ESC[I = Focus In, ESC[O = Focus Out
|
|
494
|
+
terminal.parser.registerCsiHandler({ final: 'I' }, () => true);
|
|
495
|
+
terminal.parser.registerCsiHandler({ final: 'O' }, () => true);
|
|
496
|
+
|
|
497
|
+
setTerminalReady(true);
|
|
498
|
+
|
|
499
|
+
// On mobile, disable the terminal's internal textarea to prevent:
|
|
500
|
+
// 1. Native keyboard from appearing when touching terminal
|
|
501
|
+
// 2. Double input from xterm's internal event handling
|
|
502
|
+
if (isMobile) {
|
|
503
|
+
const textarea = containerRef.current.querySelector('textarea');
|
|
504
|
+
if (textarea) {
|
|
505
|
+
textarea.setAttribute('readonly', 'true');
|
|
506
|
+
textarea.setAttribute('tabindex', '-1');
|
|
507
|
+
textarea.style.opacity = '0';
|
|
508
|
+
textarea.style.pointerEvents = 'none';
|
|
509
|
+
}
|
|
510
|
+
} else {
|
|
511
|
+
// Desktop: register onData for keyboard input
|
|
512
|
+
terminal.onData(handleUserInput);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Prevent browser from intercepting common terminal shortcuts
|
|
516
|
+
terminal.attachCustomKeyEventHandler((event) => {
|
|
517
|
+
// Allow ctrl+shift+c/v for copy/paste
|
|
518
|
+
if (event.ctrlKey && event.shiftKey && (event.key === 'c' || event.key === 'v')) {
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
// Capture all other ctrl key combinations for the terminal
|
|
522
|
+
if (event.ctrlKey && !event.altKey && !event.metaKey) {
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
return true;
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
529
|
+
// Only auto-fit in fit mode
|
|
530
|
+
if (viewModeRef.current === "fit") {
|
|
531
|
+
fitToViewport();
|
|
532
|
+
// Force a full refresh to clear any stale canvas content after resize
|
|
533
|
+
terminal.refresh(0, terminal.rows - 1);
|
|
534
|
+
handleResize();
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
const scrollContainer = containerRef.current.closest(".terminal-scroll");
|
|
539
|
+
if (scrollContainer) {
|
|
540
|
+
resizeObserver.observe(scrollContainer);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (import.meta.env.VITE_E2E === "true") {
|
|
544
|
+
(window as any).__hay = {
|
|
545
|
+
getBufferText: () => {
|
|
546
|
+
const buffer = terminal.buffer.active;
|
|
547
|
+
let text = "";
|
|
548
|
+
for (let i = 0; i < buffer.length; i += 1) {
|
|
549
|
+
text += `${buffer.getLine(i)?.translateToString(true) ?? ""}\n`;
|
|
550
|
+
}
|
|
551
|
+
return text;
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return () => {
|
|
557
|
+
resizeObserver.disconnect();
|
|
558
|
+
// Clean up touch handlers
|
|
559
|
+
if ((terminal as any).__touchCleanup) {
|
|
560
|
+
(terminal as any).__touchCleanup();
|
|
561
|
+
}
|
|
562
|
+
terminal.dispose();
|
|
563
|
+
termRef.current = null;
|
|
564
|
+
fitRef.current = null;
|
|
565
|
+
setTerminalReady(false);
|
|
566
|
+
};
|
|
567
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
568
|
+
}, [session]);
|
|
569
|
+
|
|
570
|
+
useEffect(() => {
|
|
571
|
+
if (!isMobile || !terminalReady) {
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const terminal = termRef.current;
|
|
576
|
+
const container = containerRef.current?.closest(".terminal-scroll");
|
|
577
|
+
if (!terminal || !container) {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if ((terminal as any).__touchCleanup) {
|
|
582
|
+
(terminal as any).__touchCleanup();
|
|
583
|
+
(terminal as any).__touchCleanup = null;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Selection mode uses native text selection; CSS re-enables pointer events
|
|
587
|
+
// on xterm rows so iOS handles selection, and we skip custom scrolling.
|
|
588
|
+
if (selectionMode) {
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Mobile touch scrolling with momentum (native-feeling)
|
|
593
|
+
// xterm.js has the viewport underneath the rows, so native scroll doesn't work
|
|
594
|
+
// We implement our own with inertia/momentum for iOS-like feel
|
|
595
|
+
let lastY = 0;
|
|
596
|
+
let scrollDebt = 0; // Accumulated sub-line scroll distance
|
|
597
|
+
let velocitySamples: number[] = []; // Recent velocities for smoothing
|
|
598
|
+
let lastMoveTime = 0;
|
|
599
|
+
let momentumVelocity = 0;
|
|
600
|
+
let momentumId: number | null = null;
|
|
601
|
+
let isScrolling = false;
|
|
602
|
+
let isPanning = false;
|
|
603
|
+
let panStartScrollLeft = 0;
|
|
604
|
+
let panStartScrollTop = 0;
|
|
605
|
+
let panStartX = 0;
|
|
606
|
+
let panStartY = 0;
|
|
607
|
+
|
|
608
|
+
const getCellHeight = () => {
|
|
609
|
+
const core = (terminal as any)._core;
|
|
610
|
+
const cellHeight = core?._renderService?.dimensions?.css?.cell?.height;
|
|
611
|
+
return cellHeight && cellHeight > 0 ? cellHeight : 18;
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
const getTwoFingerCenter = (touches: TouchList) => {
|
|
615
|
+
const x = (touches[0].clientX + touches[1].clientX) / 2;
|
|
616
|
+
const y = (touches[0].clientY + touches[1].clientY) / 2;
|
|
617
|
+
return { x, y };
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
const friction = 0.94; // Momentum decay (higher = longer coast)
|
|
621
|
+
const minMomentumVelocity = 0.25; // Minimum px/frame to continue momentum
|
|
622
|
+
|
|
623
|
+
const stopMomentum = () => {
|
|
624
|
+
if (momentumId !== null) {
|
|
625
|
+
cancelAnimationFrame(momentumId);
|
|
626
|
+
momentumId = null;
|
|
627
|
+
}
|
|
628
|
+
momentumVelocity = 0;
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
const applyMomentum = () => {
|
|
632
|
+
if (Math.abs(momentumVelocity) < minMomentumVelocity) {
|
|
633
|
+
momentumId = null;
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Apply momentum as fractional scroll
|
|
638
|
+
scrollDebt += momentumVelocity;
|
|
639
|
+
const lines = Math.trunc(scrollDebt / getCellHeight());
|
|
640
|
+
if (lines !== 0) {
|
|
641
|
+
terminal.scrollLines(lines);
|
|
642
|
+
scrollDebt -= lines * getCellHeight();
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
momentumVelocity *= friction;
|
|
646
|
+
momentumId = requestAnimationFrame(applyMomentum);
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
const handleTouchStart = (e: TouchEvent) => {
|
|
650
|
+
stopMomentum();
|
|
651
|
+
if (e.cancelable) {
|
|
652
|
+
e.preventDefault();
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (e.touches.length === 2) {
|
|
656
|
+
isPanning = true;
|
|
657
|
+
const center = getTwoFingerCenter(e.touches);
|
|
658
|
+
panStartX = center.x;
|
|
659
|
+
panStartY = center.y;
|
|
660
|
+
const panTarget = containerRef.current?.closest(".terminal-scroll");
|
|
661
|
+
if (panTarget) {
|
|
662
|
+
panStartScrollLeft = panTarget.scrollLeft;
|
|
663
|
+
panStartScrollTop = panTarget.scrollTop;
|
|
664
|
+
}
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (e.touches.length !== 1) {
|
|
669
|
+
isPanning = false;
|
|
670
|
+
isScrolling = false;
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
isPanning = false;
|
|
675
|
+
lastY = e.touches[0].clientY;
|
|
676
|
+
lastMoveTime = Date.now();
|
|
677
|
+
scrollDebt = 0;
|
|
678
|
+
velocitySamples = [];
|
|
679
|
+
isScrolling = true;
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
const handleTouchMove = (e: TouchEvent) => {
|
|
683
|
+
if (e.touches.length === 2) {
|
|
684
|
+
if (!isPanning) {
|
|
685
|
+
isPanning = true;
|
|
686
|
+
const center = getTwoFingerCenter(e.touches);
|
|
687
|
+
panStartX = center.x;
|
|
688
|
+
panStartY = center.y;
|
|
689
|
+
const panTarget = containerRef.current?.closest(".terminal-scroll");
|
|
690
|
+
if (panTarget) {
|
|
691
|
+
panStartScrollLeft = panTarget.scrollLeft;
|
|
692
|
+
panStartScrollTop = panTarget.scrollTop;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const center = getTwoFingerCenter(e.touches);
|
|
697
|
+
const deltaX = panStartX - center.x;
|
|
698
|
+
const deltaY = panStartY - center.y;
|
|
699
|
+
const panTarget = containerRef.current?.closest(".terminal-scroll");
|
|
700
|
+
if (panTarget) {
|
|
701
|
+
panTarget.scrollLeft = panStartScrollLeft + deltaX;
|
|
702
|
+
panTarget.scrollTop = panStartScrollTop + deltaY;
|
|
703
|
+
}
|
|
704
|
+
e.preventDefault();
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (e.touches.length !== 1) return;
|
|
709
|
+
|
|
710
|
+
if (isPanning) {
|
|
711
|
+
isPanning = false;
|
|
712
|
+
isScrolling = true;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const touchY = e.touches[0].clientY;
|
|
716
|
+
const now = Date.now();
|
|
717
|
+
const deltaY = lastY - touchY; // positive = scroll down (finger up)
|
|
718
|
+
const deltaTime = Math.max(1, now - lastMoveTime);
|
|
719
|
+
|
|
720
|
+
// ALWAYS track velocity (even before direction is locked)
|
|
721
|
+
// This ensures quick flicks have velocity data
|
|
722
|
+
if (deltaTime > 0 && deltaTime < 100) { // Ignore stale samples
|
|
723
|
+
velocitySamples.push(deltaY / deltaTime);
|
|
724
|
+
if (velocitySamples.length > 5) velocitySamples.shift();
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
e.preventDefault();
|
|
728
|
+
// Accumulate scroll and apply whole lines
|
|
729
|
+
scrollDebt += deltaY;
|
|
730
|
+
const lines = Math.trunc(scrollDebt / getCellHeight());
|
|
731
|
+
if (lines !== 0) {
|
|
732
|
+
terminal.scrollLines(lines);
|
|
733
|
+
scrollDebt -= lines * getCellHeight();
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
lastY = touchY;
|
|
737
|
+
lastMoveTime = now;
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
const handleTouchEnd = () => {
|
|
741
|
+
if (isPanning) {
|
|
742
|
+
isPanning = false;
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Only apply momentum if we were scrolling vertically
|
|
747
|
+
if (isScrolling && velocitySamples.length > 0) {
|
|
748
|
+
// Use peak velocity (max absolute value) - users often slow down at end of flick
|
|
749
|
+
// but we want to capture their flick intent, not their stopping motion
|
|
750
|
+
let peakVelocity = 0;
|
|
751
|
+
for (const v of velocitySamples) {
|
|
752
|
+
if (Math.abs(v) > Math.abs(peakVelocity)) {
|
|
753
|
+
peakVelocity = v;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
// Convert to pixels per frame (~16ms)
|
|
757
|
+
momentumVelocity = peakVelocity * 16;
|
|
758
|
+
|
|
759
|
+
// Start momentum if significant (lowered threshold for responsiveness)
|
|
760
|
+
if (Math.abs(momentumVelocity) >= minMomentumVelocity) {
|
|
761
|
+
momentumId = requestAnimationFrame(applyMomentum);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
isScrolling = false;
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
// Use capture: true to intercept events before xterm's internal handlers
|
|
768
|
+
container.addEventListener('touchstart', handleTouchStart, { passive: false, capture: true });
|
|
769
|
+
container.addEventListener('touchmove', handleTouchMove, { passive: false, capture: true });
|
|
770
|
+
container.addEventListener('touchend', handleTouchEnd, { passive: true, capture: true });
|
|
771
|
+
container.addEventListener('touchcancel', handleTouchEnd, { passive: true, capture: true });
|
|
772
|
+
|
|
773
|
+
// Store cleanup handlers
|
|
774
|
+
(terminal as any).__touchCleanup = () => {
|
|
775
|
+
stopMomentum();
|
|
776
|
+
container.removeEventListener('touchstart', handleTouchStart, { capture: true });
|
|
777
|
+
container.removeEventListener('touchmove', handleTouchMove, { capture: true });
|
|
778
|
+
container.removeEventListener('touchend', handleTouchEnd, { capture: true });
|
|
779
|
+
container.removeEventListener('touchcancel', handleTouchEnd, { capture: true });
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
return () => {
|
|
783
|
+
if ((terminal as any).__touchCleanup) {
|
|
784
|
+
(terminal as any).__touchCleanup();
|
|
785
|
+
(terminal as any).__touchCleanup = null;
|
|
786
|
+
}
|
|
787
|
+
};
|
|
788
|
+
}, [isMobile, selectionMode, terminalReady]);
|
|
789
|
+
|
|
790
|
+
useEffect(() => {
|
|
791
|
+
if (!terminalReady || !isMobile) {
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const terminal = termRef.current;
|
|
795
|
+
if (!terminal) {
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
const viewport = (terminal as any)._core?.viewport;
|
|
799
|
+
if (!viewport) {
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (!viewportTouchRef.current) {
|
|
804
|
+
viewportTouchRef.current = {
|
|
805
|
+
start: typeof viewport.handleTouchStart === "function" ? viewport.handleTouchStart.bind(viewport) : undefined,
|
|
806
|
+
move: typeof viewport.handleTouchMove === "function" ? viewport.handleTouchMove.bind(viewport) : undefined
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Selection mode: disable xterm's internal touch handlers so Safari's
|
|
811
|
+
// selection handles can take priority (CSS toggles pointer-events).
|
|
812
|
+
if (selectionMode) {
|
|
813
|
+
viewport.handleTouchStart = () => {};
|
|
814
|
+
viewport.handleTouchMove = () => true;
|
|
815
|
+
} else if (viewportTouchRef.current) {
|
|
816
|
+
if (viewportTouchRef.current.start) {
|
|
817
|
+
viewport.handleTouchStart = viewportTouchRef.current.start;
|
|
818
|
+
}
|
|
819
|
+
if (viewportTouchRef.current.move) {
|
|
820
|
+
viewport.handleTouchMove = viewportTouchRef.current.move;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return () => {
|
|
825
|
+
if (viewportTouchRef.current) {
|
|
826
|
+
if (viewportTouchRef.current.start) {
|
|
827
|
+
viewport.handleTouchStart = viewportTouchRef.current.start;
|
|
828
|
+
}
|
|
829
|
+
if (viewportTouchRef.current.move) {
|
|
830
|
+
viewport.handleTouchMove = viewportTouchRef.current.move;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
}, [terminalReady, isMobile, selectionMode]);
|
|
835
|
+
|
|
836
|
+
useEffect(() => {
|
|
837
|
+
if (!isMobile || !selectionMode) {
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const container = containerRef.current?.closest(".terminal-scroll");
|
|
841
|
+
if (!container) {
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const resetUserSelect = () => {
|
|
846
|
+
const xterm = containerRef.current?.querySelector(".xterm") as HTMLElement | null;
|
|
847
|
+
if (!xterm) {
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
xterm.style.webkitUserSelect = "none";
|
|
851
|
+
xterm.style.userSelect = "none";
|
|
852
|
+
requestAnimationFrame(() => {
|
|
853
|
+
xterm.style.webkitUserSelect = "";
|
|
854
|
+
xterm.style.userSelect = "";
|
|
855
|
+
});
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
const handleClick = () => {
|
|
859
|
+
const selection = window.getSelection();
|
|
860
|
+
if (!selection || !selection.isCollapsed) {
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
const textarea = containerRef.current?.querySelector("textarea") as HTMLTextAreaElement | null;
|
|
864
|
+
const active = document.activeElement as HTMLElement | null;
|
|
865
|
+
selection.removeAllRanges();
|
|
866
|
+
textarea?.blur();
|
|
867
|
+
if (active && active !== document.body) {
|
|
868
|
+
active.blur();
|
|
869
|
+
}
|
|
870
|
+
termRef.current?.clearSelection?.();
|
|
871
|
+
termRef.current?.blur?.();
|
|
872
|
+
resetUserSelect();
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
container.addEventListener("click", handleClick, true);
|
|
876
|
+
return () => {
|
|
877
|
+
container.removeEventListener("click", handleClick, true);
|
|
878
|
+
};
|
|
879
|
+
}, [isMobile, selectionMode]);
|
|
880
|
+
|
|
881
|
+
useEffect(() => {
|
|
882
|
+
if (!session) {
|
|
883
|
+
// Stop auto-reconnect when leaving session
|
|
884
|
+
shouldReconnectRef.current = false;
|
|
885
|
+
if (reconnectTimerRef.current) {
|
|
886
|
+
window.clearTimeout(reconnectTimerRef.current);
|
|
887
|
+
reconnectTimerRef.current = null;
|
|
888
|
+
}
|
|
889
|
+
wsRef.current?.close();
|
|
890
|
+
wsRef.current = null;
|
|
891
|
+
optimisticEchoRef.current.reset();
|
|
892
|
+
reconnectAttemptRef.current = 0;
|
|
893
|
+
}
|
|
894
|
+
return () => {
|
|
895
|
+
shouldReconnectRef.current = false;
|
|
896
|
+
if (reconnectTimerRef.current) {
|
|
897
|
+
window.clearTimeout(reconnectTimerRef.current);
|
|
898
|
+
}
|
|
899
|
+
wsRef.current?.close();
|
|
900
|
+
};
|
|
901
|
+
}, [session]);
|
|
902
|
+
|
|
903
|
+
const fetchSessions = useCallback(async () => {
|
|
904
|
+
setLoadingSessions(true);
|
|
905
|
+
try {
|
|
906
|
+
const res = await fetch("/api/sessions");
|
|
907
|
+
const data = await res.json();
|
|
908
|
+
const sessionMap = new Map<string, SessionInfo>();
|
|
909
|
+
|
|
910
|
+
// Process sessions from the API
|
|
911
|
+
for (const s of data.sessions || []) {
|
|
912
|
+
sessionMap.set(s.name, {
|
|
913
|
+
name: s.name,
|
|
914
|
+
displayName: s.displayName || s.name,
|
|
915
|
+
active: (data.active || []).includes(s.name),
|
|
916
|
+
starting: (data.starting || []).includes(s.name),
|
|
917
|
+
type: s.type,
|
|
918
|
+
port: s.port
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Add active sessions that might not be in sessions list
|
|
923
|
+
for (const name of data.active || []) {
|
|
924
|
+
if (!sessionMap.has(name)) {
|
|
925
|
+
sessionMap.set(name, {
|
|
926
|
+
name,
|
|
927
|
+
displayName: name,
|
|
928
|
+
active: true,
|
|
929
|
+
starting: false
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
setSessions(Array.from(sessionMap.values()));
|
|
935
|
+
} catch (err) {
|
|
936
|
+
console.error("Failed to fetch sessions:", err);
|
|
937
|
+
} finally {
|
|
938
|
+
setLoadingSessions(false);
|
|
939
|
+
}
|
|
940
|
+
}, []);
|
|
941
|
+
|
|
942
|
+
// Fetch sessions when drawer opens
|
|
943
|
+
useEffect(() => {
|
|
944
|
+
if (drawerOpen) {
|
|
945
|
+
fetchSessions();
|
|
946
|
+
}
|
|
947
|
+
}, [drawerOpen, fetchSessions]);
|
|
948
|
+
|
|
949
|
+
// Fetch sessions on desktop (no drawer)
|
|
950
|
+
useEffect(() => {
|
|
951
|
+
if (isMobile) return;
|
|
952
|
+
fetchSessions();
|
|
953
|
+
}, [fetchSessions, isMobile]);
|
|
954
|
+
|
|
955
|
+
// Update terminal font size when it changes
|
|
956
|
+
useEffect(() => {
|
|
957
|
+
if (termRef.current) {
|
|
958
|
+
termRef.current.options.fontSize = fontSize;
|
|
959
|
+
if (viewModeRef.current === "fit") {
|
|
960
|
+
fitToViewport();
|
|
961
|
+
handleResize();
|
|
962
|
+
} else {
|
|
963
|
+
termRef.current.refresh(0, termRef.current.rows - 1);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
localStorage.setItem("hay_font_size", String(fontSize));
|
|
967
|
+
}, [fontSize]);
|
|
968
|
+
|
|
969
|
+
const switchSession = (sessionName: string) => {
|
|
970
|
+
// Navigate to the new session
|
|
971
|
+
window.location.href = `/s/${encodeURIComponent(sessionName)}/`;
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
const handleJoin = (event: FormEvent) => {
|
|
975
|
+
event.preventDefault();
|
|
976
|
+
if (!name.trim() || !room.trim()) {
|
|
977
|
+
pushNotice("Enter a name and room to start.");
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
localStorage.setItem("hay_name", name.trim());
|
|
981
|
+
setSession({ name: name.trim(), room: room.trim() });
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
const handleCopyLink = async () => {
|
|
985
|
+
try {
|
|
986
|
+
await navigator.clipboard.writeText(shareUrl);
|
|
987
|
+
pushNotice("Share link copied.");
|
|
988
|
+
} catch {
|
|
989
|
+
pushNotice("Copy failed. Select the link manually.");
|
|
990
|
+
}
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
const handleToggleCollab = () => {
|
|
994
|
+
sendMessage({ type: "toggle_collab", enabled: !collabMode });
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
const handleTakeControl = () => {
|
|
998
|
+
sendMessage({ type: "take_control" });
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
const handleReleaseControl = () => {
|
|
1002
|
+
sendMessage({ type: "release_control" });
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
const handleFabDragStart = (clientX: number, clientY: number) => {
|
|
1006
|
+
fabDragRef.current = {
|
|
1007
|
+
dragging: false,
|
|
1008
|
+
startX: clientX,
|
|
1009
|
+
startY: clientY,
|
|
1010
|
+
startPosX: fabPosition.x,
|
|
1011
|
+
startPosY: fabPosition.y
|
|
1012
|
+
};
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
const handleFabDragMove = (clientX: number, clientY: number) => {
|
|
1016
|
+
if (!fabDragRef.current) return;
|
|
1017
|
+
const dx = clientX - fabDragRef.current.startX;
|
|
1018
|
+
const dy = clientY - fabDragRef.current.startY;
|
|
1019
|
+
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
|
|
1020
|
+
fabDragRef.current.dragging = true;
|
|
1021
|
+
}
|
|
1022
|
+
if (fabDragRef.current.dragging) {
|
|
1023
|
+
setFabPosition({
|
|
1024
|
+
x: Math.max(0, Math.min(window.innerWidth - 56, fabDragRef.current.startPosX + dx)),
|
|
1025
|
+
y: Math.max(0, Math.min(window.innerHeight - 56, fabDragRef.current.startPosY + dy))
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
|
|
1030
|
+
const handleFabDragEnd = () => {
|
|
1031
|
+
if (fabDragRef.current && !fabDragRef.current.dragging) {
|
|
1032
|
+
setDrawerOpen(true);
|
|
1033
|
+
}
|
|
1034
|
+
fabDragRef.current = null;
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
const handleKeyboardToggle = useCallback(() => {
|
|
1038
|
+
setKeyboardVisible((prev) => !prev);
|
|
1039
|
+
// Refit terminal after keyboard toggle
|
|
1040
|
+
setTimeout(() => {
|
|
1041
|
+
if (viewModeRef.current === "fit") {
|
|
1042
|
+
fitToViewport();
|
|
1043
|
+
handleResize();
|
|
1044
|
+
}
|
|
1045
|
+
}, 100);
|
|
1046
|
+
}, []);
|
|
1047
|
+
|
|
1048
|
+
const handleKeyboardInput = useCallback(
|
|
1049
|
+
(data: string) => {
|
|
1050
|
+
handleUserInput(data);
|
|
1051
|
+
},
|
|
1052
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1053
|
+
[]
|
|
1054
|
+
);
|
|
1055
|
+
|
|
1056
|
+
return (
|
|
1057
|
+
<div className="app">
|
|
1058
|
+
<header className="topbar">
|
|
1059
|
+
<div className="brand">
|
|
1060
|
+
<span className="brand-mark">◎</span>
|
|
1061
|
+
<div>
|
|
1062
|
+
<p className="brand-title">Hay</p>
|
|
1063
|
+
<p className="brand-subtitle">Collaborative terminal sharing for Hop.</p>
|
|
1064
|
+
</div>
|
|
1065
|
+
</div>
|
|
1066
|
+
<div className="presence-strip">
|
|
1067
|
+
{sortedPresence.map((client) => (
|
|
1068
|
+
<div
|
|
1069
|
+
key={client.id}
|
|
1070
|
+
className={`presence-chip ${client.id === clientId ? "self" : ""}`}
|
|
1071
|
+
title={`${client.name} · ${formatStatus(client)}`}
|
|
1072
|
+
>
|
|
1073
|
+
<span className="presence-dot" style={{ backgroundColor: client.color }} />
|
|
1074
|
+
<span className="presence-name">{client.name}</span>
|
|
1075
|
+
<span className={`presence-status ${formatStatus(client)}`}>{formatStatus(client)}</span>
|
|
1076
|
+
</div>
|
|
1077
|
+
))}
|
|
1078
|
+
{sortedPresence.length === 0 && <span className="presence-empty">No viewers yet</span>}
|
|
1079
|
+
</div>
|
|
1080
|
+
</header>
|
|
1081
|
+
|
|
1082
|
+
{!session ? (
|
|
1083
|
+
<main className="join">
|
|
1084
|
+
<div className="join-card">
|
|
1085
|
+
<h1>Terminal session</h1>
|
|
1086
|
+
<p>Connect to an existing session or start a new one.</p>
|
|
1087
|
+
<form onSubmit={handleJoin} className="join-form">
|
|
1088
|
+
<label>
|
|
1089
|
+
Display name
|
|
1090
|
+
<input
|
|
1091
|
+
value={name}
|
|
1092
|
+
onChange={(event) => setName(event.target.value)}
|
|
1093
|
+
placeholder="Casey"
|
|
1094
|
+
/>
|
|
1095
|
+
</label>
|
|
1096
|
+
<label>
|
|
1097
|
+
Session
|
|
1098
|
+
<input
|
|
1099
|
+
value={room}
|
|
1100
|
+
onChange={(event) => setRoom(event.target.value)}
|
|
1101
|
+
placeholder="my-session"
|
|
1102
|
+
/>
|
|
1103
|
+
</label>
|
|
1104
|
+
<button type="submit" className="primary">
|
|
1105
|
+
Connect
|
|
1106
|
+
</button>
|
|
1107
|
+
</form>
|
|
1108
|
+
</div>
|
|
1109
|
+
</main>
|
|
1110
|
+
) : (
|
|
1111
|
+
<main
|
|
1112
|
+
className={`session${isMobile && keyboardVisible ? " has-keyboard" : ""}${selectionMode ? " selection-mode" : ""}`}
|
|
1113
|
+
>
|
|
1114
|
+
<button
|
|
1115
|
+
type="button"
|
|
1116
|
+
className="drawer-toggle"
|
|
1117
|
+
style={{ left: fabPosition.x, top: fabPosition.y, bottom: 'auto' }}
|
|
1118
|
+
onMouseDown={(e) => handleFabDragStart(e.clientX, e.clientY)}
|
|
1119
|
+
onMouseMove={(e) => handleFabDragMove(e.clientX, e.clientY)}
|
|
1120
|
+
onMouseUp={handleFabDragEnd}
|
|
1121
|
+
onMouseLeave={handleFabDragEnd}
|
|
1122
|
+
onTouchStart={(e) => handleFabDragStart(e.touches[0].clientX, e.touches[0].clientY)}
|
|
1123
|
+
onTouchMove={(e) => handleFabDragMove(e.touches[0].clientX, e.touches[0].clientY)}
|
|
1124
|
+
onTouchEnd={handleFabDragEnd}
|
|
1125
|
+
>
|
|
1126
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
1127
|
+
<circle cx="12" cy="12" r="3"/>
|
|
1128
|
+
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
|
|
1129
|
+
</svg>
|
|
1130
|
+
</button>
|
|
1131
|
+
{drawerOpen && (
|
|
1132
|
+
<div className="drawer-overlay" onClick={() => setDrawerOpen(false)} />
|
|
1133
|
+
)}
|
|
1134
|
+
<section className={`controls ${drawerOpen ? "open" : ""}`}>
|
|
1135
|
+
<button
|
|
1136
|
+
type="button"
|
|
1137
|
+
className="drawer-close"
|
|
1138
|
+
onClick={() => setDrawerOpen(false)}
|
|
1139
|
+
>
|
|
1140
|
+
✕
|
|
1141
|
+
</button>
|
|
1142
|
+
<div className="room-info">
|
|
1143
|
+
<p className="room-label">Session</p>
|
|
1144
|
+
<h2>{session.room}</h2>
|
|
1145
|
+
<p className="room-meta">
|
|
1146
|
+
{status === "connected" ? "Live" : status === "connecting" ? "Connecting" : "Offline"}
|
|
1147
|
+
</p>
|
|
1148
|
+
</div>
|
|
1149
|
+
<div className="control-actions">
|
|
1150
|
+
{isMobile && (
|
|
1151
|
+
<button type="button" onClick={() => { handleKeyboardToggle(); setDrawerOpen(false); }}>
|
|
1152
|
+
{keyboardVisible ? "Hide keyboard" : "Show keyboard"}
|
|
1153
|
+
</button>
|
|
1154
|
+
)}
|
|
1155
|
+
<button type="button" onClick={handleCopyLink}>
|
|
1156
|
+
Copy link
|
|
1157
|
+
</button>
|
|
1158
|
+
<button type="button" onClick={() => { fitToViewport(); handleResize(); }}>
|
|
1159
|
+
Fit to screen
|
|
1160
|
+
</button>
|
|
1161
|
+
{status === "disconnected" && (
|
|
1162
|
+
<button type="button" onClick={() => setReconnectToken((value) => value + 1)}>
|
|
1163
|
+
Reconnect
|
|
1164
|
+
</button>
|
|
1165
|
+
)}
|
|
1166
|
+
</div>
|
|
1167
|
+
<div className="font-size-control">
|
|
1168
|
+
<label>Font size</label>
|
|
1169
|
+
<div className="font-size-buttons">
|
|
1170
|
+
<button type="button" onClick={() => setFontSize((s) => Math.max(8, s - 1))}>−</button>
|
|
1171
|
+
<span>{fontSize}px</span>
|
|
1172
|
+
<button type="button" onClick={() => setFontSize((s) => Math.min(24, s + 1))}>+</button>
|
|
1173
|
+
</div>
|
|
1174
|
+
</div>
|
|
1175
|
+
<div className="view-mode-control">
|
|
1176
|
+
<label>View mode</label>
|
|
1177
|
+
<div className="view-mode-buttons">
|
|
1178
|
+
<button
|
|
1179
|
+
type="button"
|
|
1180
|
+
className={viewMode === "fit" ? "active" : ""}
|
|
1181
|
+
onClick={() => setViewMode("fit")}
|
|
1182
|
+
>
|
|
1183
|
+
Auto-fit
|
|
1184
|
+
</button>
|
|
1185
|
+
<button
|
|
1186
|
+
type="button"
|
|
1187
|
+
className={viewMode === "full" ? "active" : ""}
|
|
1188
|
+
onClick={() => setViewMode("full")}
|
|
1189
|
+
>
|
|
1190
|
+
Manual
|
|
1191
|
+
</button>
|
|
1192
|
+
</div>
|
|
1193
|
+
</div>
|
|
1194
|
+
{isMobile && (
|
|
1195
|
+
<div className="selection-mode-control">
|
|
1196
|
+
<label>Selection mode</label>
|
|
1197
|
+
<div className="view-mode-buttons">
|
|
1198
|
+
<button
|
|
1199
|
+
type="button"
|
|
1200
|
+
className={!selectionMode ? "active" : ""}
|
|
1201
|
+
onClick={() => setSelectionMode(false)}
|
|
1202
|
+
>
|
|
1203
|
+
Scroll
|
|
1204
|
+
</button>
|
|
1205
|
+
<button
|
|
1206
|
+
type="button"
|
|
1207
|
+
className={selectionMode ? "active" : ""}
|
|
1208
|
+
onClick={() => setSelectionMode(true)}
|
|
1209
|
+
>
|
|
1210
|
+
Select
|
|
1211
|
+
</button>
|
|
1212
|
+
</div>
|
|
1213
|
+
</div>
|
|
1214
|
+
)}
|
|
1215
|
+
{isMobile && (
|
|
1216
|
+
<div className="haptics-control">
|
|
1217
|
+
<label>Haptics</label>
|
|
1218
|
+
<div className="view-mode-buttons">
|
|
1219
|
+
<button
|
|
1220
|
+
type="button"
|
|
1221
|
+
className={hapticsEnabled ? "active" : ""}
|
|
1222
|
+
onClick={() => setHapticsEnabled(true)}
|
|
1223
|
+
>
|
|
1224
|
+
On
|
|
1225
|
+
</button>
|
|
1226
|
+
<button
|
|
1227
|
+
type="button"
|
|
1228
|
+
className={!hapticsEnabled ? "active" : ""}
|
|
1229
|
+
onClick={() => setHapticsEnabled(false)}
|
|
1230
|
+
>
|
|
1231
|
+
Off
|
|
1232
|
+
</button>
|
|
1233
|
+
</div>
|
|
1234
|
+
</div>
|
|
1235
|
+
)}
|
|
1236
|
+
{notice && <p className="notice">{notice}</p>}
|
|
1237
|
+
{(() => {
|
|
1238
|
+
const otherSessions = sessions.filter((s) => s.name !== session?.room);
|
|
1239
|
+
return (
|
|
1240
|
+
<div className="session-switcher">
|
|
1241
|
+
<p className="session-switcher-label">Switch session</p>
|
|
1242
|
+
{loadingSessions ? (
|
|
1243
|
+
<div className="session-list-loading">Loading...</div>
|
|
1244
|
+
) : otherSessions.length === 0 ? (
|
|
1245
|
+
<div className="session-list-empty">No other sessions</div>
|
|
1246
|
+
) : (
|
|
1247
|
+
<div className="session-list">
|
|
1248
|
+
{otherSessions.map((s) => (
|
|
1249
|
+
<button
|
|
1250
|
+
key={s.name}
|
|
1251
|
+
type="button"
|
|
1252
|
+
className={`session-list-item${s.active ? " active" : ""}${s.starting ? " starting" : ""}`}
|
|
1253
|
+
onClick={() => switchSession(s.name)}
|
|
1254
|
+
>
|
|
1255
|
+
<span className="session-list-name">{s.displayName}</span>
|
|
1256
|
+
{s.active && <span className="session-badge live">LIVE</span>}
|
|
1257
|
+
{s.starting && !s.active && <span className="session-badge starting">STARTING</span>}
|
|
1258
|
+
{s.type === "port" && <span className="session-badge port">PORT {s.port}</span>}
|
|
1259
|
+
</button>
|
|
1260
|
+
))}
|
|
1261
|
+
</div>
|
|
1262
|
+
)}
|
|
1263
|
+
</div>
|
|
1264
|
+
);
|
|
1265
|
+
})()}
|
|
1266
|
+
</section>
|
|
1267
|
+
<section className="terminal">
|
|
1268
|
+
<div
|
|
1269
|
+
className="terminal-frame"
|
|
1270
|
+
onClick={() => {
|
|
1271
|
+
// On mobile, don't focus terminal to prevent system keyboard
|
|
1272
|
+
if (!isMobile) {
|
|
1273
|
+
termRef.current?.focus();
|
|
1274
|
+
}
|
|
1275
|
+
}}
|
|
1276
|
+
>
|
|
1277
|
+
<div className="terminal-scroll">
|
|
1278
|
+
<div className="terminal-inner" ref={containerRef} />
|
|
1279
|
+
</div>
|
|
1280
|
+
</div>
|
|
1281
|
+
<div className="terminal-footer">
|
|
1282
|
+
<span>{status === "connected" ? "Live" : "Awaiting connection"}</span>
|
|
1283
|
+
<span>
|
|
1284
|
+
{sortedPresence.length} viewer{sortedPresence.length === 1 ? "" : "s"}
|
|
1285
|
+
</span>
|
|
1286
|
+
</div>
|
|
1287
|
+
</section>
|
|
1288
|
+
{isMobile && (
|
|
1289
|
+
<MobileKeyboard
|
|
1290
|
+
onInput={handleKeyboardInput}
|
|
1291
|
+
visible={keyboardVisible}
|
|
1292
|
+
onToggle={handleKeyboardToggle}
|
|
1293
|
+
hapticsEnabled={hapticsEnabled}
|
|
1294
|
+
/>
|
|
1295
|
+
)}
|
|
1296
|
+
</main>
|
|
1297
|
+
)}
|
|
1298
|
+
</div>
|
|
1299
|
+
);
|
|
1300
|
+
};
|
|
1301
|
+
|
|
1302
|
+
export default App;
|