codehost 0.9.1 → 0.10.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/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/src/web/discovery.tsx +361 -236
- package/src/web/history.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.10.0](https://github.com/snomiao/codehost/compare/v0.9.1...v0.10.0) (2026-06-09)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **web:** join multiple rooms at once, merged workspace list ([de5ae61](https://github.com/snomiao/codehost/commit/de5ae610068b6f2a26792ed7d6e67d1fb5c08257))
|
|
7
|
+
|
|
1
8
|
## [0.9.1](https://github.com/snomiao/codehost/compare/v0.9.0...v0.9.1) (2026-06-08)
|
|
2
9
|
|
|
3
10
|
|
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,94 @@ 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());
|
|
130
179
|
|
|
131
180
|
// Deep-link resolution (/gh/<owner>/<repo>/... or /dev/<path>): parse once,
|
|
132
181
|
// auto-connect when a matching server appears, remember the opened folder.
|
|
133
182
|
const deepLinkRef = useRef<DeepLink>(parseDeepLink(window.location.pathname));
|
|
134
183
|
const resolvedRef = useRef(false);
|
|
135
|
-
// A valid token in the URL fragment enables single-server auto-connect
|
|
184
|
+
// A valid token in the URL fragment enables single-server auto-connect, scoped
|
|
185
|
+
// to *that* room so unrelated joined rooms don't block it.
|
|
136
186
|
const autoConnectRef = useRef(false);
|
|
187
|
+
const hashRoomRef = useRef<string | null>(null);
|
|
137
188
|
const activeFolderRef = useRef<string | undefined>(undefined);
|
|
138
189
|
const [resolving, setResolving] = useState<string | null>(() => deepLinkLabel(deepLinkRef.current));
|
|
139
190
|
|
|
@@ -142,6 +193,15 @@ export function Discovery() {
|
|
|
142
193
|
const sharePathRef = useRef<string | null>(null);
|
|
143
194
|
const [copied, setCopied] = useState(false);
|
|
144
195
|
|
|
196
|
+
function adoptRoom(t: string) {
|
|
197
|
+
setTokens((prev) => (prev.includes(t) ? prev : [...prev, t]));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Persist the joined set so rooms survive reloads.
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
setRooms(tokens);
|
|
203
|
+
}, [tokens]);
|
|
204
|
+
|
|
145
205
|
// Register the Service Worker + connection broker once. The broker shares one
|
|
146
206
|
// WebRTC connection per server across tabs; on owner failover it asks us to
|
|
147
207
|
// reload the iframe so it reconnects through the new owner.
|
|
@@ -153,12 +213,13 @@ export function Discovery() {
|
|
|
153
213
|
const folder = activeFolderRef.current;
|
|
154
214
|
setTimeout(() => setIframeSrc(`/vs/${peerId}/${folderQuery(folder)}`), 400);
|
|
155
215
|
});
|
|
156
|
-
// A valid token in the URL fragment (#t=<token>)
|
|
157
|
-
// single-server auto-connect; consume it from the address bar
|
|
158
|
-
// the secret isn't left visible or re-applied on a manual reload.
|
|
216
|
+
// A valid token in the URL fragment (#t=<token>) joins the room and turns on
|
|
217
|
+
// single-server auto-connect for it; consume it from the address bar after,
|
|
218
|
+
// so the secret isn't left visible or re-applied on a manual reload.
|
|
159
219
|
const urlToken = tokenFromHash();
|
|
160
220
|
if (urlToken && validateToken(urlToken).ok) {
|
|
161
221
|
autoConnectRef.current = true;
|
|
222
|
+
hashRoomRef.current = urlToken;
|
|
162
223
|
if (window.location.hash) {
|
|
163
224
|
history.replaceState(null, "", window.location.pathname + window.location.search);
|
|
164
225
|
}
|
|
@@ -171,56 +232,26 @@ export function Discovery() {
|
|
|
171
232
|
if (dl && !(urlToken && validateToken(urlToken).ok)) {
|
|
172
233
|
const histToken = dl.type === "repo" ? historyFor(repoKey(dl.target))?.token : undefined;
|
|
173
234
|
if (histToken) {
|
|
174
|
-
|
|
235
|
+
adoptRoom(histToken);
|
|
175
236
|
} else {
|
|
176
237
|
const rooms = getRooms();
|
|
177
238
|
if (rooms.length) {
|
|
178
239
|
void findRoomForDeepLink(dl, rooms).then((tok) => {
|
|
179
|
-
if (tok)
|
|
240
|
+
if (tok) adoptRoom(tok);
|
|
180
241
|
});
|
|
181
242
|
}
|
|
182
243
|
}
|
|
183
244
|
}
|
|
184
245
|
}, []);
|
|
185
246
|
|
|
247
|
+
// Auto-connect once discovery turns up a match: a deep-link target across any
|
|
248
|
+
// room, or the lone server of a room joined via #t=.
|
|
186
249
|
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]);
|
|
250
|
+
if (resolvedRef.current) return;
|
|
251
|
+
tryAutoConnect();
|
|
252
|
+
}, [serversByRoom, tokens]);
|
|
222
253
|
|
|
223
|
-
function
|
|
254
|
+
function joinFromInput(e: React.FormEvent) {
|
|
224
255
|
e.preventDefault();
|
|
225
256
|
const t = draft.trim();
|
|
226
257
|
const check = validateToken(t);
|
|
@@ -229,21 +260,37 @@ export function Discovery() {
|
|
|
229
260
|
return;
|
|
230
261
|
}
|
|
231
262
|
setTokenError(null);
|
|
232
|
-
|
|
233
|
-
setToken(t);
|
|
263
|
+
adoptRoom(t);
|
|
234
264
|
setDraft("");
|
|
235
265
|
setEditingToken(false);
|
|
236
266
|
}
|
|
237
267
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
268
|
+
function leaveRoom(t: string) {
|
|
269
|
+
if (activeRoomRef.current === t) disconnect();
|
|
270
|
+
setTokens((prev) => prev.filter((x) => x !== t));
|
|
271
|
+
setServersByRoom((m) => {
|
|
272
|
+
const n = { ...m };
|
|
273
|
+
delete n[t];
|
|
274
|
+
return n;
|
|
275
|
+
});
|
|
276
|
+
setRoomOpen((m) => {
|
|
277
|
+
const n = { ...m };
|
|
278
|
+
delete n[t];
|
|
279
|
+
return n;
|
|
280
|
+
});
|
|
281
|
+
sendersRef.current.delete(t);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function connectTo(server: PeerInfo, room: string, folder?: string) {
|
|
285
|
+
const send = sendersRef.current.get(room);
|
|
286
|
+
if (!send) return;
|
|
241
287
|
|
|
242
288
|
rtcRef.current?.close();
|
|
243
289
|
rtcRef.current = null;
|
|
244
290
|
setIframeSrc(null);
|
|
245
291
|
setActivePeerId(server.peerId);
|
|
246
292
|
activePeerRef.current = server.peerId;
|
|
293
|
+
activeRoomRef.current = room;
|
|
247
294
|
setConnState("connecting");
|
|
248
295
|
|
|
249
296
|
// The broker decides whether this tab owns the connection. `establish` is
|
|
@@ -252,7 +299,7 @@ export function Discovery() {
|
|
|
252
299
|
const establish = () =>
|
|
253
300
|
new Promise<RTCDataChannel>((resolve, reject) => {
|
|
254
301
|
const rtc = new RtcClient({
|
|
255
|
-
sendSignal: (data: RtcSignal) =>
|
|
302
|
+
sendSignal: (data: RtcSignal) => send(server.peerId, data),
|
|
256
303
|
onState: (state) => {
|
|
257
304
|
if (state === "failed" || state === "disconnected") setConnState("failed");
|
|
258
305
|
},
|
|
@@ -285,7 +332,7 @@ export function Discovery() {
|
|
|
285
332
|
activeFolderRef.current = openFolder;
|
|
286
333
|
setIframeSrc(`/vs/${server.peerId}/${folderQuery(openFolder)}`);
|
|
287
334
|
setResolving(null);
|
|
288
|
-
recordConnect(server, openFolder);
|
|
335
|
+
recordConnect(server, room, openFolder);
|
|
289
336
|
updateAddressBar(server, openFolder);
|
|
290
337
|
} catch {
|
|
291
338
|
setConnState("failed");
|
|
@@ -305,8 +352,10 @@ export function Discovery() {
|
|
|
305
352
|
}
|
|
306
353
|
|
|
307
354
|
async function shareLink() {
|
|
355
|
+
const room = activeRoomRef.current;
|
|
356
|
+
if (!room) return;
|
|
308
357
|
const path = sharePathRef.current ?? window.location.pathname;
|
|
309
|
-
const url = `${window.location.origin}${path}#t=${encodeURIComponent(
|
|
358
|
+
const url = `${window.location.origin}${path}#t=${encodeURIComponent(room)}`;
|
|
310
359
|
try {
|
|
311
360
|
await navigator.clipboard.writeText(url);
|
|
312
361
|
} catch {
|
|
@@ -318,30 +367,36 @@ export function Discovery() {
|
|
|
318
367
|
}
|
|
319
368
|
|
|
320
369
|
// 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
|
-
|
|
370
|
+
// daemon, else a root daemon's subfolder) across all rooms and open it once.
|
|
371
|
+
function tryAutoConnect() {
|
|
323
372
|
if (resolvedRef.current) return;
|
|
324
373
|
const dl = deepLinkRef.current;
|
|
325
374
|
if (dl) {
|
|
326
|
-
const
|
|
375
|
+
const peers = allServers.map((x) => x.server);
|
|
376
|
+
const res = dl.type === "repo" ? resolveRepoTarget(peers, dl.target) : resolveDevTarget(peers, dl.target);
|
|
327
377
|
if (!res) return;
|
|
328
|
-
const
|
|
329
|
-
if (!
|
|
378
|
+
const match = allServers.find((x) => x.server.peerId === res.peerId);
|
|
379
|
+
if (!match) return;
|
|
330
380
|
resolvedRef.current = true;
|
|
331
|
-
|
|
381
|
+
void connectTo(match.server, match.room, res.folder);
|
|
332
382
|
return;
|
|
333
383
|
}
|
|
334
|
-
// No deep link, but a token arrived via the URL: open
|
|
335
|
-
// straight away when
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
384
|
+
// No deep link, but a token arrived via the URL: open that room's server
|
|
385
|
+
// straight away when it has exactly one. Scoped to the hash room so servers
|
|
386
|
+
// in other joined rooms can't push the count past one and block it.
|
|
387
|
+
const hashRoom = hashRoomRef.current;
|
|
388
|
+
if (autoConnectRef.current && hashRoom) {
|
|
389
|
+
const inRoom = allServers.filter((x) => x.room === hashRoom);
|
|
390
|
+
if (inRoom.length === 1) {
|
|
391
|
+
resolvedRef.current = true;
|
|
392
|
+
void connectTo(inRoom[0].server, inRoom[0].room);
|
|
393
|
+
}
|
|
339
394
|
}
|
|
340
395
|
}
|
|
341
396
|
|
|
342
|
-
function recordConnect(server: PeerInfo, folder?: string) {
|
|
397
|
+
function recordConnect(server: PeerInfo, room: string, folder?: string) {
|
|
343
398
|
const base = {
|
|
344
|
-
token,
|
|
399
|
+
token: room,
|
|
345
400
|
peerId: server.peerId,
|
|
346
401
|
kind: server.meta?.kind,
|
|
347
402
|
name: server.meta?.name,
|
|
@@ -360,20 +415,28 @@ export function Discovery() {
|
|
|
360
415
|
setIframeSrc(null);
|
|
361
416
|
setActivePeerId(null);
|
|
362
417
|
activePeerRef.current = null;
|
|
418
|
+
activeRoomRef.current = null;
|
|
363
419
|
setConnState("idle");
|
|
364
420
|
sharePathRef.current = null;
|
|
365
421
|
if (window.location.pathname !== "/") history.replaceState(null, "", "/");
|
|
366
422
|
}
|
|
367
423
|
|
|
368
|
-
|
|
424
|
+
// Merge every room's servers into one list, each tagged with its room so the
|
|
425
|
+
// Connect button knows which client to signal through.
|
|
426
|
+
const allServers: RoomedServer[] = tokens.flatMap((t) =>
|
|
427
|
+
(serversByRoom[t] ?? []).map((server) => ({ server, room: t })),
|
|
428
|
+
);
|
|
429
|
+
const serverCount = allServers.length;
|
|
430
|
+
const onlineRooms = tokens.filter((t) => roomOpen[t]).length;
|
|
431
|
+
const activeServer = allServers.find((x) => x.server.peerId === activePeerId)?.server;
|
|
369
432
|
|
|
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) => ({
|
|
433
|
+
// Annotate each server with its mnemonic fake-tags (incl. its room label), then
|
|
434
|
+
// filter. The room token is hashed to a short label — never rendered raw.
|
|
435
|
+
const tagged = allServers.map(({ server: s, room }) => ({
|
|
374
436
|
server: s,
|
|
437
|
+
room,
|
|
375
438
|
name: s.meta?.name ?? s.peerId.slice(0, 8),
|
|
376
|
-
tags: deriveTags(s.meta, { roomLabel }),
|
|
439
|
+
tags: deriveTags(s.meta, { roomLabel: shortRoomLabel(room) }),
|
|
377
440
|
}));
|
|
378
441
|
const query = [...activeTags, filter].join(" ");
|
|
379
442
|
const filtered = tagged.filter((t) => matchQuery({ name: t.name, tags: t.tags }, query));
|
|
@@ -389,174 +452,233 @@ export function Discovery() {
|
|
|
389
452
|
.filter((t) => ["host", "repo", "wt", "kind", "room"].includes(tagKey(t)))
|
|
390
453
|
.slice(0, 12);
|
|
391
454
|
|
|
455
|
+
// Headless signaling clients, one per joined room. Kept mounted across BOTH
|
|
456
|
+
// views so switching into the iframe never tears down discovery/session.
|
|
457
|
+
const roomClients = tokens.map((t) => (
|
|
458
|
+
<RoomClient
|
|
459
|
+
key={t}
|
|
460
|
+
token={t}
|
|
461
|
+
onPeers={(peers) => setServersByRoom((m) => ({ ...m, [t]: peers }))}
|
|
462
|
+
onStatus={(open) => setRoomOpen((m) => ({ ...m, [t]: open }))}
|
|
463
|
+
onSignal={(from, data) => {
|
|
464
|
+
if (from === activePeerRef.current) void rtcRef.current?.handleSignal(data);
|
|
465
|
+
}}
|
|
466
|
+
registerSender={(send) => {
|
|
467
|
+
if (send) sendersRef.current.set(t, send);
|
|
468
|
+
else sendersRef.current.delete(t);
|
|
469
|
+
}}
|
|
470
|
+
/>
|
|
471
|
+
));
|
|
472
|
+
|
|
392
473
|
// Connected view: VS Code in an iframe, served over the tunnel.
|
|
393
474
|
if (iframeSrc && connState === "connected") {
|
|
394
475
|
return (
|
|
476
|
+
<>
|
|
477
|
+
{roomClients}
|
|
478
|
+
<div style={styles.page}>
|
|
479
|
+
<header style={styles.header}>
|
|
480
|
+
<span style={styles.brand}>codehost</span>
|
|
481
|
+
<span style={styles.dim}>·</span>
|
|
482
|
+
<span style={styles.dim}>{activeServer?.meta?.name ?? activePeerId?.slice(0, 8)}</span>
|
|
483
|
+
{activeServer?.meta?.cwd && <span style={styles.cwd}>{activeServer.meta.cwd}</span>}
|
|
484
|
+
<span style={{ flex: 1 }} />
|
|
485
|
+
<button
|
|
486
|
+
style={styles.shareBtn}
|
|
487
|
+
onClick={shareLink}
|
|
488
|
+
title="Copy a link that opens this workspace (includes the room token)"
|
|
489
|
+
>
|
|
490
|
+
{copied ? "Copied!" : "Share"}
|
|
491
|
+
</button>
|
|
492
|
+
<button style={styles.connectBtn} onClick={disconnect}>
|
|
493
|
+
Disconnect
|
|
494
|
+
</button>
|
|
495
|
+
</header>
|
|
496
|
+
<iframe title="VS Code" src={iframeSrc} style={{ flex: 1, border: "none", width: "100%", background: "#1e1e1e" }} />
|
|
497
|
+
</div>
|
|
498
|
+
</>
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return (
|
|
503
|
+
<>
|
|
504
|
+
{roomClients}
|
|
395
505
|
<div style={styles.page}>
|
|
396
506
|
<header style={styles.header}>
|
|
397
507
|
<span style={styles.brand}>codehost</span>
|
|
398
508
|
<span style={styles.dim}>·</span>
|
|
399
|
-
<span style={styles.dim}>{
|
|
400
|
-
{activeServer?.meta?.cwd && <span style={styles.cwd}>{activeServer.meta.cwd}</span>}
|
|
509
|
+
<span style={styles.dim}>{getSignalUrl()}</span>
|
|
401
510
|
<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>
|
|
511
|
+
<span style={{ ...styles.status, color: onlineRooms > 0 ? "#4ec9b0" : "#888" }}>
|
|
512
|
+
{tokens.length === 0
|
|
513
|
+
? "○ no rooms"
|
|
514
|
+
: `${onlineRooms > 0 ? "●" : "○"} ${onlineRooms}/${tokens.length} rooms`}
|
|
515
|
+
</span>
|
|
412
516
|
</header>
|
|
413
|
-
<iframe title="VS Code" src={iframeSrc} style={{ flex: 1, border: "none", width: "100%", background: "#1e1e1e" }} />
|
|
414
|
-
</div>
|
|
415
|
-
);
|
|
416
|
-
}
|
|
417
517
|
|
|
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 ? (
|
|
518
|
+
<main style={styles.main}>
|
|
519
|
+
{resolving && (
|
|
520
|
+
<p style={{ color: "#dcb67a", marginBottom: 12 }}>
|
|
521
|
+
Looking for <code style={styles.code}>{resolving}</code> in your rooms…{" "}
|
|
522
|
+
{tokens.length > 0 ? "waiting for a live server" : "join the room's token below"}.
|
|
523
|
+
</p>
|
|
524
|
+
)}
|
|
525
|
+
|
|
438
526
|
<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>
|
|
527
|
+
<label style={styles.label}>Rooms</label>
|
|
528
|
+
{tokens.length > 0 ? (
|
|
529
|
+
<>
|
|
530
|
+
<div style={styles.roomChips}>
|
|
531
|
+
{tokens.map((t) => (
|
|
532
|
+
<span
|
|
533
|
+
key={t}
|
|
534
|
+
style={{ ...styles.chip, ...styles.roomChip, ...(roomOpen[t] ? styles.roomChipOn : {}) }}
|
|
535
|
+
title={roomOpen[t] ? "connected" : "connecting…"}
|
|
536
|
+
>
|
|
537
|
+
{shortRoomLabel(t)}
|
|
538
|
+
<button type="button" style={styles.roomChipX} onClick={() => leaveRoom(t)} title="leave room">
|
|
539
|
+
✕
|
|
540
|
+
</button>
|
|
541
|
+
</span>
|
|
542
|
+
))}
|
|
543
|
+
</div>
|
|
544
|
+
<span style={{ flex: 1 }} />
|
|
545
|
+
{!editingToken && (
|
|
546
|
+
<button
|
|
547
|
+
type="button"
|
|
548
|
+
style={styles.shareBtn}
|
|
549
|
+
onClick={() => {
|
|
550
|
+
setDraft("");
|
|
551
|
+
setTokenError(null);
|
|
552
|
+
setEditingToken(true);
|
|
553
|
+
}}
|
|
554
|
+
>
|
|
555
|
+
+ Add
|
|
556
|
+
</button>
|
|
557
|
+
)}
|
|
558
|
+
</>
|
|
473
559
|
) : (
|
|
474
|
-
<
|
|
560
|
+
<span style={styles.dim}>none joined yet</span>
|
|
475
561
|
)}
|
|
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} ✕
|
|
562
|
+
</div>
|
|
563
|
+
|
|
564
|
+
{(editingToken || tokens.length === 0) && (
|
|
565
|
+
<>
|
|
566
|
+
<form onSubmit={joinFromInput} style={styles.tokenForm}>
|
|
567
|
+
<input
|
|
568
|
+
value={draft}
|
|
569
|
+
onChange={(e) => {
|
|
570
|
+
setDraft(e.target.value);
|
|
571
|
+
if (tokenError) setTokenError(null);
|
|
572
|
+
}}
|
|
573
|
+
placeholder="paste a room token to join"
|
|
574
|
+
style={styles.input}
|
|
575
|
+
autoFocus={editingToken}
|
|
576
|
+
/>
|
|
577
|
+
<button type="submit" style={styles.button}>
|
|
578
|
+
Join
|
|
579
|
+
</button>
|
|
580
|
+
{editingToken && tokens.length > 0 && (
|
|
581
|
+
<button
|
|
582
|
+
type="button"
|
|
583
|
+
style={styles.shareBtn}
|
|
584
|
+
onClick={() => {
|
|
585
|
+
setEditingToken(false);
|
|
586
|
+
setTokenError(null);
|
|
587
|
+
}}
|
|
588
|
+
>
|
|
589
|
+
Cancel
|
|
509
590
|
</button>
|
|
510
|
-
)
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
591
|
+
)}
|
|
592
|
+
</form>
|
|
593
|
+
{tokenError ? (
|
|
594
|
+
<p style={styles.tokenError}>{tokenError}</p>
|
|
595
|
+
) : (
|
|
596
|
+
<p style={styles.tokenHint}>Token requires {TOKEN_REQUIREMENTS}.</p>
|
|
597
|
+
)}
|
|
598
|
+
</>
|
|
599
|
+
)}
|
|
600
|
+
|
|
601
|
+
<div style={styles.listHead}>
|
|
602
|
+
<h2 style={styles.h2}>Workspaces</h2>
|
|
603
|
+
{serverCount > 0 && (
|
|
604
|
+
<span style={styles.count}>
|
|
605
|
+
{filtered.length === tagged.length
|
|
606
|
+
? `${tagged.length}`
|
|
607
|
+
: `${filtered.length} / ${tagged.length}`}
|
|
608
|
+
</span>
|
|
609
|
+
)}
|
|
610
|
+
</div>
|
|
611
|
+
{tokens.length === 0 && <p style={styles.dim}>Join a room to see your workspaces.</p>}
|
|
612
|
+
{tokens.length > 0 && serverCount === 0 && (
|
|
613
|
+
<p style={styles.dim}>
|
|
614
|
+
No servers online. Run <code style={styles.code}>bunx codehost serve -t <token></code> on a machine.
|
|
615
|
+
</p>
|
|
616
|
+
)}
|
|
617
|
+
{serverCount > 0 && (
|
|
618
|
+
<>
|
|
619
|
+
<input
|
|
620
|
+
value={filter}
|
|
621
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
622
|
+
placeholder="filter… e.g. repo:codehost host:mbp room:ab12 (space = AND)"
|
|
623
|
+
style={styles.search}
|
|
624
|
+
/>
|
|
625
|
+
{(activeTags.length > 0 || suggestedTags.length > 0) && (
|
|
626
|
+
<div style={styles.chipRow}>
|
|
627
|
+
{activeTags.map((t) => (
|
|
628
|
+
<button key={t} style={{ ...styles.chip, ...styles.chipActive }} onClick={() => toggleTag(t)}>
|
|
629
|
+
{t} ✕
|
|
516
630
|
</button>
|
|
517
631
|
))}
|
|
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}
|
|
632
|
+
{suggestedTags
|
|
633
|
+
.filter((t) => !activeTags.includes(t))
|
|
634
|
+
.map((t) => (
|
|
635
|
+
<button key={t} style={styles.chip} onClick={() => toggleTag(t)}>
|
|
636
|
+
{t}
|
|
533
637
|
</button>
|
|
534
638
|
))}
|
|
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
639
|
</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>
|
|
640
|
+
)}
|
|
641
|
+
</>
|
|
556
642
|
)}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
643
|
+
<ul style={styles.list}>
|
|
644
|
+
{filtered.map(({ server: s, room, name, tags }) => {
|
|
645
|
+
const isActive = s.peerId === activePeerId;
|
|
646
|
+
return (
|
|
647
|
+
<li key={s.peerId} style={styles.card}>
|
|
648
|
+
<div style={styles.cardMain}>
|
|
649
|
+
<div style={styles.cardName}>{name}</div>
|
|
650
|
+
<div style={styles.tagRow}>
|
|
651
|
+
{tags.map((tag) => (
|
|
652
|
+
<button key={tag} style={styles.tag} onClick={() => addTag(tag)} title={`filter by ${tag}`}>
|
|
653
|
+
{tag}
|
|
654
|
+
</button>
|
|
655
|
+
))}
|
|
656
|
+
</div>
|
|
657
|
+
<div style={styles.idLine}>peer {s.peerId.slice(0, 8)}</div>
|
|
658
|
+
{isActive && (
|
|
659
|
+
<div style={styles.echo}>
|
|
660
|
+
{connState === "connecting" && "negotiating WebRTC…"}
|
|
661
|
+
{connState === "failed" && "connection failed"}
|
|
662
|
+
</div>
|
|
663
|
+
)}
|
|
664
|
+
</div>
|
|
665
|
+
<button
|
|
666
|
+
style={styles.connectBtn}
|
|
667
|
+
onClick={() => connectTo(s, room)}
|
|
668
|
+
disabled={isActive && connState === "connecting"}
|
|
669
|
+
>
|
|
670
|
+
{isActive && connState === "connecting" ? "…" : "Connect"}
|
|
671
|
+
</button>
|
|
672
|
+
</li>
|
|
673
|
+
);
|
|
674
|
+
})}
|
|
675
|
+
{serverCount > 0 && filtered.length === 0 && (
|
|
676
|
+
<p style={styles.dim}>No workspace matches your filter.</p>
|
|
677
|
+
)}
|
|
678
|
+
</ul>
|
|
679
|
+
</main>
|
|
680
|
+
</div>
|
|
681
|
+
</>
|
|
560
682
|
);
|
|
561
683
|
}
|
|
562
684
|
|
|
@@ -571,7 +693,10 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
571
693
|
tokenHint: { margin: "0 0 20px", fontSize: 12, color: "#888" },
|
|
572
694
|
tokenError: { margin: "0 0 20px", fontSize: 12, color: "#f48771" },
|
|
573
695
|
label: { fontSize: 12, color: "#888" },
|
|
574
|
-
|
|
696
|
+
roomChips: { display: "flex", flexWrap: "wrap", gap: 6, alignItems: "center" },
|
|
697
|
+
roomChip: { display: "inline-flex", alignItems: "center", gap: 6, color: "#9aa4af" },
|
|
698
|
+
roomChipOn: { borderColor: "#0e639c", color: "#4ec9b0" },
|
|
699
|
+
roomChipX: { background: "transparent", border: "none", color: "inherit", cursor: "pointer", fontSize: 11, padding: 0, lineHeight: 1 },
|
|
575
700
|
input: { flex: 1, background: "#252525", border: "1px solid #3d3d3d", color: "#eee", padding: "8px 10px", borderRadius: 6, fontSize: 13, outline: "none" },
|
|
576
701
|
button: { background: "#0e639c", border: "none", color: "#fff", padding: "8px 16px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
|
|
577
702
|
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
|
}
|