codehost 0.11.1 → 0.13.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/cli/commands/dev.ts +1 -1
- package/src/shared/repo.test.ts +50 -1
- package/src/shared/repo.ts +41 -8
- package/src/web/discovery.tsx +141 -62
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.13.0](https://github.com/snomiao/codehost/compare/v0.12.0...v0.13.0) (2026-06-09)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **web:** host-scoped folder deep links — /host/<hostname>/<path> ([f6955ab](https://github.com/snomiao/codehost/commit/f6955ab37517f312ee780795efc503cb74ccdc36))
|
|
7
|
+
|
|
8
|
+
# [0.12.0](https://github.com/snomiao/codehost/compare/v0.11.1...v0.12.0) (2026-06-09)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **web:** URL-driven reconnect — Forward rehydrates, auto-reconnect on drop ([8e1f186](https://github.com/snomiao/codehost/commit/8e1f186bd6611d5d56a9061fed23ed7316c735ca))
|
|
14
|
+
|
|
1
15
|
## [0.11.1](https://github.com/snomiao/codehost/compare/v0.11.0...v0.11.1) (2026-06-09)
|
|
2
16
|
|
|
3
17
|
|
package/package.json
CHANGED
package/src/cli/commands/dev.ts
CHANGED
|
@@ -23,7 +23,7 @@ interface DevArgs {
|
|
|
23
23
|
export const devCommand: CommandModule<{}, DevArgs> = {
|
|
24
24
|
command: "dev [dir]",
|
|
25
25
|
describe:
|
|
26
|
-
"Serve a single folder over WebRTC; open it at codehost.dev/
|
|
26
|
+
"Serve a single folder over WebRTC; open it at codehost.dev/host/<hostname>/<path> (or /gh/<owner>/<repo>, /git/<host>/<owner>/<repo> for a git repo)",
|
|
27
27
|
builder: (y) =>
|
|
28
28
|
y
|
|
29
29
|
.positional("dir", {
|
package/src/shared/repo.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { parseDeepLink, pickRoomMatch, repoKey, shareableDeepLink, toPosixPath } from "./repo";
|
|
2
|
+
import { parseDeepLink, pickRoomMatch, repoKey, resolveDevTarget, shareableDeepLink, toPosixPath } from "./repo";
|
|
3
|
+
import type { PeerInfo } from "./signaling";
|
|
3
4
|
import { parseGitRemote } from "../cli/git";
|
|
4
5
|
|
|
5
6
|
describe("toPosixPath", () => {
|
|
@@ -109,12 +110,60 @@ describe("parseDeepLink + repoKey round-trip", () => {
|
|
|
109
110
|
expect(dl?.type === "dev" && dl.target.path).toBe("/C:/ws");
|
|
110
111
|
});
|
|
111
112
|
|
|
113
|
+
test("/host/<hostname>/<path> -> host-scoped dev target", () => {
|
|
114
|
+
const dl = parseDeepLink("/host/Mac/Users/taku");
|
|
115
|
+
expect(dl?.type === "dev" && dl.target.host).toBe("Mac");
|
|
116
|
+
expect(dl?.type === "dev" && dl.target.path).toBe("/Users/taku");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("/host/<hostname>/<Windows drive path> round-trips", () => {
|
|
120
|
+
const path = shareableDeepLink({ folder: "/C:/ws", host: "EC2AMAZ-PH8C4K1" })!;
|
|
121
|
+
expect(path).toBe("/host/EC2AMAZ-PH8C4K1/C:/ws");
|
|
122
|
+
const dl = parseDeepLink(path);
|
|
123
|
+
expect(dl?.type === "dev" && dl.target.host).toBe("EC2AMAZ-PH8C4K1");
|
|
124
|
+
expect(dl?.type === "dev" && dl.target.path).toBe("/C:/ws");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("legacy /dev/<path> still parses host-agnostic (no host)", () => {
|
|
128
|
+
const dl = parseDeepLink("/dev/C:/ws");
|
|
129
|
+
expect(dl?.type === "dev" && dl.target.host).toBeUndefined();
|
|
130
|
+
expect(dl?.type === "dev" && dl.target.path).toBe("/C:/ws");
|
|
131
|
+
});
|
|
132
|
+
|
|
112
133
|
test("non-deep-link -> null", () => {
|
|
113
134
|
expect(parseDeepLink("/")).toBeNull();
|
|
114
135
|
expect(parseDeepLink("/settings")).toBeNull();
|
|
115
136
|
});
|
|
116
137
|
});
|
|
117
138
|
|
|
139
|
+
describe("resolveDevTarget host scoping", () => {
|
|
140
|
+
const mk = (peerId: string, host: string, cwd: string): PeerInfo => ({
|
|
141
|
+
peerId,
|
|
142
|
+
role: "server",
|
|
143
|
+
meta: { name: host, host, cwd },
|
|
144
|
+
});
|
|
145
|
+
// Same served path on two different machines — the ambiguity host scoping fixes.
|
|
146
|
+
const servers = [mk("pA", "boxA", "/C:/ws"), mk("pB", "boxB", "/C:/ws")];
|
|
147
|
+
|
|
148
|
+
test("host-scoped target picks the matching host", () => {
|
|
149
|
+
expect(resolveDevTarget(servers, { host: "boxB", path: "/C:/ws" })?.peerId).toBe("pB");
|
|
150
|
+
expect(resolveDevTarget(servers, { host: "boxA", path: "/C:/ws" })?.peerId).toBe("pA");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("host-scoped target with no matching host -> null (won't cross machines)", () => {
|
|
154
|
+
expect(resolveDevTarget(servers, { host: "boxC", path: "/C:/ws" })).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("legacy host-agnostic target matches by path alone", () => {
|
|
158
|
+
expect(resolveDevTarget(servers, { path: "/C:/ws" })?.peerId).toBe("pA");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("leading/trailing slash differences still match (e.g. expose cwd)", () => {
|
|
162
|
+
const ex = [mk("pE", "boxE", "localhost:8090")];
|
|
163
|
+
expect(resolveDevTarget(ex, { host: "boxE", path: "/localhost:8090" })?.peerId).toBe("pE");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
118
167
|
describe("shareableDeepLink", () => {
|
|
119
168
|
test("GitHub repo -> /gh sugar", () => {
|
|
120
169
|
expect(shareableDeepLink({ repo: "github.com/snomiao/codehost", branch: "main" })).toBe(
|
package/src/shared/repo.ts
CHANGED
|
@@ -17,8 +17,12 @@ export interface RepoTarget {
|
|
|
17
17
|
branch?: string;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/** A direct folder mount address: `/
|
|
20
|
+
/** A direct folder mount address: host-scoped `/host/<hostname>/<path>`, or the
|
|
21
|
+
* legacy host-agnostic `/dev/<path>` (a bare path collides across machines, so
|
|
22
|
+
* new links carry the host). */
|
|
21
23
|
export interface DevTarget {
|
|
24
|
+
/** Hostname the workspace lives on; undefined for a legacy host-agnostic link. */
|
|
25
|
+
host?: string;
|
|
22
26
|
path: string;
|
|
23
27
|
}
|
|
24
28
|
|
|
@@ -31,7 +35,8 @@ export type DeepLink =
|
|
|
31
35
|
* Parse a deep-link pathname:
|
|
32
36
|
* /gh/<owner>/<repo>(/tree/<branch>) -> GitHub repo target
|
|
33
37
|
* /git/<host>/<owner>/<repo>(/tree/<branch>) -> any-host repo target
|
|
34
|
-
* /
|
|
38
|
+
* /host/<hostname>/<fs-path> -> host-scoped folder mount
|
|
39
|
+
* /dev/<fs-path> -> legacy host-agnostic folder mount
|
|
35
40
|
* Branch may contain slashes. Anything else -> null (normal app).
|
|
36
41
|
*/
|
|
37
42
|
export function parseDeepLink(pathname: string): DeepLink {
|
|
@@ -50,6 +55,13 @@ export function parseDeepLink(pathname: string): DeepLink {
|
|
|
50
55
|
target: { host: git[1].toLowerCase(), owner: git[2], name: git[3], branch: git[4] },
|
|
51
56
|
};
|
|
52
57
|
}
|
|
58
|
+
// Host-scoped folder mount: first segment is the hostname, the rest is the
|
|
59
|
+
// served path (which itself may contain slashes and a Windows drive colon).
|
|
60
|
+
const host = clean.match(/^\/host\/([^/]+)\/(.+)$/);
|
|
61
|
+
if (host) {
|
|
62
|
+
return { type: "dev", target: { host: host[1], path: `/${host[2].replace(/^\/+/, "")}` } };
|
|
63
|
+
}
|
|
64
|
+
// Legacy host-agnostic folder mount.
|
|
53
65
|
const dev = clean.match(/^\/dev\/(.+)$/);
|
|
54
66
|
if (dev) {
|
|
55
67
|
return { type: "dev", target: { path: `/${dev[1].replace(/^\/+/, "")}` } };
|
|
@@ -96,11 +108,17 @@ export function fillLayout(layout: string, t: RepoTarget): string {
|
|
|
96
108
|
* Shareable deep-link pathname for a connected workspace. A git-identified
|
|
97
109
|
* server renders `/gh/<owner>/<repo>` for GitHub or `/git/<host>/<owner>/<repo>`
|
|
98
110
|
* for any other host (with `/tree/<branch>` when known); a non-git workspace is
|
|
99
|
-
* addressed by its opened folder as
|
|
111
|
+
* addressed by its opened folder, scoped to its hostname as `/host/<host>/<path>`
|
|
112
|
+
* (or the legacy `/dev/<path>` when no host is known). Round-trips through
|
|
100
113
|
* parseDeepLink + resolve{Repo,Dev}Target so another room member opening it
|
|
101
114
|
* lands here. Returns null when there's nothing addressable.
|
|
102
115
|
*/
|
|
103
|
-
export function shareableDeepLink(opts: {
|
|
116
|
+
export function shareableDeepLink(opts: {
|
|
117
|
+
repo?: string;
|
|
118
|
+
branch?: string;
|
|
119
|
+
folder?: string;
|
|
120
|
+
host?: string;
|
|
121
|
+
}): string | null {
|
|
104
122
|
if (opts.repo) {
|
|
105
123
|
const [host, owner, name] = opts.repo.split("/");
|
|
106
124
|
if (host && owner && name) {
|
|
@@ -108,7 +126,10 @@ export function shareableDeepLink(opts: { repo?: string; branch?: string; folder
|
|
|
108
126
|
return opts.branch ? `${base}/tree/${opts.branch}` : base;
|
|
109
127
|
}
|
|
110
128
|
}
|
|
111
|
-
if (opts.folder)
|
|
129
|
+
if (opts.folder) {
|
|
130
|
+
const path = opts.folder.replace(/^\/+/, "");
|
|
131
|
+
return opts.host ? `/host/${opts.host}/${path}` : `/dev/${path}`;
|
|
132
|
+
}
|
|
112
133
|
return null;
|
|
113
134
|
}
|
|
114
135
|
|
|
@@ -138,10 +159,16 @@ export function resolveRepoTarget(servers: PeerInfo[], target: RepoTarget): Reso
|
|
|
138
159
|
return null;
|
|
139
160
|
}
|
|
140
161
|
|
|
141
|
-
/** Pick a
|
|
162
|
+
/** Pick a folder-mount server whose served cwd matches the target path, scoped
|
|
163
|
+
* to `target.host` when the link carries one (a bare path is ambiguous across
|
|
164
|
+
* machines). Compares with leading + trailing slashes stripped: `parseDeepLink`
|
|
165
|
+
* forces a leading "/" on the path, but a served cwd may lack one (e.g. an
|
|
166
|
+
* `expose` server's `localhost:<port>`), so a trailing-only trim never matches. */
|
|
142
167
|
export function resolveDevTarget(servers: PeerInfo[], target: DevTarget): Resolution | null {
|
|
143
|
-
const want =
|
|
144
|
-
const hit = servers.find(
|
|
168
|
+
const want = stripEnds(target.path);
|
|
169
|
+
const hit = servers.find(
|
|
170
|
+
(s) => s.meta && stripEnds(s.meta.cwd) === want && (!target.host || s.meta.host === target.host),
|
|
171
|
+
);
|
|
145
172
|
return hit ? { peerId: hit.peerId } : null;
|
|
146
173
|
}
|
|
147
174
|
|
|
@@ -172,3 +199,9 @@ function branchOk(meta: PeerMeta, target: RepoTarget): boolean {
|
|
|
172
199
|
function trimSlash(p: string): string {
|
|
173
200
|
return p.replace(/\/+$/, "");
|
|
174
201
|
}
|
|
202
|
+
|
|
203
|
+
/** Strip leading and trailing slashes — for comparing a `/dev/<path>` target to
|
|
204
|
+
* a served cwd that may or may not carry a leading slash. */
|
|
205
|
+
function stripEnds(p: string): string {
|
|
206
|
+
return p.replace(/^\/+|\/+$/g, "");
|
|
207
|
+
}
|
package/src/web/discovery.tsx
CHANGED
|
@@ -46,7 +46,8 @@ function tokenFromHash(): string {
|
|
|
46
46
|
/** Short label for the "looking for…" state from a deep link. */
|
|
47
47
|
function deepLinkLabel(dl: DeepLink): string | null {
|
|
48
48
|
if (!dl) return null;
|
|
49
|
-
|
|
49
|
+
if (dl.type === "repo") return `${dl.target.owner}/${dl.target.name}`;
|
|
50
|
+
return dl.target.host ? `${dl.target.host}:${dl.target.path}` : dl.target.path;
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
function folderQuery(folder?: string): string {
|
|
@@ -177,10 +178,19 @@ export function Discovery() {
|
|
|
177
178
|
const activeRoomRef = useRef<string | null>(null);
|
|
178
179
|
const sendersRef = useRef<Map<string, (to: string, data: unknown) => void>>(new Map());
|
|
179
180
|
// Whether the live connection pushed a history entry (so Disconnect/Back can
|
|
180
|
-
// pop it
|
|
181
|
-
// tears down from the iframe view, not during a mid-connect URL revert).
|
|
181
|
+
// pop it back to the list).
|
|
182
182
|
const pushedRef = useRef(false);
|
|
183
|
-
|
|
183
|
+
// A dial is in flight — a synchronous guard so the several reconnect triggers
|
|
184
|
+
// (popstate, server-list change, retry timer, deep-link auto-connect) never
|
|
185
|
+
// double-dial (connState updates a render too late to gate them).
|
|
186
|
+
const dialingRef = useRef(false);
|
|
187
|
+
// Set just before a failed-dial history.back() so the resulting popstate is
|
|
188
|
+
// treated as a URL revert, not a user navigation — the reconciler skips it once.
|
|
189
|
+
const revertingRef = useRef(false);
|
|
190
|
+
// Latest merged server list + connection state, read by the URL reconciler
|
|
191
|
+
// (invoked from the once-at-mount popstate handler) without a stale closure.
|
|
192
|
+
const allServersRef = useRef<RoomedServer[]>([]);
|
|
193
|
+
const connStateRef = useRef<ConnState>("idle");
|
|
184
194
|
|
|
185
195
|
// Deep-link resolution (/gh/<owner>/<repo>/... or /dev/<path>): parse once,
|
|
186
196
|
// auto-connect when a matching server appears, remember the opened folder.
|
|
@@ -218,11 +228,16 @@ export function Discovery() {
|
|
|
218
228
|
const folder = activeFolderRef.current;
|
|
219
229
|
setTimeout(() => setIframeSrc(`/vs/${peerId}/${folderQuery(folder)}`), 400);
|
|
220
230
|
});
|
|
221
|
-
// Back
|
|
222
|
-
//
|
|
223
|
-
//
|
|
231
|
+
// Back/Forward (Cmd+Left / Cmd+Right) reconcile the connection to the URL: a
|
|
232
|
+
// workspace deep link (re)connects to its server, the list URL drops the
|
|
233
|
+
// connection. The browser already changed the URL; we follow it. A failed
|
|
234
|
+
// dial's revert-back is skipped once (revertingRef) so it keeps the list.
|
|
224
235
|
const onPopState = () => {
|
|
225
|
-
if (
|
|
236
|
+
if (revertingRef.current) {
|
|
237
|
+
revertingRef.current = false;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
syncToUrl();
|
|
226
241
|
};
|
|
227
242
|
window.addEventListener("popstate", onPopState);
|
|
228
243
|
// A valid token in the URL fragment (#t=<token>) joins the room and turns on
|
|
@@ -265,6 +280,23 @@ export function Discovery() {
|
|
|
265
280
|
tryAutoConnect();
|
|
266
281
|
}, [serversByRoom, tokens]);
|
|
267
282
|
|
|
283
|
+
// Keep the connection in sync with the URL as servers come and go: reconnect
|
|
284
|
+
// when the workspace named by the address bar (re)appears in a room — covers a
|
|
285
|
+
// daemon restart or a dropped channel while the tab stays open.
|
|
286
|
+
useEffect(() => {
|
|
287
|
+
syncToUrl();
|
|
288
|
+
}, [serversByRoom]);
|
|
289
|
+
|
|
290
|
+
// Safety-net retry: while the URL names a workspace we're not connected to and
|
|
291
|
+
// no dial is in flight, retry every few seconds — covers a dropped channel
|
|
292
|
+
// whose server never left the room (so no list change fires the effect above).
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
if (!parseDeepLink(window.location.pathname)) return;
|
|
295
|
+
if (connState === "connected" || connState === "connecting") return;
|
|
296
|
+
const id = setInterval(() => syncToUrl(), 5000);
|
|
297
|
+
return () => clearInterval(id);
|
|
298
|
+
}, [connState, serversByRoom]);
|
|
299
|
+
|
|
268
300
|
function joinFromInput(e: React.FormEvent) {
|
|
269
301
|
e.preventDefault();
|
|
270
302
|
const t = draft.trim();
|
|
@@ -295,59 +327,70 @@ export function Discovery() {
|
|
|
295
327
|
sendersRef.current.delete(t);
|
|
296
328
|
}
|
|
297
329
|
|
|
298
|
-
async function connectTo(server: PeerInfo, room: string, folder?: string) {
|
|
330
|
+
async function connectTo(server: PeerInfo, room: string, folder?: string, fromHistory = false) {
|
|
299
331
|
const send = sendersRef.current.get(room);
|
|
300
332
|
if (!send) return;
|
|
333
|
+
dialingRef.current = true; // synchronous gate against concurrent triggers
|
|
334
|
+
let didPush = false;
|
|
335
|
+
try {
|
|
336
|
+
// Clear any prior connection's broker state first: after an RTC drop the
|
|
337
|
+
// broker still holds the dead channel in `locals`, so re-dialing the same
|
|
338
|
+
// peer would otherwise resolve straight to it. Also covers switching peers.
|
|
339
|
+
if (activePeerRef.current) connBroker.disconnect(activePeerRef.current);
|
|
301
340
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
341
|
+
rtcRef.current?.close();
|
|
342
|
+
rtcRef.current = null;
|
|
343
|
+
setIframeSrc(null);
|
|
344
|
+
setActivePeerId(server.peerId);
|
|
345
|
+
activePeerRef.current = server.peerId;
|
|
346
|
+
activeRoomRef.current = room;
|
|
347
|
+
setConnState("connecting");
|
|
348
|
+
|
|
349
|
+
// Update the address bar the instant Connect is clicked (don't wait for the
|
|
350
|
+
// handshake) and push a history entry, so Back returns to the list and
|
|
351
|
+
// Forward returns here. When `fromHistory`, the browser already set the URL
|
|
352
|
+
// (back/forward/reconnect) — don't push again, but a prior entry exists.
|
|
353
|
+
const openFolder = folder ?? server.meta?.cwd;
|
|
354
|
+
if (fromHistory) {
|
|
355
|
+
pushedRef.current = true;
|
|
356
|
+
sharePathRef.current = window.location.pathname;
|
|
357
|
+
} else {
|
|
358
|
+
const targetPath = shareablePathFor(server, openFolder);
|
|
359
|
+
didPush = !!targetPath && targetPath !== window.location.pathname;
|
|
360
|
+
if (didPush) history.pushState(null, "", targetPath);
|
|
361
|
+
pushedRef.current = didPush;
|
|
362
|
+
sharePathRef.current = targetPath ?? window.location.pathname;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// The broker decides whether this tab owns the connection. `establish` is
|
|
366
|
+
// only invoked when we're the owner (or get promoted on failover); other
|
|
367
|
+
// tabs reuse the owner's channel via a proxy, so they never open WebRTC.
|
|
368
|
+
const establish = () =>
|
|
369
|
+
new Promise<RTCDataChannel>((resolve, reject) => {
|
|
370
|
+
const rtc = new RtcClient({
|
|
371
|
+
sendSignal: (data: RtcSignal) => send(server.peerId, data),
|
|
372
|
+
onState: (state) => {
|
|
373
|
+
if (state === "failed" || state === "disconnected") setConnState("failed");
|
|
374
|
+
},
|
|
375
|
+
onOpen: (channel) => {
|
|
376
|
+
clearTimeout(timer);
|
|
377
|
+
resolve(channel);
|
|
378
|
+
},
|
|
379
|
+
onClose: () => setConnState((s) => (s === "connected" ? "idle" : s)),
|
|
380
|
+
});
|
|
381
|
+
rtcRef.current = rtc;
|
|
382
|
+
// Don't hang forever dialing a peer that never answers (e.g. a stale
|
|
383
|
+
// server still listed in the room): fail the attempt after 15s.
|
|
384
|
+
const timer = setTimeout(() => {
|
|
385
|
+
rtc.close();
|
|
386
|
+
reject(new Error("connection timed out"));
|
|
387
|
+
}, 15000);
|
|
388
|
+
rtc.start().catch((err) => {
|
|
332
389
|
clearTimeout(timer);
|
|
333
|
-
|
|
334
|
-
}
|
|
335
|
-
onClose: () => setConnState((s) => (s === "connected" ? "idle" : s)),
|
|
336
|
-
});
|
|
337
|
-
rtcRef.current = rtc;
|
|
338
|
-
// Don't hang forever dialing a peer that never answers (e.g. a stale
|
|
339
|
-
// server still listed in the room): fail the attempt after 15s.
|
|
340
|
-
const timer = setTimeout(() => {
|
|
341
|
-
rtc.close();
|
|
342
|
-
reject(new Error("connection timed out"));
|
|
343
|
-
}, 15000);
|
|
344
|
-
rtc.start().catch((err) => {
|
|
345
|
-
clearTimeout(timer);
|
|
346
|
-
reject(err);
|
|
390
|
+
reject(err);
|
|
391
|
+
});
|
|
347
392
|
});
|
|
348
|
-
});
|
|
349
393
|
|
|
350
|
-
try {
|
|
351
394
|
await connBroker.connect(server.peerId, establish);
|
|
352
395
|
setConnState("connected");
|
|
353
396
|
// The daemon no longer sets a default folder (current VS Code serve-web
|
|
@@ -357,15 +400,16 @@ export function Discovery() {
|
|
|
357
400
|
setIframeSrc(`/vs/${server.peerId}/${folderQuery(openFolder)}`);
|
|
358
401
|
setResolving(null);
|
|
359
402
|
recordConnect(server, room, openFolder);
|
|
360
|
-
viewingRef.current = true;
|
|
361
403
|
} catch {
|
|
362
404
|
setConnState("failed");
|
|
363
|
-
// Undo the optimistic history entry
|
|
364
|
-
//
|
|
365
|
-
if (
|
|
366
|
-
|
|
405
|
+
// Undo the optimistic history entry we pushed. revertingRef makes the
|
|
406
|
+
// resulting popstate a no-op so the "failed" card stays on the list.
|
|
407
|
+
if (didPush) {
|
|
408
|
+
revertingRef.current = true;
|
|
367
409
|
history.back();
|
|
368
410
|
}
|
|
411
|
+
} finally {
|
|
412
|
+
dialingRef.current = false;
|
|
369
413
|
}
|
|
370
414
|
}
|
|
371
415
|
|
|
@@ -375,7 +419,12 @@ export function Discovery() {
|
|
|
375
419
|
function shareablePathFor(server: PeerInfo, folder?: string): string | null {
|
|
376
420
|
return deepLinkRef.current
|
|
377
421
|
? window.location.pathname
|
|
378
|
-
: shareableDeepLink({
|
|
422
|
+
: shareableDeepLink({
|
|
423
|
+
repo: server.meta?.repo,
|
|
424
|
+
branch: server.meta?.branch,
|
|
425
|
+
folder,
|
|
426
|
+
host: server.meta?.host,
|
|
427
|
+
});
|
|
379
428
|
}
|
|
380
429
|
|
|
381
430
|
async function shareLink() {
|
|
@@ -449,7 +498,33 @@ export function Discovery() {
|
|
|
449
498
|
setConnState("idle");
|
|
450
499
|
sharePathRef.current = null;
|
|
451
500
|
pushedRef.current = false;
|
|
452
|
-
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Resolve a workspace deep-link path to a live server across all joined rooms.
|
|
504
|
+
function findServerForDeepLink(dl: DeepLink): (RoomedServer & { folder?: string }) | null {
|
|
505
|
+
if (!dl) return null;
|
|
506
|
+
const peers = allServersRef.current.map((x) => x.server);
|
|
507
|
+
const res = dl.type === "repo" ? resolveRepoTarget(peers, dl.target) : resolveDevTarget(peers, dl.target);
|
|
508
|
+
if (!res) return null;
|
|
509
|
+
const match = allServersRef.current.find((x) => x.server.peerId === res.peerId);
|
|
510
|
+
return match ? { ...match, folder: res.folder } : null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Reconcile the live connection to the current URL. Drives Back/Forward nav and
|
|
514
|
+
// auto-reconnect: a workspace deep link connects to (or reconnects to) the
|
|
515
|
+
// server it resolves to; the list URL ("/") drops the connection. Reads only
|
|
516
|
+
// refs/window, so it's safe to call from the once-at-mount popstate handler.
|
|
517
|
+
function syncToUrl() {
|
|
518
|
+
const dl = parseDeepLink(window.location.pathname);
|
|
519
|
+
if (!dl) {
|
|
520
|
+
if (activePeerRef.current) teardownConn();
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (dialingRef.current) return; // a dial is already in flight
|
|
524
|
+
const target = findServerForDeepLink(dl);
|
|
525
|
+
if (!target) return; // its server isn't present (yet) — wait for it to appear
|
|
526
|
+
if (activePeerRef.current === target.server.peerId && connStateRef.current === "connected") return;
|
|
527
|
+
void connectTo(target.server, target.room, target.folder, true);
|
|
453
528
|
}
|
|
454
529
|
|
|
455
530
|
function disconnect() {
|
|
@@ -468,6 +543,10 @@ export function Discovery() {
|
|
|
468
543
|
const allServers: RoomedServer[] = tokens.flatMap((t) =>
|
|
469
544
|
(serversByRoom[t] ?? []).map((server) => ({ server, room: t })),
|
|
470
545
|
);
|
|
546
|
+
// Mirror the latest merged servers + connection state into refs so the URL
|
|
547
|
+
// reconciler (called from event handlers/timers) never reads a stale closure.
|
|
548
|
+
allServersRef.current = allServers;
|
|
549
|
+
connStateRef.current = connState;
|
|
471
550
|
const serverCount = allServers.length;
|
|
472
551
|
const onlineRooms = tokens.filter((t) => roomOpen[t]).length;
|
|
473
552
|
const activeServer = allServers.find((x) => x.server.peerId === activePeerId)?.server;
|