codehost 0.13.0 → 0.15.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/docs/provisioning.md +130 -0
- package/package.json +1 -1
- package/src/shared/repo.test.ts +48 -1
- package/src/shared/repo.ts +35 -4
- package/src/web/discovery.tsx +71 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.15.0](https://github.com/snomiao/codehost/compare/v0.14.0...v0.15.0) (2026-06-10)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **web:** 'open a GitHub URL' box + deepest-root repo resolution ([0d9ebec](https://github.com/snomiao/codehost/commit/0d9ebec4409430c327e2945657b6803ca704fd53))
|
|
7
|
+
|
|
8
|
+
# [0.14.0](https://github.com/snomiao/codehost/compare/v0.13.0...v0.14.0) (2026-06-10)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **web:** always show /tree/<branch> for repo workspaces ([ad34bc2](https://github.com/snomiao/codehost/commit/ad34bc2e2e8acea98ac392b90766488c6f3b4e52))
|
|
14
|
+
|
|
1
15
|
# [0.13.0](https://github.com/snomiao/codehost/compare/v0.12.0...v0.13.0) (2026-06-09)
|
|
2
16
|
|
|
3
17
|
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Workspace provisioning
|
|
2
|
+
|
|
3
|
+
Status: **design / proposal** (not implemented). Captures the design discussed
|
|
4
|
+
for "open a repo link → the workspace materializes on the host".
|
|
5
|
+
|
|
6
|
+
## Goal
|
|
7
|
+
|
|
8
|
+
Opening a repo deep link should *materialize* the workspace on the serving host
|
|
9
|
+
on demand — clone / worktree-add / install — instead of requiring it to already
|
|
10
|
+
exist. Today, opening `/gh/<owner>/<repo>/tree/<branch>` when that worktree isn't
|
|
11
|
+
present just fails (`serve-web` reports "workspace does not exist").
|
|
12
|
+
|
|
13
|
+
The host owner stays in control: all provisioning is a **user-authored,
|
|
14
|
+
idempotent script** on the host. codehost never invents commands.
|
|
15
|
+
|
|
16
|
+
## Model
|
|
17
|
+
|
|
18
|
+
`codehost serve` runs in a *home* directory (`$HOME_ROOT`):
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
$HOME_ROOT/
|
|
22
|
+
├── .codehost/ # config dir — edited in VS Code itself
|
|
23
|
+
│ ├── config.yaml # settings (workspace path template, allowlist…)
|
|
24
|
+
│ ├── setup.sh / setup.bat # idempotent provisioning, run on every open
|
|
25
|
+
│ └── … # any other files the user keeps here
|
|
26
|
+
└── ws/ # default workspaces root (redefinable in config.yaml)
|
|
27
|
+
└── <owner>/<repo>/tree/<branch>/
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- `.codehost/` is the config folder. `ws/` is where github-shaped paths land.
|
|
31
|
+
- Default layout: `ws/{owner}/{repo}/tree/{branch}` (the `tree/<branch>` worktree
|
|
32
|
+
convention — branches are real side-by-side directories).
|
|
33
|
+
- The workspace path is redefinable in `config.yaml`.
|
|
34
|
+
|
|
35
|
+
## Open flow
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
open /gh/<owner>/<repo>/tree/<branch>
|
|
39
|
+
→ daemon runs .codehost/setup.sh with owner/repo/branch (+ host)
|
|
40
|
+
setup.sh decides: clone / git worktree add / pull / rebase / skip
|
|
41
|
+
(idempotent — the policy, incl. auto-upgrade/rebase, is the user's)
|
|
42
|
+
→ VS Code opens the resulting workspace path
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Because `setup.sh` is idempotent and runs **every** time, codehost does not track
|
|
46
|
+
clone state at all — the script clones if missing, fast-skips if present, and the
|
|
47
|
+
user decides whether to auto-pull/rebase. This removes a lot of codehost logic.
|
|
48
|
+
|
|
49
|
+
## `setup.sh` contract
|
|
50
|
+
|
|
51
|
+
- **Input** (env, never interpolated into a command string):
|
|
52
|
+
`CODEHOST_OWNER`, `CODEHOST_REPO`, `CODEHOST_BRANCH`, `CODEHOST_HOST`,
|
|
53
|
+
`CODEHOST_HOME` (the home root), `CODEHOST_WS` (the resolved default path from
|
|
54
|
+
the `config.yaml` template).
|
|
55
|
+
- **Output**: the absolute workspace path on stdout (last line). If it prints
|
|
56
|
+
nothing, codehost uses `CODEHOST_WS` (the template default).
|
|
57
|
+
- **Exit non-zero** → provisioning failed; surface the error to the browser.
|
|
58
|
+
- Must be **idempotent** and fast on an already-provisioned path (skip).
|
|
59
|
+
- **Windows**: `setup.bat` (or pwsh) with the same env contract.
|
|
60
|
+
|
|
61
|
+
## `config.yaml` (sketch)
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
workspace: "ws/{owner}/{repo}/tree/{branch}" # path template, relative to home
|
|
65
|
+
allowlist: # which repos may auto-provision
|
|
66
|
+
- github.com/snomiao/*
|
|
67
|
+
# …future: default branch, install hook, etc.
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Reuse VS Code for config (no custom UI)
|
|
71
|
+
|
|
72
|
+
Editing config = open `$HOME_ROOT/.codehost/` in the **existing VS Code iframe**.
|
|
73
|
+
A "Config" affordance in the header opens `?folder=<home>/.codehost`. No
|
|
74
|
+
`/p/<peer-id>/` settings page, no re-invented editor. (Peer ids are ephemeral —
|
|
75
|
+
they change on every daemon restart — so config is keyed by the host/home, not
|
|
76
|
+
the peer.)
|
|
77
|
+
|
|
78
|
+
## Create-from-GitHub input box
|
|
79
|
+
|
|
80
|
+
On the workspace list page, an input: **paste a GitHub URL**.
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
https://github.com/snomiao/codehost/tree/main
|
|
84
|
+
→ parse → /gh/snomiao/codehost/tree/main → open (triggers provisioning)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
One paste opens any GitHub repo on a connected host (= "create workspace from
|
|
88
|
+
GitHub"). Accepts bare `github.com/<owner>/<repo>` and `…/tree/<branch>` forms.
|
|
89
|
+
|
|
90
|
+
## Provisioning UX
|
|
91
|
+
|
|
92
|
+
- While `setup.sh` runs, show a **"provisioning…"** state with the script's
|
|
93
|
+
stdout/stderr **streamed over the tunnel** (clone/install can take a while).
|
|
94
|
+
- Idempotent re-opens skip and are near-instant.
|
|
95
|
+
|
|
96
|
+
## Security (the crux)
|
|
97
|
+
|
|
98
|
+
- **Commands are host-authored** (`setup.sh` lives on the host). The link/viewer
|
|
99
|
+
supplies only `owner/repo/branch` identity — never commands.
|
|
100
|
+
- codehost **sanitizes** `owner/repo/branch` before handing them to `setup.sh`
|
|
101
|
+
(reject `;`, `$()`, backticks, newlines, `..`, path separators where not
|
|
102
|
+
expected) and passes them as **env**, not string-interpolated into a command.
|
|
103
|
+
- **allowlist** (`config.yaml` / `setup.sh`) gates which repos auto-provision;
|
|
104
|
+
others prompt or are denied.
|
|
105
|
+
- Any room member can trigger `setup.sh` with any identity, so the **room token
|
|
106
|
+
is the trust boundary** and `setup.sh`/allowlist owns repo policy. Editing
|
|
107
|
+
config (= changing what runs on the host) may warrant owner auth beyond the
|
|
108
|
+
room token — open question.
|
|
109
|
+
|
|
110
|
+
## Defaults / scaffolding
|
|
111
|
+
|
|
112
|
+
- No `.codehost/setup.sh` → fall back to today's behavior (resolve to the layout
|
|
113
|
+
path, no clone).
|
|
114
|
+
- `codehost init` scaffolds a starter `.codehost/` (`config.yaml` + a `setup.sh`
|
|
115
|
+
that does `gh repo clone` + `git worktree add` + the `ws/` convention).
|
|
116
|
+
|
|
117
|
+
## Related fix (independent, same path)
|
|
118
|
+
|
|
119
|
+
`resolveRepoTarget` picks the *first* root daemon without checking which one
|
|
120
|
+
actually contains the repo, so with multiple roots it can pick the wrong one
|
|
121
|
+
(observed live: it chose `/Users/sno` over the correct `/Users/sno/ws`, yielding
|
|
122
|
+
a non-existent subpath). Prefer the **deepest matching root** (longest `cwd`
|
|
123
|
+
prefix). Not part of provisioning, but it gates the same "open a repo" path.
|
|
124
|
+
|
|
125
|
+
## Open questions
|
|
126
|
+
|
|
127
|
+
1. Source of truth for the path: `setup.sh` stdout vs `config.yaml` template.
|
|
128
|
+
(Proposed: stdout wins; the template is the default handed in as `CODEHOST_WS`.)
|
|
129
|
+
2. Owner auth for editing config beyond the room token?
|
|
130
|
+
3. Log streaming transport: a new tunnel channel vs reuse of an existing one.
|
package/package.json
CHANGED
package/src/shared/repo.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { parseDeepLink, pickRoomMatch, repoKey, resolveDevTarget, shareableDeepLink, toPosixPath } from "./repo";
|
|
2
|
+
import { gitUrlToPath, parseDeepLink, pickRoomMatch, repoKey, resolveDevTarget, resolveRepoTarget, shareableDeepLink, toPosixPath } from "./repo";
|
|
3
3
|
import type { PeerInfo } from "./signaling";
|
|
4
4
|
import { parseGitRemote } from "../cli/git";
|
|
5
5
|
|
|
@@ -194,6 +194,53 @@ describe("shareableDeepLink", () => {
|
|
|
194
194
|
});
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
+
describe("resolveRepoTarget root selection", () => {
|
|
198
|
+
const root = (peerId: string, cwd: string): PeerInfo => ({
|
|
199
|
+
peerId,
|
|
200
|
+
role: "server",
|
|
201
|
+
meta: { name: peerId, host: "Mac", cwd, kind: "root" },
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("among nested roots, picks the deepest (longest cwd)", () => {
|
|
205
|
+
const servers = [root("shallow", "/Users/sno"), root("deep", "/Users/sno/ws")];
|
|
206
|
+
const res = resolveRepoTarget(servers, { host: "github.com", owner: "snomiao", name: "codehost" });
|
|
207
|
+
expect(res?.peerId).toBe("deep");
|
|
208
|
+
expect(res?.folder).toBe("/Users/sno/ws/snomiao/codehost/tree/main");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("an exact repo daemon still wins over any root", () => {
|
|
212
|
+
const servers: PeerInfo[] = [
|
|
213
|
+
root("deep", "/Users/sno/ws"),
|
|
214
|
+
{ peerId: "exact", role: "server", meta: { name: "x", host: "Mac", cwd: "/x", repo: "github.com/snomiao/codehost" } },
|
|
215
|
+
];
|
|
216
|
+
expect(resolveRepoTarget(servers, { host: "github.com", owner: "snomiao", name: "codehost" })?.peerId).toBe("exact");
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("gitUrlToPath", () => {
|
|
221
|
+
test("github URLs -> /gh, preserving branch (incl. slashes)", () => {
|
|
222
|
+
expect(gitUrlToPath("https://github.com/snomiao/codehost")).toBe("/gh/snomiao/codehost");
|
|
223
|
+
expect(gitUrlToPath("https://github.com/snomiao/codehost/tree/main")).toBe("/gh/snomiao/codehost/tree/main");
|
|
224
|
+
expect(gitUrlToPath("github.com/snomiao/codehost")).toBe("/gh/snomiao/codehost");
|
|
225
|
+
expect(gitUrlToPath("https://github.com/snomiao/codehost.git")).toBe("/gh/snomiao/codehost");
|
|
226
|
+
expect(gitUrlToPath("https://github.com/snomiao/codehost/tree/feat/x")).toBe("/gh/snomiao/codehost/tree/feat/x");
|
|
227
|
+
expect(gitUrlToPath("https://github.com/snomiao/codehost/")).toBe("/gh/snomiao/codehost");
|
|
228
|
+
expect(gitUrlToPath("https://github.com/snomiao/codehost?tab=readme#x")).toBe("/gh/snomiao/codehost");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("other hosts + scp form -> /git/<host>/...", () => {
|
|
232
|
+
expect(gitUrlToPath("https://gitlab.com/group/proj/tree/dev")).toBe("/git/gitlab.com/group/proj/tree/dev");
|
|
233
|
+
expect(gitUrlToPath("git@github.com:snomiao/codehost.git")).toBe("/gh/snomiao/codehost");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("non-repo / junk -> null", () => {
|
|
237
|
+
expect(gitUrlToPath("")).toBeNull();
|
|
238
|
+
expect(gitUrlToPath("not a url")).toBeNull();
|
|
239
|
+
expect(gitUrlToPath("https://github.com/snomiao")).toBeNull(); // no repo
|
|
240
|
+
expect(gitUrlToPath("/gh/snomiao/codehost")).toBeNull(); // already a deep link, no host
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
197
244
|
describe("pickRoomMatch (cross-room ranking)", () => {
|
|
198
245
|
const exact = { token: "tA", resolution: { peerId: "p1" } }; // no folder = exact
|
|
199
246
|
const root = { token: "tB", resolution: { peerId: "p2", folder: "/work/me/repo" } };
|
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". */
|
|
@@ -96,12 +100,12 @@ export function toPosixPath(p: string): string {
|
|
|
96
100
|
return p.replace(/\\/g, "/");
|
|
97
101
|
}
|
|
98
102
|
|
|
99
|
-
/** Fill a layout template from a repo target (default branch ->
|
|
103
|
+
/** Fill a layout template from a repo target (default branch -> DEFAULT_BRANCH). */
|
|
100
104
|
export function fillLayout(layout: string, t: RepoTarget): string {
|
|
101
105
|
return layout
|
|
102
106
|
.replace(/\{owner\}/g, t.owner)
|
|
103
107
|
.replace(/\{repo\}/g, t.name)
|
|
104
|
-
.replace(/\{branch\}/g, t.branch ||
|
|
108
|
+
.replace(/\{branch\}/g, t.branch || DEFAULT_BRANCH);
|
|
105
109
|
}
|
|
106
110
|
|
|
107
111
|
/**
|
|
@@ -133,6 +137,28 @@ export function shareableDeepLink(opts: {
|
|
|
133
137
|
return null;
|
|
134
138
|
}
|
|
135
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Turn a pasted git repo URL into a codehost deep-link path: github.com ->
|
|
142
|
+
* `/gh/<owner>/<repo>`, any other host -> `/git/<host>/<owner>/<repo>`,
|
|
143
|
+
* preserving `/tree/<branch>`. Accepts with or without a scheme, an `scp`-style
|
|
144
|
+
* `git@host:owner/repo`, a trailing `.git`, query/hash, and a branch containing
|
|
145
|
+
* slashes. Returns null when it isn't a recognizable repo URL — lets the "open a
|
|
146
|
+
* GitHub URL" box reuse the same resolution as a typed deep link.
|
|
147
|
+
*/
|
|
148
|
+
export function gitUrlToPath(input: string): string | null {
|
|
149
|
+
let s = input.trim();
|
|
150
|
+
if (!s) return null;
|
|
151
|
+
s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ""); // scheme://
|
|
152
|
+
s = s.replace(/^[^@/]+@/, ""); // user@ (incl. git@)
|
|
153
|
+
s = s.replace(/^([^/:]+):(?!\d)/, "$1/"); // scp-style host:owner/repo -> host/owner/repo
|
|
154
|
+
s = s.split(/[?#]/)[0].replace(/\/+$/, ""); // drop query/hash + trailing slash
|
|
155
|
+
const m = s.match(/^([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/tree\/(.+))?$/);
|
|
156
|
+
if (!m) return null;
|
|
157
|
+
const host = m[1].toLowerCase();
|
|
158
|
+
if (!host.includes(".")) return null; // require a real hostname (github.com)
|
|
159
|
+
return shareableDeepLink({ repo: `${host}/${m[2]}/${m[3]}`, branch: m[4] });
|
|
160
|
+
}
|
|
161
|
+
|
|
136
162
|
export interface Resolution {
|
|
137
163
|
peerId: string;
|
|
138
164
|
/** Folder to open via ?folder= (root kind); undefined opens the repo as-is. */
|
|
@@ -142,7 +168,10 @@ export interface Resolution {
|
|
|
142
168
|
/**
|
|
143
169
|
* Pick the best live server for a repo deep link. Prefers an exact `repo`
|
|
144
170
|
* daemon; otherwise falls back to a `root` daemon that can open the subfolder.
|
|
145
|
-
*
|
|
171
|
+
* Among several roots, prefers the **deepest** (longest cwd) — with a nested
|
|
172
|
+
* setup like /Users/sno and /Users/sno/ws both serving, the layout subfolder
|
|
173
|
+
* exists under the deeper one (observed: /gh/snomiao/codehost belongs to
|
|
174
|
+
* /Users/sno/ws/..., not /Users/sno/...). Returns null if nothing matches.
|
|
146
175
|
*/
|
|
147
176
|
export function resolveRepoTarget(servers: PeerInfo[], target: RepoTarget): Resolution | null {
|
|
148
177
|
const key = repoKey(target);
|
|
@@ -151,7 +180,9 @@ export function resolveRepoTarget(servers: PeerInfo[], target: RepoTarget): Reso
|
|
|
151
180
|
);
|
|
152
181
|
if (repoMatch) return { peerId: repoMatch.peerId };
|
|
153
182
|
|
|
154
|
-
const root = servers
|
|
183
|
+
const root = servers
|
|
184
|
+
.filter((s) => s.meta?.kind === "root")
|
|
185
|
+
.sort((a, b) => (b.meta?.cwd.length ?? 0) - (a.meta?.cwd.length ?? 0))[0];
|
|
155
186
|
if (root && root.meta) {
|
|
156
187
|
const folder = `${trimSlash(root.meta.cwd)}/${fillLayout(root.meta.layout || DEFAULT_LAYOUT, target)}`;
|
|
157
188
|
return { peerId: root.peerId, folder };
|
package/src/web/discovery.tsx
CHANGED
|
@@ -8,8 +8,10 @@ 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,
|
|
14
|
+
gitUrlToPath,
|
|
13
15
|
parseDeepLink,
|
|
14
16
|
pickRoomMatch,
|
|
15
17
|
repoKey,
|
|
@@ -161,6 +163,12 @@ export function Discovery() {
|
|
|
161
163
|
const [editingToken, setEditingToken] = useState(false);
|
|
162
164
|
const [tokenError, setTokenError] = useState<string | null>(null);
|
|
163
165
|
|
|
166
|
+
// "Open a GitHub repo" box: paste a github.com URL -> navigate to its /gh/
|
|
167
|
+
// deep link, which resolves/opens (and, once provisioning lands, materializes)
|
|
168
|
+
// the workspace.
|
|
169
|
+
const [ghUrl, setGhUrl] = useState("");
|
|
170
|
+
const [ghError, setGhError] = useState<string | null>(null);
|
|
171
|
+
|
|
164
172
|
// Fake-tag filter over the merged workspace list: a free-text box plus a set
|
|
165
173
|
// of pinned tag tokens (chips). Both feed the same `ay ls`-style AND matcher.
|
|
166
174
|
const [filter, setFilter] = useState("");
|
|
@@ -311,6 +319,22 @@ export function Discovery() {
|
|
|
311
319
|
setEditingToken(false);
|
|
312
320
|
}
|
|
313
321
|
|
|
322
|
+
function openGithubUrl(e: React.FormEvent) {
|
|
323
|
+
e.preventDefault();
|
|
324
|
+
const path = gitUrlToPath(ghUrl);
|
|
325
|
+
if (!path) {
|
|
326
|
+
setGhError("not a recognizable git repo URL");
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
setGhError(null);
|
|
330
|
+
setGhUrl("");
|
|
331
|
+
// Navigate to the deep link and reconcile: connect if a daemon already
|
|
332
|
+
// serves it (provisioning, later, will materialize it when it doesn't).
|
|
333
|
+
history.pushState(null, "", path);
|
|
334
|
+
setResolving(deepLinkLabel(parseDeepLink(path)));
|
|
335
|
+
syncToUrl();
|
|
336
|
+
}
|
|
337
|
+
|
|
314
338
|
function leaveRoom(t: string) {
|
|
315
339
|
if (activeRoomRef.current === t) disconnect();
|
|
316
340
|
setTokens((prev) => prev.filter((x) => x !== t));
|
|
@@ -356,10 +380,19 @@ export function Discovery() {
|
|
|
356
380
|
sharePathRef.current = window.location.pathname;
|
|
357
381
|
} else {
|
|
358
382
|
const targetPath = shareablePathFor(server, openFolder);
|
|
359
|
-
didPush = !!targetPath && targetPath !== window.location.pathname;
|
|
360
|
-
if (didPush) history.pushState(null, "", targetPath);
|
|
361
|
-
pushedRef.current = didPush;
|
|
362
383
|
sharePathRef.current = targetPath ?? window.location.pathname;
|
|
384
|
+
if (targetPath && targetPath !== window.location.pathname) {
|
|
385
|
+
if (deepLinkRef.current) {
|
|
386
|
+
// Arrived via a deep link — canonicalize the URL in place (e.g. add
|
|
387
|
+
// /tree/<branch>). Same destination, so replace, don't push a
|
|
388
|
+
// back-to-the-list entry.
|
|
389
|
+
history.replaceState(null, "", targetPath);
|
|
390
|
+
} else {
|
|
391
|
+
history.pushState(null, "", targetPath);
|
|
392
|
+
didPush = true;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
pushedRef.current = didPush;
|
|
363
396
|
}
|
|
364
397
|
|
|
365
398
|
// The broker decides whether this tab owns the connection. `establish` is
|
|
@@ -417,14 +450,20 @@ export function Discovery() {
|
|
|
417
450
|
// token — Share adds that). Keeps an existing deep-link path as-is; otherwise
|
|
418
451
|
// derives /gh|/git|/dev from the server's repo identity or opened folder.
|
|
419
452
|
function shareablePathFor(server: PeerInfo, folder?: string): string | null {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
453
|
+
const dl = deepLinkRef.current;
|
|
454
|
+
// A repo workspace always shows /tree/<branch> (GitHub-style, and it pins the
|
|
455
|
+
// worktree in snomiao's /tree/<branch> layout). Branch source, in order: the
|
|
456
|
+
// deep link's branch, the server's reported branch, else the layout default —
|
|
457
|
+
// matching the worktree fillLayout actually opened.
|
|
458
|
+
if (dl?.type === "repo") {
|
|
459
|
+
const branch = dl.target.branch ?? server.meta?.branch ?? DEFAULT_BRANCH;
|
|
460
|
+
return shareableDeepLink({ repo: repoKey(dl.target), branch });
|
|
461
|
+
}
|
|
462
|
+
if (server.meta?.repo) {
|
|
463
|
+
return shareableDeepLink({ repo: server.meta.repo, branch: server.meta.branch ?? DEFAULT_BRANCH });
|
|
464
|
+
}
|
|
465
|
+
// Folder mount: keep the deep-link path as-is, else derive the host-scoped one.
|
|
466
|
+
return dl ? window.location.pathname : shareableDeepLink({ folder, host: server.meta?.host });
|
|
428
467
|
}
|
|
429
468
|
|
|
430
469
|
async function shareLink() {
|
|
@@ -719,6 +758,26 @@ export function Discovery() {
|
|
|
719
758
|
</>
|
|
720
759
|
)}
|
|
721
760
|
|
|
761
|
+
{tokens.length > 0 && (
|
|
762
|
+
<>
|
|
763
|
+
<form onSubmit={openGithubUrl} style={styles.ghForm}>
|
|
764
|
+
<input
|
|
765
|
+
value={ghUrl}
|
|
766
|
+
onChange={(e) => {
|
|
767
|
+
setGhUrl(e.target.value);
|
|
768
|
+
if (ghError) setGhError(null);
|
|
769
|
+
}}
|
|
770
|
+
placeholder="open a repo… paste a github.com URL"
|
|
771
|
+
style={styles.input}
|
|
772
|
+
/>
|
|
773
|
+
<button type="submit" style={styles.button}>
|
|
774
|
+
Open
|
|
775
|
+
</button>
|
|
776
|
+
</form>
|
|
777
|
+
{ghError && <p style={styles.tokenError}>{ghError}</p>}
|
|
778
|
+
</>
|
|
779
|
+
)}
|
|
780
|
+
|
|
722
781
|
<div style={styles.listHead}>
|
|
723
782
|
<h2 style={styles.h2}>Workspaces</h2>
|
|
724
783
|
{serverCount > 0 && (
|
|
@@ -820,6 +879,7 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
820
879
|
roomChipX: { background: "transparent", border: "none", color: "inherit", cursor: "pointer", fontSize: 11, padding: 0, lineHeight: 1 },
|
|
821
880
|
input: { flex: 1, background: "#252525", border: "1px solid #3d3d3d", color: "#eee", padding: "8px 10px", borderRadius: 6, fontSize: 13, outline: "none" },
|
|
822
881
|
button: { background: "#0e639c", border: "none", color: "#fff", padding: "8px 16px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
|
|
882
|
+
ghForm: { display: "flex", alignItems: "center", gap: 8, margin: "0 0 14px" },
|
|
823
883
|
listHead: { display: "flex", alignItems: "baseline", gap: 10, margin: "0 0 12px" },
|
|
824
884
|
h2: { fontSize: 14, color: "#aaa", fontWeight: 600, margin: 0 },
|
|
825
885
|
count: { fontSize: 12, color: "#888", fontFamily: "monospace" },
|