codehost 0.12.0 → 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 +7 -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 +34 -10
- package/src/web/discovery.tsx +8 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
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
|
+
|
|
1
8
|
# [0.12.0](https://github.com/snomiao/codehost/compare/v0.11.1...v0.12.0) (2026-06-09)
|
|
2
9
|
|
|
3
10
|
|
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,13 +159,16 @@ export function resolveRepoTarget(servers: PeerInfo[], target: RepoTarget): Reso
|
|
|
138
159
|
return null;
|
|
139
160
|
}
|
|
140
161
|
|
|
141
|
-
/** Pick a
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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. */
|
|
145
167
|
export function resolveDevTarget(servers: PeerInfo[], target: DevTarget): Resolution | null {
|
|
146
168
|
const want = stripEnds(target.path);
|
|
147
|
-
const hit = servers.find(
|
|
169
|
+
const hit = servers.find(
|
|
170
|
+
(s) => s.meta && stripEnds(s.meta.cwd) === want && (!target.host || s.meta.host === target.host),
|
|
171
|
+
);
|
|
148
172
|
return hit ? { peerId: hit.peerId } : null;
|
|
149
173
|
}
|
|
150
174
|
|
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 {
|
|
@@ -418,7 +419,12 @@ export function Discovery() {
|
|
|
418
419
|
function shareablePathFor(server: PeerInfo, folder?: string): string | null {
|
|
419
420
|
return deepLinkRef.current
|
|
420
421
|
? window.location.pathname
|
|
421
|
-
: shareableDeepLink({
|
|
422
|
+
: shareableDeepLink({
|
|
423
|
+
repo: server.meta?.repo,
|
|
424
|
+
branch: server.meta?.branch,
|
|
425
|
+
folder,
|
|
426
|
+
host: server.meta?.host,
|
|
427
|
+
});
|
|
422
428
|
}
|
|
423
429
|
|
|
424
430
|
async function shareLink() {
|