codehost 0.9.0 → 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 +14 -0
- package/package.json +1 -1
- package/src/web/discovery.tsx +367 -214
- package/src/web/history.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
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
|
+
|
|
8
|
+
## [0.9.1](https://github.com/snomiao/codehost/compare/v0.9.0...v0.9.1) (2026-06-08)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **web:** stop rendering raw room token in the input ([abf7ea5](https://github.com/snomiao/codehost/commit/abf7ea5abd96f156f178f5f5c8ddb0e31f8fdd81))
|
|
14
|
+
|
|
1
15
|
# [0.9.0](https://github.com/snomiao/codehost/compare/v0.8.0...v0.9.0) (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,42 +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
|
-
|
|
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.
|
|
159
|
+
const [draft, setDraft] = useState("");
|
|
160
|
+
const [editingToken, setEditingToken] = useState(false);
|
|
107
161
|
const [tokenError, setTokenError] = useState<string | null>(null);
|
|
108
|
-
const [connected, setConnected] = useState(false);
|
|
109
|
-
const [servers, setServers] = useState<PeerInfo[]>([]);
|
|
110
162
|
|
|
111
|
-
// Fake-tag filter over the workspace list: a free-text box plus a set
|
|
112
|
-
// 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.
|
|
113
165
|
const [filter, setFilter] = useState("");
|
|
114
166
|
const [activeTags, setActiveTags] = useState<string[]>([]);
|
|
115
167
|
|
|
116
|
-
//
|
|
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.
|
|
117
171
|
const [activePeerId, setActivePeerId] = useState<string | null>(null);
|
|
118
172
|
const [connState, setConnState] = useState<ConnState>("idle");
|
|
119
|
-
|
|
120
|
-
// Once a server's data channel is open we mount its VS Code in an iframe.
|
|
121
173
|
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
|
|
122
174
|
|
|
123
|
-
const clientRef = useRef<SignalingClient | null>(null);
|
|
124
175
|
const rtcRef = useRef<RtcClient | null>(null);
|
|
125
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());
|
|
126
179
|
|
|
127
180
|
// Deep-link resolution (/gh/<owner>/<repo>/... or /dev/<path>): parse once,
|
|
128
181
|
// auto-connect when a matching server appears, remember the opened folder.
|
|
129
182
|
const deepLinkRef = useRef<DeepLink>(parseDeepLink(window.location.pathname));
|
|
130
183
|
const resolvedRef = useRef(false);
|
|
131
|
-
// 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.
|
|
132
186
|
const autoConnectRef = useRef(false);
|
|
187
|
+
const hashRoomRef = useRef<string | null>(null);
|
|
133
188
|
const activeFolderRef = useRef<string | undefined>(undefined);
|
|
134
189
|
const [resolving, setResolving] = useState<string | null>(() => deepLinkLabel(deepLinkRef.current));
|
|
135
190
|
|
|
@@ -138,6 +193,15 @@ export function Discovery() {
|
|
|
138
193
|
const sharePathRef = useRef<string | null>(null);
|
|
139
194
|
const [copied, setCopied] = useState(false);
|
|
140
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
|
+
|
|
141
205
|
// Register the Service Worker + connection broker once. The broker shares one
|
|
142
206
|
// WebRTC connection per server across tabs; on owner failover it asks us to
|
|
143
207
|
// reload the iframe so it reconnects through the new owner.
|
|
@@ -149,12 +213,13 @@ export function Discovery() {
|
|
|
149
213
|
const folder = activeFolderRef.current;
|
|
150
214
|
setTimeout(() => setIframeSrc(`/vs/${peerId}/${folderQuery(folder)}`), 400);
|
|
151
215
|
});
|
|
152
|
-
// A valid token in the URL fragment (#t=<token>)
|
|
153
|
-
// single-server auto-connect; consume it from the address bar
|
|
154
|
-
// 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.
|
|
155
219
|
const urlToken = tokenFromHash();
|
|
156
220
|
if (urlToken && validateToken(urlToken).ok) {
|
|
157
221
|
autoConnectRef.current = true;
|
|
222
|
+
hashRoomRef.current = urlToken;
|
|
158
223
|
if (window.location.hash) {
|
|
159
224
|
history.replaceState(null, "", window.location.pathname + window.location.search);
|
|
160
225
|
}
|
|
@@ -167,56 +232,26 @@ export function Discovery() {
|
|
|
167
232
|
if (dl && !(urlToken && validateToken(urlToken).ok)) {
|
|
168
233
|
const histToken = dl.type === "repo" ? historyFor(repoKey(dl.target))?.token : undefined;
|
|
169
234
|
if (histToken) {
|
|
170
|
-
|
|
235
|
+
adoptRoom(histToken);
|
|
171
236
|
} else {
|
|
172
237
|
const rooms = getRooms();
|
|
173
238
|
if (rooms.length) {
|
|
174
239
|
void findRoomForDeepLink(dl, rooms).then((tok) => {
|
|
175
|
-
if (tok)
|
|
240
|
+
if (tok) adoptRoom(tok);
|
|
176
241
|
});
|
|
177
242
|
}
|
|
178
243
|
}
|
|
179
244
|
}
|
|
180
245
|
}, []);
|
|
181
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=.
|
|
182
249
|
useEffect(() => {
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
token,
|
|
187
|
-
role: "viewer",
|
|
188
|
-
onOpen: () => {
|
|
189
|
-
setConnected(true);
|
|
190
|
-
addRoom(token);
|
|
191
|
-
},
|
|
192
|
-
onClose: () => setConnected(false),
|
|
193
|
-
onPeers: (peers) => {
|
|
194
|
-
const list = peers.filter((p) => p.role === "server");
|
|
195
|
-
setServers(list);
|
|
196
|
-
void tryAutoConnect(list);
|
|
197
|
-
},
|
|
198
|
-
onSignal: (from, data) => {
|
|
199
|
-
if (from === activePeerRef.current) void rtcRef.current?.handleSignal(data);
|
|
200
|
-
},
|
|
201
|
-
});
|
|
202
|
-
clientRef.current = client;
|
|
203
|
-
client.connect();
|
|
204
|
-
return () => {
|
|
205
|
-
rtcRef.current?.close();
|
|
206
|
-
rtcRef.current = null;
|
|
207
|
-
if (activePeerRef.current) connBroker.disconnect(activePeerRef.current);
|
|
208
|
-
client.close();
|
|
209
|
-
clientRef.current = null;
|
|
210
|
-
setConnected(false);
|
|
211
|
-
setServers([]);
|
|
212
|
-
setActivePeerId(null);
|
|
213
|
-
activePeerRef.current = null;
|
|
214
|
-
setConnState("idle");
|
|
215
|
-
setIframeSrc(null);
|
|
216
|
-
};
|
|
217
|
-
}, [token]);
|
|
250
|
+
if (resolvedRef.current) return;
|
|
251
|
+
tryAutoConnect();
|
|
252
|
+
}, [serversByRoom, tokens]);
|
|
218
253
|
|
|
219
|
-
function
|
|
254
|
+
function joinFromInput(e: React.FormEvent) {
|
|
220
255
|
e.preventDefault();
|
|
221
256
|
const t = draft.trim();
|
|
222
257
|
const check = validateToken(t);
|
|
@@ -225,19 +260,37 @@ export function Discovery() {
|
|
|
225
260
|
return;
|
|
226
261
|
}
|
|
227
262
|
setTokenError(null);
|
|
228
|
-
|
|
229
|
-
|
|
263
|
+
adoptRoom(t);
|
|
264
|
+
setDraft("");
|
|
265
|
+
setEditingToken(false);
|
|
230
266
|
}
|
|
231
267
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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;
|
|
235
287
|
|
|
236
288
|
rtcRef.current?.close();
|
|
237
289
|
rtcRef.current = null;
|
|
238
290
|
setIframeSrc(null);
|
|
239
291
|
setActivePeerId(server.peerId);
|
|
240
292
|
activePeerRef.current = server.peerId;
|
|
293
|
+
activeRoomRef.current = room;
|
|
241
294
|
setConnState("connecting");
|
|
242
295
|
|
|
243
296
|
// The broker decides whether this tab owns the connection. `establish` is
|
|
@@ -246,7 +299,7 @@ export function Discovery() {
|
|
|
246
299
|
const establish = () =>
|
|
247
300
|
new Promise<RTCDataChannel>((resolve, reject) => {
|
|
248
301
|
const rtc = new RtcClient({
|
|
249
|
-
sendSignal: (data: RtcSignal) =>
|
|
302
|
+
sendSignal: (data: RtcSignal) => send(server.peerId, data),
|
|
250
303
|
onState: (state) => {
|
|
251
304
|
if (state === "failed" || state === "disconnected") setConnState("failed");
|
|
252
305
|
},
|
|
@@ -279,7 +332,7 @@ export function Discovery() {
|
|
|
279
332
|
activeFolderRef.current = openFolder;
|
|
280
333
|
setIframeSrc(`/vs/${server.peerId}/${folderQuery(openFolder)}`);
|
|
281
334
|
setResolving(null);
|
|
282
|
-
recordConnect(server, openFolder);
|
|
335
|
+
recordConnect(server, room, openFolder);
|
|
283
336
|
updateAddressBar(server, openFolder);
|
|
284
337
|
} catch {
|
|
285
338
|
setConnState("failed");
|
|
@@ -299,8 +352,10 @@ export function Discovery() {
|
|
|
299
352
|
}
|
|
300
353
|
|
|
301
354
|
async function shareLink() {
|
|
355
|
+
const room = activeRoomRef.current;
|
|
356
|
+
if (!room) return;
|
|
302
357
|
const path = sharePathRef.current ?? window.location.pathname;
|
|
303
|
-
const url = `${window.location.origin}${path}#t=${encodeURIComponent(
|
|
358
|
+
const url = `${window.location.origin}${path}#t=${encodeURIComponent(room)}`;
|
|
304
359
|
try {
|
|
305
360
|
await navigator.clipboard.writeText(url);
|
|
306
361
|
} catch {
|
|
@@ -312,30 +367,36 @@ export function Discovery() {
|
|
|
312
367
|
}
|
|
313
368
|
|
|
314
369
|
// Deep-link auto-connect: when servers arrive, pick the best match (exact repo
|
|
315
|
-
// daemon, else a root daemon's subfolder) and open it once.
|
|
316
|
-
|
|
370
|
+
// daemon, else a root daemon's subfolder) across all rooms and open it once.
|
|
371
|
+
function tryAutoConnect() {
|
|
317
372
|
if (resolvedRef.current) return;
|
|
318
373
|
const dl = deepLinkRef.current;
|
|
319
374
|
if (dl) {
|
|
320
|
-
const
|
|
375
|
+
const peers = allServers.map((x) => x.server);
|
|
376
|
+
const res = dl.type === "repo" ? resolveRepoTarget(peers, dl.target) : resolveDevTarget(peers, dl.target);
|
|
321
377
|
if (!res) return;
|
|
322
|
-
const
|
|
323
|
-
if (!
|
|
378
|
+
const match = allServers.find((x) => x.server.peerId === res.peerId);
|
|
379
|
+
if (!match) return;
|
|
324
380
|
resolvedRef.current = true;
|
|
325
|
-
|
|
381
|
+
void connectTo(match.server, match.room, res.folder);
|
|
326
382
|
return;
|
|
327
383
|
}
|
|
328
|
-
// No deep link, but a token arrived via the URL: open
|
|
329
|
-
// straight away when
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
+
}
|
|
333
394
|
}
|
|
334
395
|
}
|
|
335
396
|
|
|
336
|
-
function recordConnect(server: PeerInfo, folder?: string) {
|
|
397
|
+
function recordConnect(server: PeerInfo, room: string, folder?: string) {
|
|
337
398
|
const base = {
|
|
338
|
-
token,
|
|
399
|
+
token: room,
|
|
339
400
|
peerId: server.peerId,
|
|
340
401
|
kind: server.meta?.kind,
|
|
341
402
|
name: server.meta?.name,
|
|
@@ -354,20 +415,28 @@ export function Discovery() {
|
|
|
354
415
|
setIframeSrc(null);
|
|
355
416
|
setActivePeerId(null);
|
|
356
417
|
activePeerRef.current = null;
|
|
418
|
+
activeRoomRef.current = null;
|
|
357
419
|
setConnState("idle");
|
|
358
420
|
sharePathRef.current = null;
|
|
359
421
|
if (window.location.pathname !== "/") history.replaceState(null, "", "/");
|
|
360
422
|
}
|
|
361
423
|
|
|
362
|
-
|
|
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;
|
|
363
432
|
|
|
364
|
-
// Annotate each server with its mnemonic fake-tags
|
|
365
|
-
// token is hashed to a short label — never rendered raw
|
|
366
|
-
const
|
|
367
|
-
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 }) => ({
|
|
368
436
|
server: s,
|
|
437
|
+
room,
|
|
369
438
|
name: s.meta?.name ?? s.peerId.slice(0, 8),
|
|
370
|
-
tags: deriveTags(s.meta, { roomLabel }),
|
|
439
|
+
tags: deriveTags(s.meta, { roomLabel: shortRoomLabel(room) }),
|
|
371
440
|
}));
|
|
372
441
|
const query = [...activeTags, filter].join(" ");
|
|
373
442
|
const filtered = tagged.filter((t) => matchQuery({ name: t.name, tags: t.tags }, query));
|
|
@@ -383,153 +452,233 @@ export function Discovery() {
|
|
|
383
452
|
.filter((t) => ["host", "repo", "wt", "kind", "room"].includes(tagKey(t)))
|
|
384
453
|
.slice(0, 12);
|
|
385
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
|
+
|
|
386
473
|
// Connected view: VS Code in an iframe, served over the tunnel.
|
|
387
474
|
if (iframeSrc && connState === "connected") {
|
|
388
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}
|
|
389
505
|
<div style={styles.page}>
|
|
390
506
|
<header style={styles.header}>
|
|
391
507
|
<span style={styles.brand}>codehost</span>
|
|
392
508
|
<span style={styles.dim}>·</span>
|
|
393
|
-
<span style={styles.dim}>{
|
|
394
|
-
{activeServer?.meta?.cwd && <span style={styles.cwd}>{activeServer.meta.cwd}</span>}
|
|
509
|
+
<span style={styles.dim}>{getSignalUrl()}</span>
|
|
395
510
|
<span style={{ flex: 1 }} />
|
|
396
|
-
<
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
>
|
|
401
|
-
{copied ? "Copied!" : "Share"}
|
|
402
|
-
</button>
|
|
403
|
-
<button style={styles.connectBtn} onClick={disconnect}>
|
|
404
|
-
Disconnect
|
|
405
|
-
</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>
|
|
406
516
|
</header>
|
|
407
|
-
<iframe title="VS Code" src={iframeSrc} style={{ flex: 1, border: "none", width: "100%", background: "#1e1e1e" }} />
|
|
408
|
-
</div>
|
|
409
|
-
);
|
|
410
|
-
}
|
|
411
517
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
<span style={{ flex: 1 }} />
|
|
419
|
-
<span style={{ ...styles.status, color: connected ? "#4ec9b0" : "#888" }}>
|
|
420
|
-
{token ? (connected ? "● connected" : "○ connecting…") : "○ no token"}
|
|
421
|
-
</span>
|
|
422
|
-
</header>
|
|
423
|
-
|
|
424
|
-
<main style={styles.main}>
|
|
425
|
-
{resolving && (
|
|
426
|
-
<p style={{ color: "#dcb67a", marginBottom: 12 }}>
|
|
427
|
-
Looking for <code style={styles.code}>{resolving}</code> in your rooms…{" "}
|
|
428
|
-
{token ? "waiting for a live server" : "enter the room's token below"}.
|
|
429
|
-
</p>
|
|
430
|
-
)}
|
|
431
|
-
<form onSubmit={applyToken} style={styles.tokenForm}>
|
|
432
|
-
<label style={styles.label}>Token</label>
|
|
433
|
-
<input
|
|
434
|
-
value={draft}
|
|
435
|
-
onChange={(e) => {
|
|
436
|
-
setDraft(e.target.value);
|
|
437
|
-
if (tokenError) setTokenError(null);
|
|
438
|
-
}}
|
|
439
|
-
placeholder="your room token"
|
|
440
|
-
style={styles.input}
|
|
441
|
-
/>
|
|
442
|
-
<button type="submit" style={styles.button}>
|
|
443
|
-
{token === draft.trim() ? "Reconnect" : "Connect"}
|
|
444
|
-
</button>
|
|
445
|
-
</form>
|
|
446
|
-
{tokenError ? (
|
|
447
|
-
<p style={styles.tokenError}>{tokenError}</p>
|
|
448
|
-
) : (
|
|
449
|
-
<p style={styles.tokenHint}>Token requires {TOKEN_REQUIREMENTS}.</p>
|
|
450
|
-
)}
|
|
451
|
-
|
|
452
|
-
<div style={styles.listHead}>
|
|
453
|
-
<h2 style={styles.h2}>Workspaces</h2>
|
|
454
|
-
{token && servers.length > 0 && (
|
|
455
|
-
<span style={styles.count}>
|
|
456
|
-
{filtered.length === tagged.length
|
|
457
|
-
? `${tagged.length}`
|
|
458
|
-
: `${filtered.length} / ${tagged.length}`}
|
|
459
|
-
</span>
|
|
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>
|
|
460
524
|
)}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
525
|
+
|
|
526
|
+
<div style={styles.tokenForm}>
|
|
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
|
+
</>
|
|
559
|
+
) : (
|
|
560
|
+
<span style={styles.dim}>none joined yet</span>
|
|
561
|
+
)}
|
|
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
|
|
482
590
|
</button>
|
|
483
|
-
)
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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} ✕
|
|
489
630
|
</button>
|
|
490
631
|
))}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
{filtered.map(({ server: s, name, tags }) => {
|
|
497
|
-
const isActive = s.peerId === activePeerId;
|
|
498
|
-
return (
|
|
499
|
-
<li key={s.peerId} style={styles.card}>
|
|
500
|
-
<div style={styles.cardMain}>
|
|
501
|
-
<div style={styles.cardName}>{name}</div>
|
|
502
|
-
<div style={styles.tagRow}>
|
|
503
|
-
{tags.map((tag) => (
|
|
504
|
-
<button key={tag} style={styles.tag} onClick={() => addTag(tag)} title={`filter by ${tag}`}>
|
|
505
|
-
{tag}
|
|
632
|
+
{suggestedTags
|
|
633
|
+
.filter((t) => !activeTags.includes(t))
|
|
634
|
+
.map((t) => (
|
|
635
|
+
<button key={t} style={styles.chip} onClick={() => toggleTag(t)}>
|
|
636
|
+
{t}
|
|
506
637
|
</button>
|
|
507
638
|
))}
|
|
508
|
-
</div>
|
|
509
|
-
<div style={styles.idLine}>peer {s.peerId.slice(0, 8)}</div>
|
|
510
|
-
{isActive && (
|
|
511
|
-
<div style={styles.echo}>
|
|
512
|
-
{connState === "connecting" && "negotiating WebRTC…"}
|
|
513
|
-
{connState === "failed" && "connection failed"}
|
|
514
|
-
</div>
|
|
515
|
-
)}
|
|
516
639
|
</div>
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
onClick={() => connectTo(s)}
|
|
520
|
-
disabled={isActive && connState === "connecting"}
|
|
521
|
-
>
|
|
522
|
-
{isActive && connState === "connecting" ? "…" : "Connect"}
|
|
523
|
-
</button>
|
|
524
|
-
</li>
|
|
525
|
-
);
|
|
526
|
-
})}
|
|
527
|
-
{token && servers.length > 0 && filtered.length === 0 && (
|
|
528
|
-
<p style={styles.dim}>No workspace matches your filter.</p>
|
|
640
|
+
)}
|
|
641
|
+
</>
|
|
529
642
|
)}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
+
</>
|
|
533
682
|
);
|
|
534
683
|
}
|
|
535
684
|
|
|
@@ -544,6 +693,10 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
544
693
|
tokenHint: { margin: "0 0 20px", fontSize: 12, color: "#888" },
|
|
545
694
|
tokenError: { margin: "0 0 20px", fontSize: 12, color: "#f48771" },
|
|
546
695
|
label: { fontSize: 12, color: "#888" },
|
|
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 },
|
|
547
700
|
input: { flex: 1, background: "#252525", border: "1px solid #3d3d3d", color: "#eee", padding: "8px 10px", borderRadius: 6, fontSize: 13, outline: "none" },
|
|
548
701
|
button: { background: "#0e639c", border: "none", color: "#fff", padding: "8px 16px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
|
|
549
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
|
}
|