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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehost",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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>, /git/<host>/<owner>/<repo> for a git repo)",
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", {
@@ -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(
@@ -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: `/dev/<absolute-fs-path>`. */
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
- * /dev/<fs-path> -> direct folder mount
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 -> "main"). */
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 || "main");
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 a `/dev/<path>` mount. Round-trips through
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: { repo?: string; branch?: string; folder?: string }): string | null {
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) return `/dev/${opts.folder.replace(/^\/+/, "")}`;
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 `dev`/repo server whose served cwd matches a /dev/<path> target.
142
- * Compares with leading + trailing slashes stripped: `parseDeepLink` forces a
143
- * leading "/" on the path, but a served cwd may lack one (e.g. an `expose`
144
- * server's `localhost:<port>`), so a trailing-only trim would never match it. */
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((s) => s.meta && stripEnds(s.meta.cwd) === want);
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
 
@@ -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
- return dl.type === "repo" ? `${dl.target.owner}/${dl.target.name}` : dl.target.path;
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
- return deepLinkRef.current
420
- ? window.location.pathname
421
- : shareableDeepLink({ repo: server.meta?.repo, branch: server.meta?.branch, folder });
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() {