codehost 0.4.0 → 0.5.1
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 +15 -0
- package/package.json +1 -1
- package/src/cli/commands/dev.ts +3 -3
- package/src/cli/commands/serve.ts +2 -2
- package/src/cli/git.ts +37 -17
- package/src/cli/vscode.ts +26 -4
- package/src/shared/repo.test.ts +142 -14
- package/src/shared/repo.ts +71 -20
- package/src/shared/signaling.ts +1 -1
- package/src/web/discovery.tsx +104 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
## [0.5.1](https://github.com/snomiao/codehost/compare/v0.5.0...v0.5.1) (2026-06-08)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* don't time out (and oxmgr-restart-loop) on first-run VS Code server download ([5ad1b06](https://github.com/snomiao/codehost/commit/5ad1b068668afda4d19f403286240827a0a4b9c4))
|
|
7
|
+
* Windows ?folder= path must be /C:/ws (file-URI form), not git-bash /c/ws ([f6d2485](https://github.com/snomiao/codehost/commit/f6d24858c943dd2b7b5f1d5a96f71468e9552140))
|
|
8
|
+
|
|
9
|
+
# [0.5.0](https://github.com/snomiao/codehost/compare/v0.4.0...v0.5.0) (2026-06-08)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
* shareable workspace URLs (host-agnostic) + Share button + cross-room search ([f1db806](https://github.com/snomiao/codehost/commit/f1db806a1ec86cdc369b17ccca82057eb9526385))
|
|
15
|
+
|
|
1
16
|
# [0.4.0](https://github.com/snomiao/codehost/compare/v0.3.1...v0.4.0) (2026-06-08)
|
|
2
17
|
|
|
3
18
|
|
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/dev/<path> (or /gh/<owner>/<repo>
|
|
26
|
+
"Serve a single folder over WebRTC; open it at codehost.dev/dev/<path> (or /gh/<owner>/<repo>, /git/<host>/<owner>/<repo> for a git repo)",
|
|
27
27
|
builder: (y) =>
|
|
28
28
|
y
|
|
29
29
|
.positional("dir", {
|
|
@@ -86,8 +86,8 @@ export const devCommand: CommandModule<{}, DevArgs> = {
|
|
|
86
86
|
const id = repoIdentity(dir);
|
|
87
87
|
const meta: PeerMeta = {
|
|
88
88
|
name: argv.name ?? host,
|
|
89
|
-
//
|
|
90
|
-
// OS path for the local VS Code working dir.
|
|
89
|
+
// VS Code-web ?folder= form for the browser (C:\ws -> /C:/ws); `dir` stays
|
|
90
|
+
// the real OS path for the local VS Code working dir.
|
|
91
91
|
cwd: toPosixPath(dir),
|
|
92
92
|
host,
|
|
93
93
|
kind: "repo",
|
|
@@ -23,7 +23,7 @@ interface ServeArgs {
|
|
|
23
23
|
export const serveCommand: CommandModule<{}, ServeArgs> = {
|
|
24
24
|
command: "serve [dir]",
|
|
25
25
|
describe:
|
|
26
|
-
"Serve a workspace root over WebRTC; repos under it open via codehost.dev/gh/<owner>/<repo>",
|
|
26
|
+
"Serve a workspace root over WebRTC; repos under it open via codehost.dev/gh/<owner>/<repo> (or /git/<host>/<owner>/<repo>)",
|
|
27
27
|
builder: (y) =>
|
|
28
28
|
y
|
|
29
29
|
.positional("dir", {
|
|
@@ -87,7 +87,7 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
|
|
|
87
87
|
// onto subfolders via VS Code's ?folder= using this layout.
|
|
88
88
|
const meta: PeerMeta = {
|
|
89
89
|
name: argv.name ?? host,
|
|
90
|
-
//
|
|
90
|
+
// VS Code-web ?folder= form for the browser (C:\ws -> /C:/ws); the real
|
|
91
91
|
// OS path `dir` is still what we spawn VS Code in.
|
|
92
92
|
cwd: toPosixPath(dir),
|
|
93
93
|
host,
|
package/src/cli/git.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
|
|
3
|
-
// Derive a normalized repo identity ("
|
|
4
|
-
// working tree, so a `codehost dev` daemon can be addressed by
|
|
5
|
-
// deep links
|
|
3
|
+
// Derive a normalized, host-agnostic repo identity ("<host>/<owner>/<repo>") +
|
|
4
|
+
// branch from a git working tree, so a `codehost dev` daemon can be addressed by
|
|
5
|
+
// deep links (/gh/<owner>/<repo> for GitHub, /git/<host>/<owner>/<repo> for any
|
|
6
|
+
// other host). Best-effort: returns undefined fields off git / off a recognized
|
|
7
|
+
// remote.
|
|
6
8
|
|
|
7
9
|
export interface RepoIdentity {
|
|
8
|
-
/** Normalized identity, e.g. "
|
|
10
|
+
/** Normalized identity, e.g. "github.com/snomiao/codehost". */
|
|
9
11
|
repo?: string;
|
|
10
12
|
/** Current branch, e.g. "main". */
|
|
11
13
|
branch?: string;
|
|
@@ -24,27 +26,45 @@ export function repoIdentity(dir: string): RepoIdentity {
|
|
|
24
26
|
const branch =
|
|
25
27
|
git(dir, ["rev-parse", "--abbrev-ref", "HEAD"]) || git(dir, ["symbolic-ref", "--short", "HEAD"]);
|
|
26
28
|
return {
|
|
27
|
-
repo:
|
|
29
|
+
repo: parseGitRemote(remote),
|
|
28
30
|
branch: branch && branch !== "HEAD" ? branch : undefined,
|
|
29
31
|
};
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
/**
|
|
33
|
-
* Parse
|
|
35
|
+
* Parse any git remote URL into a host-agnostic "<host>/<owner>/<repo>" key
|
|
36
|
+
* (lowercased host, no trailing `.git`). Handles:
|
|
34
37
|
* https://github.com/owner/repo(.git)
|
|
35
|
-
* git@
|
|
36
|
-
* ssh://git@
|
|
37
|
-
*
|
|
38
|
+
* git@gitlab.com:owner/repo(.git) (scp-like)
|
|
39
|
+
* ssh://git@git.company.com:2222/owner/repo (with optional user/port)
|
|
40
|
+
* git://host/owner/repo
|
|
41
|
+
* Returns undefined for unparseable remotes. Only the first two path segments
|
|
42
|
+
* (owner/repo) are used; deeper paths (e.g. GitLab subgroups) collapse to those.
|
|
38
43
|
*/
|
|
39
|
-
export function
|
|
40
|
-
|
|
44
|
+
export function parseGitRemote(url: string): string | undefined {
|
|
45
|
+
let u = url.trim();
|
|
41
46
|
if (!u) return undefined;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
u = u.replace(/\.git\/?$/i, "");
|
|
48
|
+
|
|
49
|
+
let host: string | undefined;
|
|
50
|
+
let path: string | undefined;
|
|
51
|
+
|
|
52
|
+
// scp-like syntax: [user@]host:owner/repo (no scheme).
|
|
53
|
+
const scp = u.match(/^(?:[^@/]+@)?([^/:]+):(.+)$/);
|
|
54
|
+
// URL syntax: scheme://[user@]host[:port]/owner/repo
|
|
55
|
+
const uri = u.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/i);
|
|
56
|
+
if (uri) {
|
|
57
|
+
host = uri[1];
|
|
58
|
+
path = uri[2];
|
|
59
|
+
} else if (scp && !u.includes("://")) {
|
|
60
|
+
host = scp[1];
|
|
61
|
+
path = scp[2];
|
|
62
|
+
}
|
|
63
|
+
if (!host || !path) return undefined;
|
|
64
|
+
|
|
65
|
+
const segs = path.replace(/^\/+/, "").split("/").filter(Boolean);
|
|
66
|
+
if (segs.length < 2) return undefined;
|
|
67
|
+
return `${host.toLowerCase()}/${segs[0]}/${segs[1]}`;
|
|
48
68
|
}
|
|
49
69
|
|
|
50
70
|
function git(dir: string, args: string[]): string {
|
package/src/cli/vscode.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { spawn, type Subprocess } from "bun";
|
|
2
2
|
import { resolveCodeBinary } from "./vscode-install";
|
|
3
3
|
|
|
4
|
+
// How long to wait for `code serve-web` to answer. The default is generous
|
|
5
|
+
// because the FIRST run downloads the server component, which can take minutes
|
|
6
|
+
// on a slow link or a fresh Windows box — and under the oxmgr daemon
|
|
7
|
+
// (`--restart on-failure`) a too-short timeout makes us exit mid-download, get
|
|
8
|
+
// restarted, and re-download forever. Override with CODEHOST_VSCODE_READY_TIMEOUT_MS.
|
|
9
|
+
const READY_TIMEOUT_MS = Number(process.env.CODEHOST_VSCODE_READY_TIMEOUT_MS) || 10 * 60_000;
|
|
10
|
+
|
|
4
11
|
export interface VscodeServer {
|
|
5
12
|
port: number;
|
|
6
13
|
basePath: string;
|
|
@@ -49,7 +56,7 @@ export async function launchVscode(opts: LaunchOptions): Promise<VscodeServer> {
|
|
|
49
56
|
});
|
|
50
57
|
|
|
51
58
|
const base = `http://127.0.0.1:${port}${opts.basePath}/`;
|
|
52
|
-
await waitForHttp(base,
|
|
59
|
+
await waitForHttp(base, READY_TIMEOUT_MS, proc);
|
|
53
60
|
console.log(`[codehost] VS Code ready at ${base}`);
|
|
54
61
|
|
|
55
62
|
const stop = () => {
|
|
@@ -62,18 +69,33 @@ export async function launchVscode(opts: LaunchOptions): Promise<VscodeServer> {
|
|
|
62
69
|
return { port, basePath: opts.basePath, proc, stop };
|
|
63
70
|
}
|
|
64
71
|
|
|
65
|
-
async function waitForHttp(url: string, timeoutMs: number): Promise<void> {
|
|
66
|
-
const
|
|
72
|
+
async function waitForHttp(url: string, timeoutMs: number, proc?: Subprocess): Promise<void> {
|
|
73
|
+
const started = Date.now();
|
|
74
|
+
const deadline = started + timeoutMs;
|
|
75
|
+
let nextHeartbeat = started + 15_000;
|
|
67
76
|
while (Date.now() < deadline) {
|
|
77
|
+
// If the server process died (bad flag, crash), fail now instead of waiting
|
|
78
|
+
// out the whole timeout — and surface that it exited rather than hung.
|
|
79
|
+
if (proc && proc.exitCode !== null) {
|
|
80
|
+
throw new Error(`VS Code server exited (code ${proc.exitCode}) before becoming ready at ${url}`);
|
|
81
|
+
}
|
|
68
82
|
try {
|
|
69
83
|
const res = await fetch(url, { redirect: "manual" });
|
|
70
84
|
if (res.status > 0) return;
|
|
71
85
|
} catch {
|
|
72
86
|
// not up yet
|
|
73
87
|
}
|
|
88
|
+
if (Date.now() >= nextHeartbeat) {
|
|
89
|
+
const secs = Math.round((Date.now() - started) / 1000);
|
|
90
|
+
console.log(`[codehost] waiting for VS Code server to start… (${secs}s; first run downloads the server component)`);
|
|
91
|
+
nextHeartbeat += 15_000;
|
|
92
|
+
}
|
|
74
93
|
await Bun.sleep(300);
|
|
75
94
|
}
|
|
76
|
-
throw new Error(
|
|
95
|
+
throw new Error(
|
|
96
|
+
`VS Code did not become ready at ${url} within ${timeoutMs}ms ` +
|
|
97
|
+
`(first-run server download can be slow — raise CODEHOST_VSCODE_READY_TIMEOUT_MS)`,
|
|
98
|
+
);
|
|
77
99
|
}
|
|
78
100
|
|
|
79
101
|
async function freePort(): Promise<number> {
|
package/src/shared/repo.test.ts
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { toPosixPath } from "./repo";
|
|
2
|
+
import { parseDeepLink, pickRoomMatch, repoKey, shareableDeepLink, toPosixPath } from "./repo";
|
|
3
|
+
import { parseGitRemote } from "../cli/git";
|
|
3
4
|
|
|
4
5
|
describe("toPosixPath", () => {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
// VS Code web's ?folder= on Windows wants the file-URI authority form
|
|
7
|
+
// (/C:/ws), NOT git-bash /c/ws — the latter reports "workspace does not exist".
|
|
8
|
+
test("Windows drive path -> /<Drive>:/... (file-URI form)", () => {
|
|
9
|
+
expect(toPosixPath("C:\\ws")).toBe("/C:/ws");
|
|
10
|
+
expect(toPosixPath("C:\\Users\\taku")).toBe("/C:/Users/taku");
|
|
8
11
|
});
|
|
9
12
|
|
|
10
|
-
test("
|
|
11
|
-
expect(toPosixPath("D:\\foo")).toBe("/
|
|
12
|
-
expect(toPosixPath("c:\\ws")).toBe("/c
|
|
13
|
+
test("preserves drive-letter case (drive is case-insensitive on Windows)", () => {
|
|
14
|
+
expect(toPosixPath("D:\\foo")).toBe("/D:/foo");
|
|
15
|
+
expect(toPosixPath("c:\\ws")).toBe("/c:/ws");
|
|
13
16
|
});
|
|
14
17
|
|
|
15
|
-
test("drive root collapses to /<
|
|
16
|
-
expect(toPosixPath("C:\\")).toBe("/
|
|
17
|
-
expect(toPosixPath("C:")).toBe("/
|
|
18
|
+
test("drive root collapses to /<Drive>: (no trailing slash)", () => {
|
|
19
|
+
expect(toPosixPath("C:\\")).toBe("/C:");
|
|
20
|
+
expect(toPosixPath("C:")).toBe("/C:");
|
|
18
21
|
});
|
|
19
22
|
|
|
20
23
|
test("forward-slash Windows paths normalize too", () => {
|
|
21
|
-
expect(toPosixPath("C:/ws")).toBe("/
|
|
24
|
+
expect(toPosixPath("C:/ws")).toBe("/C:/ws");
|
|
22
25
|
});
|
|
23
26
|
|
|
24
27
|
test("POSIX absolute paths are unchanged (mac/linux not broken)", () => {
|
|
@@ -27,11 +30,136 @@ describe("toPosixPath", () => {
|
|
|
27
30
|
expect(toPosixPath("/")).toBe("/");
|
|
28
31
|
});
|
|
29
32
|
|
|
30
|
-
test("already-normalized
|
|
31
|
-
expect(toPosixPath("/
|
|
33
|
+
test("already-normalized path is idempotent", () => {
|
|
34
|
+
expect(toPosixPath("/C:/ws")).toBe("/C:/ws");
|
|
32
35
|
});
|
|
33
36
|
|
|
34
37
|
test("trims trailing backslashes/slashes on a drive path", () => {
|
|
35
|
-
expect(toPosixPath("C:\\ws\\")).toBe("/
|
|
38
|
+
expect(toPosixPath("C:\\ws\\")).toBe("/C:/ws");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Regression: the value VS Code web receives via ?folder= (URL-decoded) must
|
|
42
|
+
// be exactly /C:/ws so serve-web resolves it to the real C:\ws on disk.
|
|
43
|
+
test("?folder= round-trip: encode(toPosixPath) decodes back to /C:/ws", () => {
|
|
44
|
+
const folderParam = encodeURIComponent(toPosixPath("C:\\ws"));
|
|
45
|
+
expect(folderParam).toBe("%2FC%3A%2Fws");
|
|
46
|
+
expect(decodeURIComponent(folderParam)).toBe("/C:/ws");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("parseGitRemote", () => {
|
|
51
|
+
test("GitHub https / ssh / git@ -> host-agnostic key", () => {
|
|
52
|
+
expect(parseGitRemote("https://github.com/snomiao/codehost.git")).toBe("github.com/snomiao/codehost");
|
|
53
|
+
expect(parseGitRemote("git@github.com:snomiao/codehost.git")).toBe("github.com/snomiao/codehost");
|
|
54
|
+
expect(parseGitRemote("ssh://git@github.com/snomiao/codehost")).toBe("github.com/snomiao/codehost");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("other hosts work too (gitlab, bitbucket, self-hosted with port)", () => {
|
|
58
|
+
expect(parseGitRemote("https://gitlab.com/group/proj.git")).toBe("gitlab.com/group/proj");
|
|
59
|
+
expect(parseGitRemote("git@bitbucket.org:team/repo.git")).toBe("bitbucket.org/team/repo");
|
|
60
|
+
expect(parseGitRemote("ssh://git@git.company.com:2222/team/svc.git")).toBe("git.company.com/team/svc");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("lowercases host, strips .git, ignores deeper path segments", () => {
|
|
64
|
+
expect(parseGitRemote("https://GitHub.com/Owner/Repo")).toBe("github.com/Owner/Repo");
|
|
65
|
+
expect(parseGitRemote("https://gitlab.com/group/sub/proj.git")).toBe("gitlab.com/group/sub");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("returns undefined for empty / unparseable / single-segment remotes", () => {
|
|
69
|
+
expect(parseGitRemote("")).toBeUndefined();
|
|
70
|
+
expect(parseGitRemote("not a url")).toBeUndefined();
|
|
71
|
+
expect(parseGitRemote("https://github.com/onlyowner")).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("parseDeepLink + repoKey round-trip", () => {
|
|
76
|
+
test("/gh/owner/repo -> github.com key", () => {
|
|
77
|
+
const dl = parseDeepLink("/gh/snomiao/codehost");
|
|
78
|
+
expect(dl?.type).toBe("repo");
|
|
79
|
+
if (dl?.type === "repo") {
|
|
80
|
+
expect(repoKey(dl.target)).toBe("github.com/snomiao/codehost");
|
|
81
|
+
expect(dl.target.branch).toBeUndefined();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("/gh/owner/repo/tree/branch keeps the branch (slashes allowed)", () => {
|
|
86
|
+
const dl = parseDeepLink("/gh/snomiao/codehost/tree/feat/x");
|
|
87
|
+
expect(dl?.type === "repo" && dl.target.branch).toBe("feat/x");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("/git/<host>/owner/repo -> that host's key", () => {
|
|
91
|
+
const dl = parseDeepLink("/git/gitlab.com/group/proj/tree/dev");
|
|
92
|
+
expect(dl?.type).toBe("repo");
|
|
93
|
+
if (dl?.type === "repo") {
|
|
94
|
+
expect(repoKey(dl.target)).toBe("gitlab.com/group/proj");
|
|
95
|
+
expect(dl.target.branch).toBe("dev");
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("/dev/<path> -> dev target with leading slash", () => {
|
|
100
|
+
const dl = parseDeepLink("/dev/Users/sno/ws");
|
|
101
|
+
expect(dl?.type === "dev" && dl.target.path).toBe("/Users/sno/ws");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("/dev/<Windows drive path> round-trips (colon in a non-leading segment)", () => {
|
|
105
|
+
// shareableDeepLink -> address bar -> parseDeepLink must preserve /C:/ws.
|
|
106
|
+
const path = shareableDeepLink({ folder: toPosixPath("C:\\ws") })!;
|
|
107
|
+
expect(path).toBe("/dev/C:/ws");
|
|
108
|
+
const dl = parseDeepLink(path);
|
|
109
|
+
expect(dl?.type === "dev" && dl.target.path).toBe("/C:/ws");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("non-deep-link -> null", () => {
|
|
113
|
+
expect(parseDeepLink("/")).toBeNull();
|
|
114
|
+
expect(parseDeepLink("/settings")).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("shareableDeepLink", () => {
|
|
119
|
+
test("GitHub repo -> /gh sugar", () => {
|
|
120
|
+
expect(shareableDeepLink({ repo: "github.com/snomiao/codehost", branch: "main" })).toBe(
|
|
121
|
+
"/gh/snomiao/codehost/tree/main",
|
|
122
|
+
);
|
|
123
|
+
expect(shareableDeepLink({ repo: "github.com/snomiao/codehost" })).toBe("/gh/snomiao/codehost");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("other host -> /git/<host>/...", () => {
|
|
127
|
+
expect(shareableDeepLink({ repo: "gitlab.com/group/proj", branch: "dev" })).toBe(
|
|
128
|
+
"/git/gitlab.com/group/proj/tree/dev",
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("no repo -> /dev/<folder> (Windows drive path preserved)", () => {
|
|
133
|
+
expect(shareableDeepLink({ folder: "/C:/ws" })).toBe("/dev/C:/ws");
|
|
134
|
+
expect(shareableDeepLink({ folder: "/Users/sno/ws" })).toBe("/dev/Users/sno/ws");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("nothing addressable -> null", () => {
|
|
138
|
+
expect(shareableDeepLink({})).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("round-trips: shareableDeepLink output parses back to the same key", () => {
|
|
142
|
+
const path = shareableDeepLink({ repo: "gitlab.com/group/proj", branch: "dev" })!;
|
|
143
|
+
const dl = parseDeepLink(path);
|
|
144
|
+
expect(dl?.type === "repo" && repoKey(dl.target)).toBe("gitlab.com/group/proj");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("pickRoomMatch (cross-room ranking)", () => {
|
|
149
|
+
const exact = { token: "tA", resolution: { peerId: "p1" } }; // no folder = exact
|
|
150
|
+
const root = { token: "tB", resolution: { peerId: "p2", folder: "/work/me/repo" } };
|
|
151
|
+
|
|
152
|
+
test("exact match (no folder) beats a root fallback, regardless of order", () => {
|
|
153
|
+
expect(pickRoomMatch([root, exact])?.token).toBe("tA");
|
|
154
|
+
expect(pickRoomMatch([exact, root])?.token).toBe("tA");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("only root fallbacks -> first root", () => {
|
|
158
|
+
const root2 = { token: "tC", resolution: { peerId: "p3", folder: "/x" } };
|
|
159
|
+
expect(pickRoomMatch([root, root2])?.token).toBe("tB");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("no matches -> null", () => {
|
|
163
|
+
expect(pickRoomMatch([])).toBeNull();
|
|
36
164
|
});
|
|
37
165
|
});
|
package/src/shared/repo.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
// Shared helpers for
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Shared helpers for git-shaped deep links and matching them to a daemon. Used
|
|
2
|
+
// by the web resolver (src/web) and conceptually mirrors the daemon's repo
|
|
3
|
+
// identity (src/cli/git.ts). Host-agnostic: GitHub gets the short `/gh/...`
|
|
4
|
+
// form, any other host uses `/git/<host>/...`.
|
|
4
5
|
|
|
5
6
|
import type { PeerInfo, PeerMeta } from "./signaling";
|
|
6
7
|
|
|
7
8
|
export const DEFAULT_LAYOUT = "{owner}/{repo}/tree/{branch}";
|
|
9
|
+
export const GITHUB_HOST = "github.com";
|
|
8
10
|
|
|
9
11
|
export interface RepoTarget {
|
|
10
|
-
|
|
12
|
+
/** Git host, e.g. "github.com" or "gitlab.com". */
|
|
13
|
+
host: string;
|
|
11
14
|
owner: string;
|
|
12
15
|
name: string;
|
|
13
16
|
/** Branch from the deep link, if present. */
|
|
@@ -26,10 +29,10 @@ export type DeepLink =
|
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
31
|
* Parse a deep-link pathname:
|
|
29
|
-
* /gh/<owner>/<repo>
|
|
30
|
-
* /
|
|
31
|
-
* /dev/<fs-path>
|
|
32
|
-
* Anything else -> null (normal app).
|
|
32
|
+
* /gh/<owner>/<repo>(/tree/<branch>) -> GitHub repo target
|
|
33
|
+
* /git/<host>/<owner>/<repo>(/tree/<branch>) -> any-host repo target
|
|
34
|
+
* /dev/<fs-path> -> direct folder mount
|
|
35
|
+
* Branch may contain slashes. Anything else -> null (normal app).
|
|
33
36
|
*/
|
|
34
37
|
export function parseDeepLink(pathname: string): DeepLink {
|
|
35
38
|
const clean = pathname.replace(/\/+$/, "");
|
|
@@ -37,7 +40,14 @@ export function parseDeepLink(pathname: string): DeepLink {
|
|
|
37
40
|
if (gh) {
|
|
38
41
|
return {
|
|
39
42
|
type: "repo",
|
|
40
|
-
target: {
|
|
43
|
+
target: { host: GITHUB_HOST, owner: gh[1], name: gh[2], branch: gh[3] },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const git = clean.match(/^\/git\/([^/]+)\/([^/]+)\/([^/]+)(?:\/tree\/(.+))?$/);
|
|
47
|
+
if (git) {
|
|
48
|
+
return {
|
|
49
|
+
type: "repo",
|
|
50
|
+
target: { host: git[1].toLowerCase(), owner: git[2], name: git[3], branch: git[4] },
|
|
41
51
|
};
|
|
42
52
|
}
|
|
43
53
|
const dev = clean.match(/^\/dev\/(.+)$/);
|
|
@@ -47,25 +57,28 @@ export function parseDeepLink(pathname: string): DeepLink {
|
|
|
47
57
|
return null;
|
|
48
58
|
}
|
|
49
59
|
|
|
50
|
-
/** Normalized repo key, e.g. "
|
|
51
|
-
export function repoKey(t: Pick<RepoTarget, "owner" | "name">): string {
|
|
52
|
-
return
|
|
60
|
+
/** Normalized repo key, e.g. "github.com/owner/repo" — matches PeerMeta.repo. */
|
|
61
|
+
export function repoKey(t: Pick<RepoTarget, "host" | "owner" | "name">): string {
|
|
62
|
+
return `${t.host}/${t.owner}/${t.name}`;
|
|
53
63
|
}
|
|
54
64
|
|
|
55
65
|
/**
|
|
56
|
-
* Normalize a served workspace path to the
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* `C:\Users\x` -> `/
|
|
60
|
-
*
|
|
61
|
-
*
|
|
66
|
+
* Normalize a served workspace path to the form VS Code web's `?folder=` query
|
|
67
|
+
* expects. On Windows that's the file-URI authority form: a leading slash, the
|
|
68
|
+
* drive letter and colon preserved, backslashes -> slashes —
|
|
69
|
+
* `C:\ws` -> `/C:/ws`, `C:\Users\x` -> `/C:/Users/x`, `D:\` -> `/D:`. (The
|
|
70
|
+
* git-bash `/c/ws` form does NOT resolve — serve-web reports "workspace does not
|
|
71
|
+
* exist".) POSIX absolute paths (mac/linux) are returned unchanged, and the
|
|
72
|
+
* result is idempotent. Used for `PeerMeta.cwd`, which feeds the `?folder=` URI
|
|
73
|
+
* (URL-encoded in transit, decoded back to this by VS Code) and the `/dev/<path>`
|
|
74
|
+
* deep link — the real OS path is still used for the local VS Code working dir.
|
|
62
75
|
*/
|
|
63
76
|
export function toPosixPath(p: string): string {
|
|
64
77
|
const drive = /^([A-Za-z]):(?:[\\/](.*))?$/.exec(p);
|
|
65
78
|
if (drive) {
|
|
66
|
-
const letter = drive[1]
|
|
79
|
+
const letter = drive[1]; // preserve drive-letter case
|
|
67
80
|
const rest = (drive[2] ?? "").replace(/\\/g, "/").replace(/\/+$/, "");
|
|
68
|
-
return rest ? `/${letter}
|
|
81
|
+
return rest ? `/${letter}:/${rest}` : `/${letter}:`;
|
|
69
82
|
}
|
|
70
83
|
// Already POSIX (or a relative path): just unify any stray backslashes.
|
|
71
84
|
return p.replace(/\\/g, "/");
|
|
@@ -79,6 +92,26 @@ export function fillLayout(layout: string, t: RepoTarget): string {
|
|
|
79
92
|
.replace(/\{branch\}/g, t.branch || "main");
|
|
80
93
|
}
|
|
81
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Shareable deep-link pathname for a connected workspace. A git-identified
|
|
97
|
+
* server renders `/gh/<owner>/<repo>` for GitHub or `/git/<host>/<owner>/<repo>`
|
|
98
|
+
* for any other host (with `/tree/<branch>` when known); a non-git workspace is
|
|
99
|
+
* addressed by its opened folder as a `/dev/<path>` mount. Round-trips through
|
|
100
|
+
* parseDeepLink + resolve{Repo,Dev}Target so another room member opening it
|
|
101
|
+
* lands here. Returns null when there's nothing addressable.
|
|
102
|
+
*/
|
|
103
|
+
export function shareableDeepLink(opts: { repo?: string; branch?: string; folder?: string }): string | null {
|
|
104
|
+
if (opts.repo) {
|
|
105
|
+
const [host, owner, name] = opts.repo.split("/");
|
|
106
|
+
if (host && owner && name) {
|
|
107
|
+
const base = host === GITHUB_HOST ? `/gh/${owner}/${name}` : `/git/${host}/${owner}/${name}`;
|
|
108
|
+
return opts.branch ? `${base}/tree/${opts.branch}` : base;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (opts.folder) return `/dev/${opts.folder.replace(/^\/+/, "")}`;
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
82
115
|
export interface Resolution {
|
|
83
116
|
peerId: string;
|
|
84
117
|
/** Folder to open via ?folder= (root kind); undefined opens the repo as-is. */
|
|
@@ -112,6 +145,24 @@ export function resolveDevTarget(servers: PeerInfo[], target: DevTarget): Resolu
|
|
|
112
145
|
return hit ? { peerId: hit.peerId } : null;
|
|
113
146
|
}
|
|
114
147
|
|
|
148
|
+
/** A candidate room (its token) plus how the deep link resolved within it. */
|
|
149
|
+
export interface RoomMatch {
|
|
150
|
+
token: string;
|
|
151
|
+
resolution: Resolution;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Rank matches found while searching multiple rooms for a token-less deep link.
|
|
156
|
+
* An *exact* match (a server that genuinely serves this repo/folder — no
|
|
157
|
+
* synthesized `folder`) beats a *root fallback* (a root daemon that would open
|
|
158
|
+
* the repo as a subfolder, which `resolveRepoTarget` returns for ANY repo link).
|
|
159
|
+
* Without this preference, first-responder-wins could pick an unrelated room
|
|
160
|
+
* that merely has a root server. Returns null when there are no matches.
|
|
161
|
+
*/
|
|
162
|
+
export function pickRoomMatch(matches: RoomMatch[]): RoomMatch | null {
|
|
163
|
+
return matches.find((m) => !m.resolution.folder) ?? matches[0] ?? null;
|
|
164
|
+
}
|
|
165
|
+
|
|
115
166
|
function branchOk(meta: PeerMeta, target: RepoTarget): boolean {
|
|
116
167
|
// No branch requested, or the server doesn't report one -> accept; else exact.
|
|
117
168
|
if (!target.branch || !meta.branch) return true;
|
package/src/shared/signaling.ts
CHANGED
|
@@ -18,7 +18,7 @@ export interface PeerMeta {
|
|
|
18
18
|
* Absent is treated as "repo" for backward compatibility.
|
|
19
19
|
*/
|
|
20
20
|
kind?: "repo" | "root";
|
|
21
|
-
/** repo kind:
|
|
21
|
+
/** repo kind: host-agnostic identity, e.g. "github.com/snomiao/codehost". */
|
|
22
22
|
repo?: string;
|
|
23
23
|
/** repo kind: current branch, e.g. "main". */
|
|
24
24
|
branch?: string;
|
package/src/web/discovery.tsx
CHANGED
|
@@ -9,12 +9,15 @@ import { registerTunnelHost } from "./tunnel-host";
|
|
|
9
9
|
import { connBroker } from "./conn-broker";
|
|
10
10
|
import {
|
|
11
11
|
type DeepLink,
|
|
12
|
+
type RoomMatch,
|
|
12
13
|
parseDeepLink,
|
|
14
|
+
pickRoomMatch,
|
|
13
15
|
repoKey,
|
|
14
16
|
resolveDevTarget,
|
|
15
17
|
resolveRepoTarget,
|
|
18
|
+
shareableDeepLink,
|
|
16
19
|
} from "../shared/repo";
|
|
17
|
-
import { addRoom, historyFor, recordConnection } from "./history";
|
|
20
|
+
import { addRoom, getRooms, historyFor, recordConnection } from "./history";
|
|
18
21
|
|
|
19
22
|
const TOKEN_KEY = "codehost.token";
|
|
20
23
|
|
|
@@ -46,6 +49,50 @@ function folderQuery(folder?: string): string {
|
|
|
46
49
|
return folder ? `?folder=${encodeURIComponent(folder)}` : "";
|
|
47
50
|
}
|
|
48
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Find which of the user's saved rooms hosts a server matching a token-less deep
|
|
54
|
+
* link. Opens a short-lived viewer connection to each candidate room in
|
|
55
|
+
* parallel. An *exact* match (a server that truly serves this workspace) wins
|
|
56
|
+
* immediately; *root-fallback* matches (any room with a root daemon, which
|
|
57
|
+
* `resolveRepoTarget` returns for ANY repo link) are only chosen at the timeout,
|
|
58
|
+
* via `pickRoomMatch`, so an unrelated room with a root server can't steal the
|
|
59
|
+
* link. Resolves to the winning room's token (or null on no match). All temp
|
|
60
|
+
* clients are closed.
|
|
61
|
+
*/
|
|
62
|
+
function findRoomForDeepLink(dl: DeepLink, tokens: string[], timeoutMs = 6000): Promise<string | null> {
|
|
63
|
+
if (!dl || tokens.length === 0) return Promise.resolve(null);
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
const clients: SignalingClient[] = [];
|
|
66
|
+
const fallbacks: RoomMatch[] = [];
|
|
67
|
+
let done = false;
|
|
68
|
+
const finish = (tok: string | null) => {
|
|
69
|
+
if (done) return;
|
|
70
|
+
done = true;
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
clients.forEach((c) => c.close());
|
|
73
|
+
resolve(tok);
|
|
74
|
+
};
|
|
75
|
+
const timer = setTimeout(() => finish(pickRoomMatch(fallbacks)?.token ?? null), timeoutMs);
|
|
76
|
+
for (const tok of tokens) {
|
|
77
|
+
const client = new SignalingClient({
|
|
78
|
+
url: getSignalUrl(),
|
|
79
|
+
token: tok,
|
|
80
|
+
role: "viewer",
|
|
81
|
+
onPeers: (peers) => {
|
|
82
|
+
const servers = peers.filter((p) => p.role === "server");
|
|
83
|
+
const res =
|
|
84
|
+
dl.type === "repo" ? resolveRepoTarget(servers, dl.target) : resolveDevTarget(servers, dl.target);
|
|
85
|
+
if (!res) return;
|
|
86
|
+
if (!res.folder) finish(tok); // exact match — take it now
|
|
87
|
+
else if (!fallbacks.some((f) => f.token === tok)) fallbacks.push({ token: tok, resolution: res });
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
clients.push(client);
|
|
91
|
+
client.connect();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
49
96
|
export function Discovery() {
|
|
50
97
|
const [token, setToken] = useState(() => {
|
|
51
98
|
const fromHash = tokenFromHash();
|
|
@@ -80,6 +127,11 @@ export function Discovery() {
|
|
|
80
127
|
const activeFolderRef = useRef<string | undefined>(undefined);
|
|
81
128
|
const [resolving, setResolving] = useState<string | null>(() => deepLinkLabel(deepLinkRef.current));
|
|
82
129
|
|
|
130
|
+
// Shareable deep-link pathname for the live connection (drives the address bar
|
|
131
|
+
// and the Share button); transient "copied" flag for the button.
|
|
132
|
+
const sharePathRef = useRef<string | null>(null);
|
|
133
|
+
const [copied, setCopied] = useState(false);
|
|
134
|
+
|
|
83
135
|
// Register the Service Worker + connection broker once. The broker shares one
|
|
84
136
|
// WebRTC connection per server across tabs; on owner failover it asks us to
|
|
85
137
|
// reload the iframe so it reconnects through the new owner.
|
|
@@ -102,12 +154,22 @@ export function Discovery() {
|
|
|
102
154
|
}
|
|
103
155
|
}
|
|
104
156
|
|
|
105
|
-
//
|
|
106
|
-
//
|
|
157
|
+
// Resolve a token-less deep link to a room: first the room that last served
|
|
158
|
+
// this repo, otherwise search all saved rooms for a live server that hosts
|
|
159
|
+
// this workspace and adopt it. Skipped when the link already carries a token.
|
|
107
160
|
const dl = deepLinkRef.current;
|
|
108
|
-
if (dl
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
161
|
+
if (dl && !(urlToken && validateToken(urlToken).ok)) {
|
|
162
|
+
const histToken = dl.type === "repo" ? historyFor(repoKey(dl.target))?.token : undefined;
|
|
163
|
+
if (histToken) {
|
|
164
|
+
setToken(histToken);
|
|
165
|
+
} else {
|
|
166
|
+
const rooms = getRooms();
|
|
167
|
+
if (rooms.length) {
|
|
168
|
+
void findRoomForDeepLink(dl, rooms).then((tok) => {
|
|
169
|
+
if (tok) setToken(tok);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
111
173
|
}
|
|
112
174
|
}, []);
|
|
113
175
|
|
|
@@ -212,11 +274,37 @@ export function Discovery() {
|
|
|
212
274
|
setIframeSrc(`/vs/${server.peerId}/${folderQuery(openFolder)}`);
|
|
213
275
|
setResolving(null);
|
|
214
276
|
recordConnect(server, openFolder);
|
|
277
|
+
updateAddressBar(server, openFolder);
|
|
215
278
|
} catch {
|
|
216
279
|
setConnState("failed");
|
|
217
280
|
}
|
|
218
281
|
}
|
|
219
282
|
|
|
283
|
+
// Reflect the live connection in the address bar as a clean, shareable deep
|
|
284
|
+
// link (no token — Share adds that). If we arrived via a deep link, keep its
|
|
285
|
+
// pathname; otherwise derive one from the server's repo identity or folder.
|
|
286
|
+
function updateAddressBar(server: PeerInfo, folder?: string) {
|
|
287
|
+
const path = deepLinkRef.current
|
|
288
|
+
? window.location.pathname
|
|
289
|
+
: shareableDeepLink({ repo: server.meta?.repo, branch: server.meta?.branch, folder });
|
|
290
|
+
if (!path) return;
|
|
291
|
+
sharePathRef.current = path;
|
|
292
|
+
if (path !== window.location.pathname) history.replaceState(null, "", path);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function shareLink() {
|
|
296
|
+
const path = sharePathRef.current ?? window.location.pathname;
|
|
297
|
+
const url = `${window.location.origin}${path}#t=${encodeURIComponent(token)}`;
|
|
298
|
+
try {
|
|
299
|
+
await navigator.clipboard.writeText(url);
|
|
300
|
+
} catch {
|
|
301
|
+
// clipboard blocked (insecure context / permission) — fall back to prompt
|
|
302
|
+
window.prompt("Copy this share link:", url);
|
|
303
|
+
}
|
|
304
|
+
setCopied(true);
|
|
305
|
+
setTimeout(() => setCopied(false), 1500);
|
|
306
|
+
}
|
|
307
|
+
|
|
220
308
|
// Deep-link auto-connect: when servers arrive, pick the best match (exact repo
|
|
221
309
|
// daemon, else a root daemon's subfolder) and open it once.
|
|
222
310
|
async function tryAutoConnect(list: PeerInfo[]) {
|
|
@@ -261,6 +349,8 @@ export function Discovery() {
|
|
|
261
349
|
setActivePeerId(null);
|
|
262
350
|
activePeerRef.current = null;
|
|
263
351
|
setConnState("idle");
|
|
352
|
+
sharePathRef.current = null;
|
|
353
|
+
if (window.location.pathname !== "/") history.replaceState(null, "", "/");
|
|
264
354
|
}
|
|
265
355
|
|
|
266
356
|
const activeServer = servers.find((s) => s.peerId === activePeerId);
|
|
@@ -275,6 +365,13 @@ export function Discovery() {
|
|
|
275
365
|
<span style={styles.dim}>{activeServer?.meta?.name ?? activePeerId?.slice(0, 8)}</span>
|
|
276
366
|
{activeServer?.meta?.cwd && <span style={styles.cwd}>{activeServer.meta.cwd}</span>}
|
|
277
367
|
<span style={{ flex: 1 }} />
|
|
368
|
+
<button
|
|
369
|
+
style={styles.shareBtn}
|
|
370
|
+
onClick={shareLink}
|
|
371
|
+
title="Copy a link that opens this workspace (includes the room token)"
|
|
372
|
+
>
|
|
373
|
+
{copied ? "Copied!" : "Share"}
|
|
374
|
+
</button>
|
|
278
375
|
<button style={styles.connectBtn} onClick={disconnect}>
|
|
279
376
|
Disconnect
|
|
280
377
|
</button>
|
|
@@ -389,4 +486,5 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
389
486
|
cwd: { fontFamily: "monospace" },
|
|
390
487
|
echo: { marginTop: 6, fontSize: 12, color: "#4ec9b0", fontFamily: "monospace" },
|
|
391
488
|
connectBtn: { background: "#0e639c", border: "none", color: "#fff", padding: "6px 14px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
|
|
489
|
+
shareBtn: { background: "transparent", border: "1px solid #3d3d3d", color: "#ccc", padding: "6px 14px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
|
|
392
490
|
};
|