codehost 0.9.1 → 0.11.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/.claude/scheduled_tasks.lock +1 -0
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/web/discovery.tsx +415 -248
- package/src/web/history.ts +5 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"sessionId":"f8e4e571-944e-43d7-bbfa-267a5251e41c","pid":87968,"procStart":"Mon Jun 8 09:46:02 2026","acquiredAt":1780974562147}
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.11.0](https://github.com/snomiao/codehost/compare/v0.10.0...v0.11.0) (2026-06-09)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **web:** update URL on Connect + Back returns to the list ([7258a16](https://github.com/snomiao/codehost/commit/7258a168a0bcc1d476aab8a7f3fcd81f35ccfcb5))
|
|
7
|
+
|
|
8
|
+
# [0.10.0](https://github.com/snomiao/codehost/compare/v0.9.1...v0.10.0) (2026-06-09)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **web:** join multiple rooms at once, merged workspace list ([de5ae61](https://github.com/snomiao/codehost/commit/de5ae610068b6f2a26792ed7d6e67d1fb5c08257))
|
|
14
|
+
|
|
1
15
|
## [0.9.1](https://github.com/snomiao/codehost/compare/v0.9.0...v0.9.1) (2026-06-08)
|
|
2
16
|
|
|
3
17
|
|
package/package.json
CHANGED
package/src/web/discovery.tsx
CHANGED
|
@@ -17,13 +17,16 @@ import {
|
|
|
17
17
|
resolveRepoTarget,
|
|
18
18
|
shareableDeepLink,
|
|
19
19
|
} from "../shared/repo";
|
|
20
|
-
import {
|
|
20
|
+
import { getRooms, historyFor, recordConnection, setRooms } from "./history";
|
|
21
21
|
import { deriveTags, matchQuery, shortRoomLabel, tagKey } from "../shared/tags";
|
|
22
22
|
|
|
23
23
|
const TOKEN_KEY = "codehost.token";
|
|
24
24
|
|
|
25
25
|
type ConnState = "idle" | "connecting" | "connected" | "failed";
|
|
26
26
|
|
|
27
|
+
/** A server discovered in a specific room (its token routes the signaling). */
|
|
28
|
+
type RoomedServer = { server: PeerInfo; room: string };
|
|
29
|
+
|
|
27
30
|
/**
|
|
28
31
|
* Read a room token handed in the URL fragment as `#t=<token>` (what the CLI
|
|
29
32
|
* prints/opens after `setup`/`serve`). The page is static, so the fragment
|
|
@@ -94,46 +97,99 @@ function findRoomForDeepLink(dl: DeepLink, tokens: string[], timeoutMs = 6000):
|
|
|
94
97
|
});
|
|
95
98
|
}
|
|
96
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Headless per-room signaling client — one instance per joined room. React's
|
|
102
|
+
* keyed reconciliation (`key={token}`) adds/removes these as the joined set
|
|
103
|
+
* changes, so joining or leaving a room never tears down the other rooms' live
|
|
104
|
+
* discovery (or the active WebRTC session). Renders nothing: it pushes its
|
|
105
|
+
* room's servers/open-state up to the parent and registers a signal sender so
|
|
106
|
+
* the parent can dial peers found in this room.
|
|
107
|
+
*/
|
|
108
|
+
function RoomClient(props: {
|
|
109
|
+
token: string;
|
|
110
|
+
onPeers: (peers: PeerInfo[]) => void;
|
|
111
|
+
onStatus: (open: boolean) => void;
|
|
112
|
+
onSignal: (from: string, data: unknown) => void;
|
|
113
|
+
registerSender: (send: ((to: string, data: unknown) => void) | null) => void;
|
|
114
|
+
}) {
|
|
115
|
+
// Keep the latest callbacks in a ref so the socket effect runs once per token,
|
|
116
|
+
// not on every parent re-render (which would needlessly churn the WebSocket).
|
|
117
|
+
const cb = useRef(props);
|
|
118
|
+
cb.current = props;
|
|
119
|
+
const { token } = props;
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
const client = new SignalingClient({
|
|
122
|
+
url: getSignalUrl(),
|
|
123
|
+
token,
|
|
124
|
+
role: "viewer",
|
|
125
|
+
onOpen: () => cb.current.onStatus(true),
|
|
126
|
+
onClose: () => cb.current.onStatus(false),
|
|
127
|
+
onPeers: (peers) => cb.current.onPeers(peers.filter((p) => p.role === "server")),
|
|
128
|
+
onSignal: (from, data) => cb.current.onSignal(from, data),
|
|
129
|
+
});
|
|
130
|
+
cb.current.registerSender((to, data) => client.sendSignal(to, data));
|
|
131
|
+
client.connect();
|
|
132
|
+
return () => {
|
|
133
|
+
client.close();
|
|
134
|
+
cb.current.registerSender(null);
|
|
135
|
+
};
|
|
136
|
+
}, [token]);
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
97
140
|
export function Discovery() {
|
|
98
|
-
|
|
141
|
+
// Joined rooms — each token *is* a room id, and we keep one live signaling
|
|
142
|
+
// client per room (see RoomClient). Seeded from the persisted room list plus
|
|
143
|
+
// any legacy single-token / URL-fragment token, then format-validated.
|
|
144
|
+
const [tokens, setTokens] = useState<string[]>(() => {
|
|
145
|
+
const seed = new Set<string>(getRooms());
|
|
146
|
+
const legacy = localStorage.getItem(TOKEN_KEY);
|
|
147
|
+
if (legacy) seed.add(legacy);
|
|
99
148
|
const fromHash = tokenFromHash();
|
|
100
|
-
if (fromHash
|
|
101
|
-
|
|
102
|
-
return fromHash;
|
|
103
|
-
}
|
|
104
|
-
return localStorage.getItem(TOKEN_KEY) ?? "";
|
|
149
|
+
if (fromHash) seed.add(fromHash);
|
|
150
|
+
return [...seed].filter((t) => validateToken(t).ok);
|
|
105
151
|
});
|
|
106
|
-
|
|
107
|
-
//
|
|
108
|
-
|
|
152
|
+
|
|
153
|
+
// Per-room discovery state, merged into one workspace list below.
|
|
154
|
+
const [serversByRoom, setServersByRoom] = useState<Record<string, PeerInfo[]>>({});
|
|
155
|
+
const [roomOpen, setRoomOpen] = useState<Record<string, boolean>>({});
|
|
156
|
+
|
|
157
|
+
// Token input = "join another room": validated, then appended to the set.
|
|
158
|
+
// Never pre-filled with a saved token — it's a bearer secret.
|
|
109
159
|
const [draft, setDraft] = useState("");
|
|
110
160
|
const [editingToken, setEditingToken] = useState(false);
|
|
111
161
|
const [tokenError, setTokenError] = useState<string | null>(null);
|
|
112
|
-
const [connected, setConnected] = useState(false);
|
|
113
|
-
const [servers, setServers] = useState<PeerInfo[]>([]);
|
|
114
162
|
|
|
115
|
-
// Fake-tag filter over the workspace list: a free-text box plus a set
|
|
116
|
-
// pinned tag tokens (chips). Both feed the same `ay ls`-style AND matcher.
|
|
163
|
+
// Fake-tag filter over the merged workspace list: a free-text box plus a set
|
|
164
|
+
// of pinned tag tokens (chips). Both feed the same `ay ls`-style AND matcher.
|
|
117
165
|
const [filter, setFilter] = useState("");
|
|
118
166
|
const [activeTags, setActiveTags] = useState<string[]>([]);
|
|
119
167
|
|
|
120
|
-
//
|
|
168
|
+
// One WebRTC session at a time (you view a single VS Code), discovered across
|
|
169
|
+
// many rooms. `activeRoomRef` is the room the active peer was found in: its
|
|
170
|
+
// client carries the peer's signaling and it's the token Share/history record.
|
|
121
171
|
const [activePeerId, setActivePeerId] = useState<string | null>(null);
|
|
122
172
|
const [connState, setConnState] = useState<ConnState>("idle");
|
|
123
|
-
|
|
124
|
-
// Once a server's data channel is open we mount its VS Code in an iframe.
|
|
125
173
|
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
|
|
126
174
|
|
|
127
|
-
const clientRef = useRef<SignalingClient | null>(null);
|
|
128
175
|
const rtcRef = useRef<RtcClient | null>(null);
|
|
129
176
|
const activePeerRef = useRef<string | null>(null);
|
|
177
|
+
const activeRoomRef = useRef<string | null>(null);
|
|
178
|
+
const sendersRef = useRef<Map<string, (to: string, data: unknown) => void>>(new Map());
|
|
179
|
+
// Whether the live connection pushed a history entry (so Disconnect/Back can
|
|
180
|
+
// pop it), and whether we're currently *viewing* a workspace (so popstate only
|
|
181
|
+
// tears down from the iframe view, not during a mid-connect URL revert).
|
|
182
|
+
const pushedRef = useRef(false);
|
|
183
|
+
const viewingRef = useRef(false);
|
|
130
184
|
|
|
131
185
|
// Deep-link resolution (/gh/<owner>/<repo>/... or /dev/<path>): parse once,
|
|
132
186
|
// auto-connect when a matching server appears, remember the opened folder.
|
|
133
187
|
const deepLinkRef = useRef<DeepLink>(parseDeepLink(window.location.pathname));
|
|
134
188
|
const resolvedRef = useRef(false);
|
|
135
|
-
// A valid token in the URL fragment enables single-server auto-connect
|
|
189
|
+
// A valid token in the URL fragment enables single-server auto-connect, scoped
|
|
190
|
+
// to *that* room so unrelated joined rooms don't block it.
|
|
136
191
|
const autoConnectRef = useRef(false);
|
|
192
|
+
const hashRoomRef = useRef<string | null>(null);
|
|
137
193
|
const activeFolderRef = useRef<string | undefined>(undefined);
|
|
138
194
|
const [resolving, setResolving] = useState<string | null>(() => deepLinkLabel(deepLinkRef.current));
|
|
139
195
|
|
|
@@ -142,6 +198,15 @@ export function Discovery() {
|
|
|
142
198
|
const sharePathRef = useRef<string | null>(null);
|
|
143
199
|
const [copied, setCopied] = useState(false);
|
|
144
200
|
|
|
201
|
+
function adoptRoom(t: string) {
|
|
202
|
+
setTokens((prev) => (prev.includes(t) ? prev : [...prev, t]));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Persist the joined set so rooms survive reloads.
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
setRooms(tokens);
|
|
208
|
+
}, [tokens]);
|
|
209
|
+
|
|
145
210
|
// Register the Service Worker + connection broker once. The broker shares one
|
|
146
211
|
// WebRTC connection per server across tabs; on owner failover it asks us to
|
|
147
212
|
// reload the iframe so it reconnects through the new owner.
|
|
@@ -153,12 +218,20 @@ export function Discovery() {
|
|
|
153
218
|
const folder = activeFolderRef.current;
|
|
154
219
|
setTimeout(() => setIframeSrc(`/vs/${peerId}/${folderQuery(folder)}`), 400);
|
|
155
220
|
});
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
221
|
+
// Back / Cmd+Left from a workspace returns to the list: the browser restores
|
|
222
|
+
// the previous URL and we drop the connection. Only when actually viewing —
|
|
223
|
+
// a mid-connect URL revert (failed dial) must leave the list state untouched.
|
|
224
|
+
const onPopState = () => {
|
|
225
|
+
if (viewingRef.current) teardownConn();
|
|
226
|
+
};
|
|
227
|
+
window.addEventListener("popstate", onPopState);
|
|
228
|
+
// A valid token in the URL fragment (#t=<token>) joins the room and turns on
|
|
229
|
+
// single-server auto-connect for it; consume it from the address bar after,
|
|
230
|
+
// so the secret isn't left visible or re-applied on a manual reload.
|
|
159
231
|
const urlToken = tokenFromHash();
|
|
160
232
|
if (urlToken && validateToken(urlToken).ok) {
|
|
161
233
|
autoConnectRef.current = true;
|
|
234
|
+
hashRoomRef.current = urlToken;
|
|
162
235
|
if (window.location.hash) {
|
|
163
236
|
history.replaceState(null, "", window.location.pathname + window.location.search);
|
|
164
237
|
}
|
|
@@ -171,56 +244,28 @@ export function Discovery() {
|
|
|
171
244
|
if (dl && !(urlToken && validateToken(urlToken).ok)) {
|
|
172
245
|
const histToken = dl.type === "repo" ? historyFor(repoKey(dl.target))?.token : undefined;
|
|
173
246
|
if (histToken) {
|
|
174
|
-
|
|
247
|
+
adoptRoom(histToken);
|
|
175
248
|
} else {
|
|
176
249
|
const rooms = getRooms();
|
|
177
250
|
if (rooms.length) {
|
|
178
251
|
void findRoomForDeepLink(dl, rooms).then((tok) => {
|
|
179
|
-
if (tok)
|
|
252
|
+
if (tok) adoptRoom(tok);
|
|
180
253
|
});
|
|
181
254
|
}
|
|
182
255
|
}
|
|
183
256
|
}
|
|
257
|
+
|
|
258
|
+
return () => window.removeEventListener("popstate", onPopState);
|
|
184
259
|
}, []);
|
|
185
260
|
|
|
261
|
+
// Auto-connect once discovery turns up a match: a deep-link target across any
|
|
262
|
+
// room, or the lone server of a room joined via #t=.
|
|
186
263
|
useEffect(() => {
|
|
187
|
-
if (
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
token,
|
|
191
|
-
role: "viewer",
|
|
192
|
-
onOpen: () => {
|
|
193
|
-
setConnected(true);
|
|
194
|
-
addRoom(token);
|
|
195
|
-
},
|
|
196
|
-
onClose: () => setConnected(false),
|
|
197
|
-
onPeers: (peers) => {
|
|
198
|
-
const list = peers.filter((p) => p.role === "server");
|
|
199
|
-
setServers(list);
|
|
200
|
-
void tryAutoConnect(list);
|
|
201
|
-
},
|
|
202
|
-
onSignal: (from, data) => {
|
|
203
|
-
if (from === activePeerRef.current) void rtcRef.current?.handleSignal(data);
|
|
204
|
-
},
|
|
205
|
-
});
|
|
206
|
-
clientRef.current = client;
|
|
207
|
-
client.connect();
|
|
208
|
-
return () => {
|
|
209
|
-
rtcRef.current?.close();
|
|
210
|
-
rtcRef.current = null;
|
|
211
|
-
if (activePeerRef.current) connBroker.disconnect(activePeerRef.current);
|
|
212
|
-
client.close();
|
|
213
|
-
clientRef.current = null;
|
|
214
|
-
setConnected(false);
|
|
215
|
-
setServers([]);
|
|
216
|
-
setActivePeerId(null);
|
|
217
|
-
activePeerRef.current = null;
|
|
218
|
-
setConnState("idle");
|
|
219
|
-
setIframeSrc(null);
|
|
220
|
-
};
|
|
221
|
-
}, [token]);
|
|
264
|
+
if (resolvedRef.current) return;
|
|
265
|
+
tryAutoConnect();
|
|
266
|
+
}, [serversByRoom, tokens]);
|
|
222
267
|
|
|
223
|
-
function
|
|
268
|
+
function joinFromInput(e: React.FormEvent) {
|
|
224
269
|
e.preventDefault();
|
|
225
270
|
const t = draft.trim();
|
|
226
271
|
const check = validateToken(t);
|
|
@@ -229,30 +274,57 @@ export function Discovery() {
|
|
|
229
274
|
return;
|
|
230
275
|
}
|
|
231
276
|
setTokenError(null);
|
|
232
|
-
|
|
233
|
-
setToken(t);
|
|
277
|
+
adoptRoom(t);
|
|
234
278
|
setDraft("");
|
|
235
279
|
setEditingToken(false);
|
|
236
280
|
}
|
|
237
281
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
282
|
+
function leaveRoom(t: string) {
|
|
283
|
+
if (activeRoomRef.current === t) disconnect();
|
|
284
|
+
setTokens((prev) => prev.filter((x) => x !== t));
|
|
285
|
+
setServersByRoom((m) => {
|
|
286
|
+
const n = { ...m };
|
|
287
|
+
delete n[t];
|
|
288
|
+
return n;
|
|
289
|
+
});
|
|
290
|
+
setRoomOpen((m) => {
|
|
291
|
+
const n = { ...m };
|
|
292
|
+
delete n[t];
|
|
293
|
+
return n;
|
|
294
|
+
});
|
|
295
|
+
sendersRef.current.delete(t);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function connectTo(server: PeerInfo, room: string, folder?: string) {
|
|
299
|
+
const send = sendersRef.current.get(room);
|
|
300
|
+
if (!send) return;
|
|
241
301
|
|
|
242
302
|
rtcRef.current?.close();
|
|
243
303
|
rtcRef.current = null;
|
|
244
304
|
setIframeSrc(null);
|
|
245
305
|
setActivePeerId(server.peerId);
|
|
246
306
|
activePeerRef.current = server.peerId;
|
|
307
|
+
activeRoomRef.current = room;
|
|
308
|
+
viewingRef.current = false;
|
|
247
309
|
setConnState("connecting");
|
|
248
310
|
|
|
311
|
+
// Update the address bar the instant Connect is clicked (don't wait for the
|
|
312
|
+
// WebRTC handshake) and push a history entry, so Back / Cmd+Left returns to
|
|
313
|
+
// the list. Reverted if the connection fails.
|
|
314
|
+
const openFolder = folder ?? server.meta?.cwd;
|
|
315
|
+
const targetPath = shareablePathFor(server, openFolder);
|
|
316
|
+
const pushed = !!targetPath && targetPath !== window.location.pathname;
|
|
317
|
+
if (pushed) history.pushState(null, "", targetPath);
|
|
318
|
+
pushedRef.current = pushed;
|
|
319
|
+
sharePathRef.current = targetPath ?? window.location.pathname;
|
|
320
|
+
|
|
249
321
|
// The broker decides whether this tab owns the connection. `establish` is
|
|
250
322
|
// only invoked when we're the owner (or get promoted on failover); other
|
|
251
323
|
// tabs reuse the owner's channel via a proxy, so they never open WebRTC.
|
|
252
324
|
const establish = () =>
|
|
253
325
|
new Promise<RTCDataChannel>((resolve, reject) => {
|
|
254
326
|
const rtc = new RtcClient({
|
|
255
|
-
sendSignal: (data: RtcSignal) =>
|
|
327
|
+
sendSignal: (data: RtcSignal) => send(server.peerId, data),
|
|
256
328
|
onState: (state) => {
|
|
257
329
|
if (state === "failed" || state === "disconnected") setConnState("failed");
|
|
258
330
|
},
|
|
@@ -279,34 +351,38 @@ export function Discovery() {
|
|
|
279
351
|
await connBroker.connect(server.peerId, establish);
|
|
280
352
|
setConnState("connected");
|
|
281
353
|
// The daemon no longer sets a default folder (current VS Code serve-web
|
|
282
|
-
// dropped that flag), so open the served workspace from here:
|
|
354
|
+
// dropped that flag), so open the served workspace from here: the
|
|
283
355
|
// deep-link folder if we have one, else the server's reported cwd.
|
|
284
|
-
const openFolder = folder ?? server.meta?.cwd;
|
|
285
356
|
activeFolderRef.current = openFolder;
|
|
286
357
|
setIframeSrc(`/vs/${server.peerId}/${folderQuery(openFolder)}`);
|
|
287
358
|
setResolving(null);
|
|
288
|
-
recordConnect(server, openFolder);
|
|
289
|
-
|
|
359
|
+
recordConnect(server, room, openFolder);
|
|
360
|
+
viewingRef.current = true;
|
|
290
361
|
} catch {
|
|
291
362
|
setConnState("failed");
|
|
363
|
+
// Undo the optimistic history entry / URL change. We never started
|
|
364
|
+
// viewing, so the popstate handler leaves the "failed" card in place.
|
|
365
|
+
if (pushed) {
|
|
366
|
+
pushedRef.current = false;
|
|
367
|
+
history.back();
|
|
368
|
+
}
|
|
292
369
|
}
|
|
293
370
|
}
|
|
294
371
|
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
function
|
|
299
|
-
|
|
372
|
+
// Shareable deep-link pathname for a server+folder, with no side effects (no
|
|
373
|
+
// token — Share adds that). Keeps an existing deep-link path as-is; otherwise
|
|
374
|
+
// derives /gh|/git|/dev from the server's repo identity or opened folder.
|
|
375
|
+
function shareablePathFor(server: PeerInfo, folder?: string): string | null {
|
|
376
|
+
return deepLinkRef.current
|
|
300
377
|
? window.location.pathname
|
|
301
378
|
: shareableDeepLink({ repo: server.meta?.repo, branch: server.meta?.branch, folder });
|
|
302
|
-
if (!path) return;
|
|
303
|
-
sharePathRef.current = path;
|
|
304
|
-
if (path !== window.location.pathname) history.replaceState(null, "", path);
|
|
305
379
|
}
|
|
306
380
|
|
|
307
381
|
async function shareLink() {
|
|
382
|
+
const room = activeRoomRef.current;
|
|
383
|
+
if (!room) return;
|
|
308
384
|
const path = sharePathRef.current ?? window.location.pathname;
|
|
309
|
-
const url = `${window.location.origin}${path}#t=${encodeURIComponent(
|
|
385
|
+
const url = `${window.location.origin}${path}#t=${encodeURIComponent(room)}`;
|
|
310
386
|
try {
|
|
311
387
|
await navigator.clipboard.writeText(url);
|
|
312
388
|
} catch {
|
|
@@ -318,30 +394,36 @@ export function Discovery() {
|
|
|
318
394
|
}
|
|
319
395
|
|
|
320
396
|
// Deep-link auto-connect: when servers arrive, pick the best match (exact repo
|
|
321
|
-
// daemon, else a root daemon's subfolder) and open it once.
|
|
322
|
-
|
|
397
|
+
// daemon, else a root daemon's subfolder) across all rooms and open it once.
|
|
398
|
+
function tryAutoConnect() {
|
|
323
399
|
if (resolvedRef.current) return;
|
|
324
400
|
const dl = deepLinkRef.current;
|
|
325
401
|
if (dl) {
|
|
326
|
-
const
|
|
402
|
+
const peers = allServers.map((x) => x.server);
|
|
403
|
+
const res = dl.type === "repo" ? resolveRepoTarget(peers, dl.target) : resolveDevTarget(peers, dl.target);
|
|
327
404
|
if (!res) return;
|
|
328
|
-
const
|
|
329
|
-
if (!
|
|
405
|
+
const match = allServers.find((x) => x.server.peerId === res.peerId);
|
|
406
|
+
if (!match) return;
|
|
330
407
|
resolvedRef.current = true;
|
|
331
|
-
|
|
408
|
+
void connectTo(match.server, match.room, res.folder);
|
|
332
409
|
return;
|
|
333
410
|
}
|
|
334
|
-
// No deep link, but a token arrived via the URL: open
|
|
335
|
-
// straight away when
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
411
|
+
// No deep link, but a token arrived via the URL: open that room's server
|
|
412
|
+
// straight away when it has exactly one. Scoped to the hash room so servers
|
|
413
|
+
// in other joined rooms can't push the count past one and block it.
|
|
414
|
+
const hashRoom = hashRoomRef.current;
|
|
415
|
+
if (autoConnectRef.current && hashRoom) {
|
|
416
|
+
const inRoom = allServers.filter((x) => x.room === hashRoom);
|
|
417
|
+
if (inRoom.length === 1) {
|
|
418
|
+
resolvedRef.current = true;
|
|
419
|
+
void connectTo(inRoom[0].server, inRoom[0].room);
|
|
420
|
+
}
|
|
339
421
|
}
|
|
340
422
|
}
|
|
341
423
|
|
|
342
|
-
function recordConnect(server: PeerInfo, folder?: string) {
|
|
424
|
+
function recordConnect(server: PeerInfo, room: string, folder?: string) {
|
|
343
425
|
const base = {
|
|
344
|
-
token,
|
|
426
|
+
token: room,
|
|
345
427
|
peerId: server.peerId,
|
|
346
428
|
kind: server.meta?.kind,
|
|
347
429
|
name: server.meta?.name,
|
|
@@ -353,27 +435,50 @@ export function Discovery() {
|
|
|
353
435
|
if (dl?.type === "repo") recordConnection(repoKey(dl.target), { ...base, folder });
|
|
354
436
|
}
|
|
355
437
|
|
|
356
|
-
|
|
438
|
+
// Tear down the active connection and return to the workspace list. Does NOT
|
|
439
|
+
// touch history — the caller (Disconnect → history.back, or a popstate from
|
|
440
|
+
// Cmd+Left) owns the URL.
|
|
441
|
+
function teardownConn() {
|
|
357
442
|
rtcRef.current?.close();
|
|
358
443
|
rtcRef.current = null;
|
|
359
444
|
if (activePeerRef.current) connBroker.disconnect(activePeerRef.current);
|
|
360
445
|
setIframeSrc(null);
|
|
361
446
|
setActivePeerId(null);
|
|
362
447
|
activePeerRef.current = null;
|
|
448
|
+
activeRoomRef.current = null;
|
|
363
449
|
setConnState("idle");
|
|
364
450
|
sharePathRef.current = null;
|
|
451
|
+
pushedRef.current = false;
|
|
452
|
+
viewingRef.current = false;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function disconnect() {
|
|
456
|
+
// Mirror Cmd+Left: if connecting pushed a history entry, pop it — the
|
|
457
|
+
// browser restores the previous URL and our popstate handler tears down.
|
|
458
|
+
if (pushedRef.current) {
|
|
459
|
+
history.back();
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
teardownConn();
|
|
365
463
|
if (window.location.pathname !== "/") history.replaceState(null, "", "/");
|
|
366
464
|
}
|
|
367
465
|
|
|
368
|
-
|
|
466
|
+
// Merge every room's servers into one list, each tagged with its room so the
|
|
467
|
+
// Connect button knows which client to signal through.
|
|
468
|
+
const allServers: RoomedServer[] = tokens.flatMap((t) =>
|
|
469
|
+
(serversByRoom[t] ?? []).map((server) => ({ server, room: t })),
|
|
470
|
+
);
|
|
471
|
+
const serverCount = allServers.length;
|
|
472
|
+
const onlineRooms = tokens.filter((t) => roomOpen[t]).length;
|
|
473
|
+
const activeServer = allServers.find((x) => x.server.peerId === activePeerId)?.server;
|
|
369
474
|
|
|
370
|
-
// Annotate each server with its mnemonic fake-tags
|
|
371
|
-
// token is hashed to a short label — never rendered raw
|
|
372
|
-
const
|
|
373
|
-
const tagged = servers.map((s) => ({
|
|
475
|
+
// Annotate each server with its mnemonic fake-tags (incl. its room label), then
|
|
476
|
+
// filter. The room token is hashed to a short label — never rendered raw.
|
|
477
|
+
const tagged = allServers.map(({ server: s, room }) => ({
|
|
374
478
|
server: s,
|
|
479
|
+
room,
|
|
375
480
|
name: s.meta?.name ?? s.peerId.slice(0, 8),
|
|
376
|
-
tags: deriveTags(s.meta, { roomLabel }),
|
|
481
|
+
tags: deriveTags(s.meta, { roomLabel: shortRoomLabel(room) }),
|
|
377
482
|
}));
|
|
378
483
|
const query = [...activeTags, filter].join(" ");
|
|
379
484
|
const filtered = tagged.filter((t) => matchQuery({ name: t.name, tags: t.tags }, query));
|
|
@@ -389,174 +494,233 @@ export function Discovery() {
|
|
|
389
494
|
.filter((t) => ["host", "repo", "wt", "kind", "room"].includes(tagKey(t)))
|
|
390
495
|
.slice(0, 12);
|
|
391
496
|
|
|
497
|
+
// Headless signaling clients, one per joined room. Kept mounted across BOTH
|
|
498
|
+
// views so switching into the iframe never tears down discovery/session.
|
|
499
|
+
const roomClients = tokens.map((t) => (
|
|
500
|
+
<RoomClient
|
|
501
|
+
key={t}
|
|
502
|
+
token={t}
|
|
503
|
+
onPeers={(peers) => setServersByRoom((m) => ({ ...m, [t]: peers }))}
|
|
504
|
+
onStatus={(open) => setRoomOpen((m) => ({ ...m, [t]: open }))}
|
|
505
|
+
onSignal={(from, data) => {
|
|
506
|
+
if (from === activePeerRef.current) void rtcRef.current?.handleSignal(data);
|
|
507
|
+
}}
|
|
508
|
+
registerSender={(send) => {
|
|
509
|
+
if (send) sendersRef.current.set(t, send);
|
|
510
|
+
else sendersRef.current.delete(t);
|
|
511
|
+
}}
|
|
512
|
+
/>
|
|
513
|
+
));
|
|
514
|
+
|
|
392
515
|
// Connected view: VS Code in an iframe, served over the tunnel.
|
|
393
516
|
if (iframeSrc && connState === "connected") {
|
|
394
517
|
return (
|
|
518
|
+
<>
|
|
519
|
+
{roomClients}
|
|
520
|
+
<div style={styles.page}>
|
|
521
|
+
<header style={styles.header}>
|
|
522
|
+
<span style={styles.brand}>codehost</span>
|
|
523
|
+
<span style={styles.dim}>·</span>
|
|
524
|
+
<span style={styles.dim}>{activeServer?.meta?.name ?? activePeerId?.slice(0, 8)}</span>
|
|
525
|
+
{activeServer?.meta?.cwd && <span style={styles.cwd}>{activeServer.meta.cwd}</span>}
|
|
526
|
+
<span style={{ flex: 1 }} />
|
|
527
|
+
<button
|
|
528
|
+
style={styles.shareBtn}
|
|
529
|
+
onClick={shareLink}
|
|
530
|
+
title="Copy a link that opens this workspace (includes the room token)"
|
|
531
|
+
>
|
|
532
|
+
{copied ? "Copied!" : "Share"}
|
|
533
|
+
</button>
|
|
534
|
+
<button style={styles.connectBtn} onClick={disconnect}>
|
|
535
|
+
Disconnect
|
|
536
|
+
</button>
|
|
537
|
+
</header>
|
|
538
|
+
<iframe title="VS Code" src={iframeSrc} style={{ flex: 1, border: "none", width: "100%", background: "#1e1e1e" }} />
|
|
539
|
+
</div>
|
|
540
|
+
</>
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return (
|
|
545
|
+
<>
|
|
546
|
+
{roomClients}
|
|
395
547
|
<div style={styles.page}>
|
|
396
548
|
<header style={styles.header}>
|
|
397
549
|
<span style={styles.brand}>codehost</span>
|
|
398
550
|
<span style={styles.dim}>·</span>
|
|
399
|
-
<span style={styles.dim}>{
|
|
400
|
-
{activeServer?.meta?.cwd && <span style={styles.cwd}>{activeServer.meta.cwd}</span>}
|
|
551
|
+
<span style={styles.dim}>{getSignalUrl()}</span>
|
|
401
552
|
<span style={{ flex: 1 }} />
|
|
402
|
-
<
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
>
|
|
407
|
-
{copied ? "Copied!" : "Share"}
|
|
408
|
-
</button>
|
|
409
|
-
<button style={styles.connectBtn} onClick={disconnect}>
|
|
410
|
-
Disconnect
|
|
411
|
-
</button>
|
|
553
|
+
<span style={{ ...styles.status, color: onlineRooms > 0 ? "#4ec9b0" : "#888" }}>
|
|
554
|
+
{tokens.length === 0
|
|
555
|
+
? "○ no rooms"
|
|
556
|
+
: `${onlineRooms > 0 ? "●" : "○"} ${onlineRooms}/${tokens.length} rooms`}
|
|
557
|
+
</span>
|
|
412
558
|
</header>
|
|
413
|
-
<iframe title="VS Code" src={iframeSrc} style={{ flex: 1, border: "none", width: "100%", background: "#1e1e1e" }} />
|
|
414
|
-
</div>
|
|
415
|
-
);
|
|
416
|
-
}
|
|
417
559
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
{token ? (connected ? "● connected" : "○ connecting…") : "○ no token"}
|
|
427
|
-
</span>
|
|
428
|
-
</header>
|
|
429
|
-
|
|
430
|
-
<main style={styles.main}>
|
|
431
|
-
{resolving && (
|
|
432
|
-
<p style={{ color: "#dcb67a", marginBottom: 12 }}>
|
|
433
|
-
Looking for <code style={styles.code}>{resolving}</code> in your rooms…{" "}
|
|
434
|
-
{token ? "waiting for a live server" : "enter the room's token below"}.
|
|
435
|
-
</p>
|
|
436
|
-
)}
|
|
437
|
-
{token && !editingToken ? (
|
|
560
|
+
<main style={styles.main}>
|
|
561
|
+
{resolving && (
|
|
562
|
+
<p style={{ color: "#dcb67a", marginBottom: 12 }}>
|
|
563
|
+
Looking for <code style={styles.code}>{resolving}</code> in your rooms…{" "}
|
|
564
|
+
{tokens.length > 0 ? "waiting for a live server" : "join the room's token below"}.
|
|
565
|
+
</p>
|
|
566
|
+
)}
|
|
567
|
+
|
|
438
568
|
<div style={styles.tokenForm}>
|
|
439
|
-
<label style={styles.label}>
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
{tokenError ? (
|
|
472
|
-
<p style={styles.tokenError}>{tokenError}</p>
|
|
569
|
+
<label style={styles.label}>Rooms</label>
|
|
570
|
+
{tokens.length > 0 ? (
|
|
571
|
+
<>
|
|
572
|
+
<div style={styles.roomChips}>
|
|
573
|
+
{tokens.map((t) => (
|
|
574
|
+
<span
|
|
575
|
+
key={t}
|
|
576
|
+
style={{ ...styles.chip, ...styles.roomChip, ...(roomOpen[t] ? styles.roomChipOn : {}) }}
|
|
577
|
+
title={roomOpen[t] ? "connected" : "connecting…"}
|
|
578
|
+
>
|
|
579
|
+
{shortRoomLabel(t)}
|
|
580
|
+
<button type="button" style={styles.roomChipX} onClick={() => leaveRoom(t)} title="leave room">
|
|
581
|
+
✕
|
|
582
|
+
</button>
|
|
583
|
+
</span>
|
|
584
|
+
))}
|
|
585
|
+
</div>
|
|
586
|
+
<span style={{ flex: 1 }} />
|
|
587
|
+
{!editingToken && (
|
|
588
|
+
<button
|
|
589
|
+
type="button"
|
|
590
|
+
style={styles.shareBtn}
|
|
591
|
+
onClick={() => {
|
|
592
|
+
setDraft("");
|
|
593
|
+
setTokenError(null);
|
|
594
|
+
setEditingToken(true);
|
|
595
|
+
}}
|
|
596
|
+
>
|
|
597
|
+
+ Add
|
|
598
|
+
</button>
|
|
599
|
+
)}
|
|
600
|
+
</>
|
|
473
601
|
) : (
|
|
474
|
-
<
|
|
602
|
+
<span style={styles.dim}>none joined yet</span>
|
|
475
603
|
)}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
{(activeTags.length > 0 || suggestedTags.length > 0) && (
|
|
505
|
-
<div style={styles.chipRow}>
|
|
506
|
-
{activeTags.map((t) => (
|
|
507
|
-
<button key={t} style={{ ...styles.chip, ...styles.chipActive }} onClick={() => toggleTag(t)}>
|
|
508
|
-
{t} ✕
|
|
604
|
+
</div>
|
|
605
|
+
|
|
606
|
+
{(editingToken || tokens.length === 0) && (
|
|
607
|
+
<>
|
|
608
|
+
<form onSubmit={joinFromInput} style={styles.tokenForm}>
|
|
609
|
+
<input
|
|
610
|
+
value={draft}
|
|
611
|
+
onChange={(e) => {
|
|
612
|
+
setDraft(e.target.value);
|
|
613
|
+
if (tokenError) setTokenError(null);
|
|
614
|
+
}}
|
|
615
|
+
placeholder="paste a room token to join"
|
|
616
|
+
style={styles.input}
|
|
617
|
+
autoFocus={editingToken}
|
|
618
|
+
/>
|
|
619
|
+
<button type="submit" style={styles.button}>
|
|
620
|
+
Join
|
|
621
|
+
</button>
|
|
622
|
+
{editingToken && tokens.length > 0 && (
|
|
623
|
+
<button
|
|
624
|
+
type="button"
|
|
625
|
+
style={styles.shareBtn}
|
|
626
|
+
onClick={() => {
|
|
627
|
+
setEditingToken(false);
|
|
628
|
+
setTokenError(null);
|
|
629
|
+
}}
|
|
630
|
+
>
|
|
631
|
+
Cancel
|
|
509
632
|
</button>
|
|
510
|
-
)
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
633
|
+
)}
|
|
634
|
+
</form>
|
|
635
|
+
{tokenError ? (
|
|
636
|
+
<p style={styles.tokenError}>{tokenError}</p>
|
|
637
|
+
) : (
|
|
638
|
+
<p style={styles.tokenHint}>Token requires {TOKEN_REQUIREMENTS}.</p>
|
|
639
|
+
)}
|
|
640
|
+
</>
|
|
641
|
+
)}
|
|
642
|
+
|
|
643
|
+
<div style={styles.listHead}>
|
|
644
|
+
<h2 style={styles.h2}>Workspaces</h2>
|
|
645
|
+
{serverCount > 0 && (
|
|
646
|
+
<span style={styles.count}>
|
|
647
|
+
{filtered.length === tagged.length
|
|
648
|
+
? `${tagged.length}`
|
|
649
|
+
: `${filtered.length} / ${tagged.length}`}
|
|
650
|
+
</span>
|
|
651
|
+
)}
|
|
652
|
+
</div>
|
|
653
|
+
{tokens.length === 0 && <p style={styles.dim}>Join a room to see your workspaces.</p>}
|
|
654
|
+
{tokens.length > 0 && serverCount === 0 && (
|
|
655
|
+
<p style={styles.dim}>
|
|
656
|
+
No servers online. Run <code style={styles.code}>bunx codehost serve -t <token></code> on a machine.
|
|
657
|
+
</p>
|
|
658
|
+
)}
|
|
659
|
+
{serverCount > 0 && (
|
|
660
|
+
<>
|
|
661
|
+
<input
|
|
662
|
+
value={filter}
|
|
663
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
664
|
+
placeholder="filter… e.g. repo:codehost host:mbp room:ab12 (space = AND)"
|
|
665
|
+
style={styles.search}
|
|
666
|
+
/>
|
|
667
|
+
{(activeTags.length > 0 || suggestedTags.length > 0) && (
|
|
668
|
+
<div style={styles.chipRow}>
|
|
669
|
+
{activeTags.map((t) => (
|
|
670
|
+
<button key={t} style={{ ...styles.chip, ...styles.chipActive }} onClick={() => toggleTag(t)}>
|
|
671
|
+
{t} ✕
|
|
516
672
|
</button>
|
|
517
673
|
))}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
{filtered.map(({ server: s, name, tags }) => {
|
|
524
|
-
const isActive = s.peerId === activePeerId;
|
|
525
|
-
return (
|
|
526
|
-
<li key={s.peerId} style={styles.card}>
|
|
527
|
-
<div style={styles.cardMain}>
|
|
528
|
-
<div style={styles.cardName}>{name}</div>
|
|
529
|
-
<div style={styles.tagRow}>
|
|
530
|
-
{tags.map((tag) => (
|
|
531
|
-
<button key={tag} style={styles.tag} onClick={() => addTag(tag)} title={`filter by ${tag}`}>
|
|
532
|
-
{tag}
|
|
674
|
+
{suggestedTags
|
|
675
|
+
.filter((t) => !activeTags.includes(t))
|
|
676
|
+
.map((t) => (
|
|
677
|
+
<button key={t} style={styles.chip} onClick={() => toggleTag(t)}>
|
|
678
|
+
{t}
|
|
533
679
|
</button>
|
|
534
680
|
))}
|
|
535
|
-
</div>
|
|
536
|
-
<div style={styles.idLine}>peer {s.peerId.slice(0, 8)}</div>
|
|
537
|
-
{isActive && (
|
|
538
|
-
<div style={styles.echo}>
|
|
539
|
-
{connState === "connecting" && "negotiating WebRTC…"}
|
|
540
|
-
{connState === "failed" && "connection failed"}
|
|
541
|
-
</div>
|
|
542
|
-
)}
|
|
543
681
|
</div>
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
onClick={() => connectTo(s)}
|
|
547
|
-
disabled={isActive && connState === "connecting"}
|
|
548
|
-
>
|
|
549
|
-
{isActive && connState === "connecting" ? "…" : "Connect"}
|
|
550
|
-
</button>
|
|
551
|
-
</li>
|
|
552
|
-
);
|
|
553
|
-
})}
|
|
554
|
-
{token && servers.length > 0 && filtered.length === 0 && (
|
|
555
|
-
<p style={styles.dim}>No workspace matches your filter.</p>
|
|
682
|
+
)}
|
|
683
|
+
</>
|
|
556
684
|
)}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
685
|
+
<ul style={styles.list}>
|
|
686
|
+
{filtered.map(({ server: s, room, name, tags }) => {
|
|
687
|
+
const isActive = s.peerId === activePeerId;
|
|
688
|
+
return (
|
|
689
|
+
<li key={s.peerId} style={styles.card}>
|
|
690
|
+
<div style={styles.cardMain}>
|
|
691
|
+
<div style={styles.cardName}>{name}</div>
|
|
692
|
+
<div style={styles.tagRow}>
|
|
693
|
+
{tags.map((tag) => (
|
|
694
|
+
<button key={tag} style={styles.tag} onClick={() => addTag(tag)} title={`filter by ${tag}`}>
|
|
695
|
+
{tag}
|
|
696
|
+
</button>
|
|
697
|
+
))}
|
|
698
|
+
</div>
|
|
699
|
+
<div style={styles.idLine}>peer {s.peerId.slice(0, 8)}</div>
|
|
700
|
+
{isActive && (
|
|
701
|
+
<div style={styles.echo}>
|
|
702
|
+
{connState === "connecting" && "negotiating WebRTC…"}
|
|
703
|
+
{connState === "failed" && "connection failed"}
|
|
704
|
+
</div>
|
|
705
|
+
)}
|
|
706
|
+
</div>
|
|
707
|
+
<button
|
|
708
|
+
style={styles.connectBtn}
|
|
709
|
+
onClick={() => connectTo(s, room)}
|
|
710
|
+
disabled={isActive && connState === "connecting"}
|
|
711
|
+
>
|
|
712
|
+
{isActive && connState === "connecting" ? "…" : "Connect"}
|
|
713
|
+
</button>
|
|
714
|
+
</li>
|
|
715
|
+
);
|
|
716
|
+
})}
|
|
717
|
+
{serverCount > 0 && filtered.length === 0 && (
|
|
718
|
+
<p style={styles.dim}>No workspace matches your filter.</p>
|
|
719
|
+
)}
|
|
720
|
+
</ul>
|
|
721
|
+
</main>
|
|
722
|
+
</div>
|
|
723
|
+
</>
|
|
560
724
|
);
|
|
561
725
|
}
|
|
562
726
|
|
|
@@ -571,7 +735,10 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
571
735
|
tokenHint: { margin: "0 0 20px", fontSize: 12, color: "#888" },
|
|
572
736
|
tokenError: { margin: "0 0 20px", fontSize: 12, color: "#f48771" },
|
|
573
737
|
label: { fontSize: 12, color: "#888" },
|
|
574
|
-
|
|
738
|
+
roomChips: { display: "flex", flexWrap: "wrap", gap: 6, alignItems: "center" },
|
|
739
|
+
roomChip: { display: "inline-flex", alignItems: "center", gap: 6, color: "#9aa4af" },
|
|
740
|
+
roomChipOn: { borderColor: "#0e639c", color: "#4ec9b0" },
|
|
741
|
+
roomChipX: { background: "transparent", border: "none", color: "inherit", cursor: "pointer", fontSize: 11, padding: 0, lineHeight: 1 },
|
|
575
742
|
input: { flex: 1, background: "#252525", border: "1px solid #3d3d3d", color: "#eee", padding: "8px 10px", borderRadius: 6, fontSize: 13, outline: "none" },
|
|
576
743
|
button: { background: "#0e639c", border: "none", color: "#fff", padding: "8px 16px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
|
|
577
744
|
listHead: { display: "flex", alignItems: "baseline", gap: 10, margin: "0 0 12px" },
|
package/src/web/history.ts
CHANGED
|
@@ -43,6 +43,11 @@ export function addRoom(token: string): void {
|
|
|
43
43
|
if (!rooms.includes(token)) write(ROOMS_KEY, [...rooms, token]);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** Replace the persisted joined-room set (deduped, empties dropped). */
|
|
47
|
+
export function setRooms(tokens: string[]): void {
|
|
48
|
+
write(ROOMS_KEY, [...new Set(tokens.filter(Boolean))]);
|
|
49
|
+
}
|
|
50
|
+
|
|
46
51
|
export function getHistory(): Record<string, HistoryEntry> {
|
|
47
52
|
return read<Record<string, HistoryEntry>>(HISTORY_KEY, {});
|
|
48
53
|
}
|