codehost 0.12.0 → 0.14.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 +40 -12
- package/src/web/discovery.tsx +29 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.14.0](https://github.com/snomiao/codehost/compare/v0.13.0...v0.14.0) (2026-06-10)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **web:** always show /tree/<branch> for repo workspaces ([ad34bc2](https://github.com/snomiao/codehost/commit/ad34bc2e2e8acea98ac392b90766488c6f3b4e52))
|
|
7
|
+
|
|
8
|
+
# [0.13.0](https://github.com/snomiao/codehost/compare/v0.12.0...v0.13.0) (2026-06-09)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **web:** host-scoped folder deep links — /host/<hostname>/<path> ([f6955ab](https://github.com/snomiao/codehost/commit/f6955ab37517f312ee780795efc503cb74ccdc36))
|
|
14
|
+
|
|
1
15
|
# [0.12.0](https://github.com/snomiao/codehost/compare/v0.11.1...v0.12.0) (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
|
@@ -7,6 +7,10 @@ import type { PeerInfo, PeerMeta } from "./signaling";
|
|
|
7
7
|
|
|
8
8
|
export const DEFAULT_LAYOUT = "{owner}/{repo}/tree/{branch}";
|
|
9
9
|
export const GITHUB_HOST = "github.com";
|
|
10
|
+
/** Branch assumed when a repo link/target carries none — what `fillLayout` opens
|
|
11
|
+
* and what the address bar shows, so a bare `/gh/<owner>/<repo>` canonicalizes
|
|
12
|
+
* to `/gh/<owner>/<repo>/tree/<DEFAULT_BRANCH>`. */
|
|
13
|
+
export const DEFAULT_BRANCH = "main";
|
|
10
14
|
|
|
11
15
|
export interface RepoTarget {
|
|
12
16
|
/** Git host, e.g. "github.com" or "gitlab.com". */
|
|
@@ -17,8 +21,12 @@ export interface RepoTarget {
|
|
|
17
21
|
branch?: string;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
/** A direct folder mount address: `/
|
|
24
|
+
/** A direct folder mount address: host-scoped `/host/<hostname>/<path>`, or the
|
|
25
|
+
* legacy host-agnostic `/dev/<path>` (a bare path collides across machines, so
|
|
26
|
+
* new links carry the host). */
|
|
21
27
|
export interface DevTarget {
|
|
28
|
+
/** Hostname the workspace lives on; undefined for a legacy host-agnostic link. */
|
|
29
|
+
host?: string;
|
|
22
30
|
path: string;
|
|
23
31
|
}
|
|
24
32
|
|
|
@@ -31,7 +39,8 @@ export type DeepLink =
|
|
|
31
39
|
* Parse a deep-link pathname:
|
|
32
40
|
* /gh/<owner>/<repo>(/tree/<branch>) -> GitHub repo target
|
|
33
41
|
* /git/<host>/<owner>/<repo>(/tree/<branch>) -> any-host repo target
|
|
34
|
-
* /
|
|
42
|
+
* /host/<hostname>/<fs-path> -> host-scoped folder mount
|
|
43
|
+
* /dev/<fs-path> -> legacy host-agnostic folder mount
|
|
35
44
|
* Branch may contain slashes. Anything else -> null (normal app).
|
|
36
45
|
*/
|
|
37
46
|
export function parseDeepLink(pathname: string): DeepLink {
|
|
@@ -50,6 +59,13 @@ export function parseDeepLink(pathname: string): DeepLink {
|
|
|
50
59
|
target: { host: git[1].toLowerCase(), owner: git[2], name: git[3], branch: git[4] },
|
|
51
60
|
};
|
|
52
61
|
}
|
|
62
|
+
// Host-scoped folder mount: first segment is the hostname, the rest is the
|
|
63
|
+
// served path (which itself may contain slashes and a Windows drive colon).
|
|
64
|
+
const host = clean.match(/^\/host\/([^/]+)\/(.+)$/);
|
|
65
|
+
if (host) {
|
|
66
|
+
return { type: "dev", target: { host: host[1], path: `/${host[2].replace(/^\/+/, "")}` } };
|
|
67
|
+
}
|
|
68
|
+
// Legacy host-agnostic folder mount.
|
|
53
69
|
const dev = clean.match(/^\/dev\/(.+)$/);
|
|
54
70
|
if (dev) {
|
|
55
71
|
return { type: "dev", target: { path: `/${dev[1].replace(/^\/+/, "")}` } };
|
|
@@ -84,23 +100,29 @@ export function toPosixPath(p: string): string {
|
|
|
84
100
|
return p.replace(/\\/g, "/");
|
|
85
101
|
}
|
|
86
102
|
|
|
87
|
-
/** Fill a layout template from a repo target (default branch ->
|
|
103
|
+
/** Fill a layout template from a repo target (default branch -> DEFAULT_BRANCH). */
|
|
88
104
|
export function fillLayout(layout: string, t: RepoTarget): string {
|
|
89
105
|
return layout
|
|
90
106
|
.replace(/\{owner\}/g, t.owner)
|
|
91
107
|
.replace(/\{repo\}/g, t.name)
|
|
92
|
-
.replace(/\{branch\}/g, t.branch ||
|
|
108
|
+
.replace(/\{branch\}/g, t.branch || DEFAULT_BRANCH);
|
|
93
109
|
}
|
|
94
110
|
|
|
95
111
|
/**
|
|
96
112
|
* Shareable deep-link pathname for a connected workspace. A git-identified
|
|
97
113
|
* server renders `/gh/<owner>/<repo>` for GitHub or `/git/<host>/<owner>/<repo>`
|
|
98
114
|
* for any other host (with `/tree/<branch>` when known); a non-git workspace is
|
|
99
|
-
* addressed by its opened folder as
|
|
115
|
+
* addressed by its opened folder, scoped to its hostname as `/host/<host>/<path>`
|
|
116
|
+
* (or the legacy `/dev/<path>` when no host is known). Round-trips through
|
|
100
117
|
* parseDeepLink + resolve{Repo,Dev}Target so another room member opening it
|
|
101
118
|
* lands here. Returns null when there's nothing addressable.
|
|
102
119
|
*/
|
|
103
|
-
export function shareableDeepLink(opts: {
|
|
120
|
+
export function shareableDeepLink(opts: {
|
|
121
|
+
repo?: string;
|
|
122
|
+
branch?: string;
|
|
123
|
+
folder?: string;
|
|
124
|
+
host?: string;
|
|
125
|
+
}): string | null {
|
|
104
126
|
if (opts.repo) {
|
|
105
127
|
const [host, owner, name] = opts.repo.split("/");
|
|
106
128
|
if (host && owner && name) {
|
|
@@ -108,7 +130,10 @@ export function shareableDeepLink(opts: { repo?: string; branch?: string; folder
|
|
|
108
130
|
return opts.branch ? `${base}/tree/${opts.branch}` : base;
|
|
109
131
|
}
|
|
110
132
|
}
|
|
111
|
-
if (opts.folder)
|
|
133
|
+
if (opts.folder) {
|
|
134
|
+
const path = opts.folder.replace(/^\/+/, "");
|
|
135
|
+
return opts.host ? `/host/${opts.host}/${path}` : `/dev/${path}`;
|
|
136
|
+
}
|
|
112
137
|
return null;
|
|
113
138
|
}
|
|
114
139
|
|
|
@@ -138,13 +163,16 @@ export function resolveRepoTarget(servers: PeerInfo[], target: RepoTarget): Reso
|
|
|
138
163
|
return null;
|
|
139
164
|
}
|
|
140
165
|
|
|
141
|
-
/** Pick a
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
166
|
+
/** Pick a folder-mount server whose served cwd matches the target path, scoped
|
|
167
|
+
* to `target.host` when the link carries one (a bare path is ambiguous across
|
|
168
|
+
* machines). Compares with leading + trailing slashes stripped: `parseDeepLink`
|
|
169
|
+
* forces a leading "/" on the path, but a served cwd may lack one (e.g. an
|
|
170
|
+
* `expose` server's `localhost:<port>`), so a trailing-only trim never matches. */
|
|
145
171
|
export function resolveDevTarget(servers: PeerInfo[], target: DevTarget): Resolution | null {
|
|
146
172
|
const want = stripEnds(target.path);
|
|
147
|
-
const hit = servers.find(
|
|
173
|
+
const hit = servers.find(
|
|
174
|
+
(s) => s.meta && stripEnds(s.meta.cwd) === want && (!target.host || s.meta.host === target.host),
|
|
175
|
+
);
|
|
148
176
|
return hit ? { peerId: hit.peerId } : null;
|
|
149
177
|
}
|
|
150
178
|
|
package/src/web/discovery.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { getSignalUrl } from "./config";
|
|
|
8
8
|
import { registerTunnelHost } from "./tunnel-host";
|
|
9
9
|
import { connBroker } from "./conn-broker";
|
|
10
10
|
import {
|
|
11
|
+
DEFAULT_BRANCH,
|
|
11
12
|
type DeepLink,
|
|
12
13
|
type RoomMatch,
|
|
13
14
|
parseDeepLink,
|
|
@@ -46,7 +47,8 @@ function tokenFromHash(): string {
|
|
|
46
47
|
/** Short label for the "looking for…" state from a deep link. */
|
|
47
48
|
function deepLinkLabel(dl: DeepLink): string | null {
|
|
48
49
|
if (!dl) return null;
|
|
49
|
-
|
|
50
|
+
if (dl.type === "repo") return `${dl.target.owner}/${dl.target.name}`;
|
|
51
|
+
return dl.target.host ? `${dl.target.host}:${dl.target.path}` : dl.target.path;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
function folderQuery(folder?: string): string {
|
|
@@ -355,10 +357,19 @@ export function Discovery() {
|
|
|
355
357
|
sharePathRef.current = window.location.pathname;
|
|
356
358
|
} else {
|
|
357
359
|
const targetPath = shareablePathFor(server, openFolder);
|
|
358
|
-
didPush = !!targetPath && targetPath !== window.location.pathname;
|
|
359
|
-
if (didPush) history.pushState(null, "", targetPath);
|
|
360
|
-
pushedRef.current = didPush;
|
|
361
360
|
sharePathRef.current = targetPath ?? window.location.pathname;
|
|
361
|
+
if (targetPath && targetPath !== window.location.pathname) {
|
|
362
|
+
if (deepLinkRef.current) {
|
|
363
|
+
// Arrived via a deep link — canonicalize the URL in place (e.g. add
|
|
364
|
+
// /tree/<branch>). Same destination, so replace, don't push a
|
|
365
|
+
// back-to-the-list entry.
|
|
366
|
+
history.replaceState(null, "", targetPath);
|
|
367
|
+
} else {
|
|
368
|
+
history.pushState(null, "", targetPath);
|
|
369
|
+
didPush = true;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
pushedRef.current = didPush;
|
|
362
373
|
}
|
|
363
374
|
|
|
364
375
|
// The broker decides whether this tab owns the connection. `establish` is
|
|
@@ -416,9 +427,20 @@ export function Discovery() {
|
|
|
416
427
|
// token — Share adds that). Keeps an existing deep-link path as-is; otherwise
|
|
417
428
|
// derives /gh|/git|/dev from the server's repo identity or opened folder.
|
|
418
429
|
function shareablePathFor(server: PeerInfo, folder?: string): string | null {
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
430
|
+
const dl = deepLinkRef.current;
|
|
431
|
+
// A repo workspace always shows /tree/<branch> (GitHub-style, and it pins the
|
|
432
|
+
// worktree in snomiao's /tree/<branch> layout). Branch source, in order: the
|
|
433
|
+
// deep link's branch, the server's reported branch, else the layout default —
|
|
434
|
+
// matching the worktree fillLayout actually opened.
|
|
435
|
+
if (dl?.type === "repo") {
|
|
436
|
+
const branch = dl.target.branch ?? server.meta?.branch ?? DEFAULT_BRANCH;
|
|
437
|
+
return shareableDeepLink({ repo: repoKey(dl.target), branch });
|
|
438
|
+
}
|
|
439
|
+
if (server.meta?.repo) {
|
|
440
|
+
return shareableDeepLink({ repo: server.meta.repo, branch: server.meta.branch ?? DEFAULT_BRANCH });
|
|
441
|
+
}
|
|
442
|
+
// Folder mount: keep the deep-link path as-is, else derive the host-scoped one.
|
|
443
|
+
return dl ? window.location.pathname : shareableDeepLink({ folder, host: server.meta?.host });
|
|
422
444
|
}
|
|
423
445
|
|
|
424
446
|
async function shareLink() {
|