codehost 0.7.0 → 0.8.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 +15 -0
- package/package.json +1 -1
- package/src/cli/commands/list.ts +5 -1
- package/src/cli/oxmgr.ts +36 -3
- package/src/cli/proc.ts +57 -0
- package/src/cli/vscode.ts +5 -1
- package/src/shared/tags.test.ts +56 -0
- package/src/shared/tags.ts +83 -0
- package/src/web/discovery.tsx +97 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
# [0.8.0](https://github.com/snomiao/codehost/compare/v0.7.1...v0.8.0) (2026-06-08)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **web:** filterable workspace list with fake-tags (ay-ls style) ([83dff4f](https://github.com/snomiao/codehost/commit/83dff4ffb813ac93afddf6bb4118b5ffb5278c3b))
|
|
7
|
+
|
|
8
|
+
## [0.7.1](https://github.com/snomiao/codehost/compare/v0.7.0...v0.7.1) (2026-06-08)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* ch list shows only codehost daemons, not all oxmgr processes ([e9cf4fc](https://github.com/snomiao/codehost/commit/e9cf4fca40b653c430c26eb29ba47ef9a154f089))
|
|
14
|
+
* kill the VS Code serve-web process tree on stop (no orphans) ([d6337f6](https://github.com/snomiao/codehost/commit/d6337f6d45bf41fb57f2d0cd28c60a70f55f62d0))
|
|
15
|
+
|
|
1
16
|
# [0.7.0](https://github.com/snomiao/codehost/compare/v0.6.0...v0.7.0) (2026-06-08)
|
|
2
17
|
|
|
3
18
|
|
package/package.json
CHANGED
package/src/cli/commands/list.ts
CHANGED
|
@@ -18,7 +18,11 @@ export const listCommand: CommandModule = {
|
|
|
18
18
|
// Only hit oxmgr if it's actually runnable — `hasOxmgr` doesn't self-heal,
|
|
19
19
|
// so a broken install won't re-download its binary on every `list`.
|
|
20
20
|
if (await hasOxmgr()) {
|
|
21
|
-
|
|
21
|
+
const shown = await listDaemons();
|
|
22
|
+
// listDaemons returns the count of codehost daemons (>=0) or -1 if oxmgr
|
|
23
|
+
// is unusable; only the latter is an error exit. It prints its own
|
|
24
|
+
// "No codehost daemons running." message when the count is 0.
|
|
25
|
+
process.exit(shown < 0 ? 1 : 0);
|
|
22
26
|
}
|
|
23
27
|
if (!detached.length) console.log("No codehost daemons running.");
|
|
24
28
|
process.exit(0);
|
package/src/cli/oxmgr.ts
CHANGED
|
@@ -117,10 +117,43 @@ function enableStartup(): void {
|
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* `codehost list` -> oxmgr's process table, filtered to codehost-owned daemons.
|
|
122
|
+
*
|
|
123
|
+
* oxmgr is a shared process manager (other tools register their own services
|
|
124
|
+
* with it), so a raw `oxmgr list` leaks unrelated processes. oxmgr has no JSON
|
|
125
|
+
* or name-filter flag, so we capture its ASCII table and drop data rows whose
|
|
126
|
+
* NAME column isn't one of ours (the `codehost-` prefix from `daemonName`).
|
|
127
|
+
* Returns the number of codehost daemons shown (-1 if oxmgr is unusable).
|
|
128
|
+
*/
|
|
121
129
|
export async function listDaemons(): Promise<number> {
|
|
122
|
-
if (!(await ensureOxmgr())) return 1;
|
|
123
|
-
|
|
130
|
+
if (!(await ensureOxmgr())) return -1;
|
|
131
|
+
const entry = oxmgrEntry();
|
|
132
|
+
if (!entry) return -1;
|
|
133
|
+
const r = spawnSync(process.execPath, [entry, "list"], { encoding: "utf8" });
|
|
134
|
+
if (r.status !== 0) {
|
|
135
|
+
if (r.stderr) process.stderr.write(r.stderr);
|
|
136
|
+
return -1;
|
|
137
|
+
}
|
|
138
|
+
const lines = (r.stdout ?? "").split("\n");
|
|
139
|
+
let shown = 0;
|
|
140
|
+
const kept = lines.filter((line) => {
|
|
141
|
+
// Border lines (+----+) and blank lines pass through unchanged.
|
|
142
|
+
if (!line.startsWith("|")) return true;
|
|
143
|
+
const name = line.split("|")[2]?.trim() ?? "";
|
|
144
|
+
if (name === "NAME") return true; // header row
|
|
145
|
+
if (name.startsWith("codehost-")) {
|
|
146
|
+
shown++;
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
});
|
|
151
|
+
if (shown === 0) {
|
|
152
|
+
console.log("No codehost daemons running.");
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
process.stdout.write(kept.join("\n"));
|
|
156
|
+
return shown;
|
|
124
157
|
}
|
|
125
158
|
|
|
126
159
|
/** `codehost stop <name>` -> stop + delete the oxmgr process. */
|
package/src/cli/proc.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Kill a process and all of its descendants. VS Code's `serve-web` launcher
|
|
5
|
+
* double-forks (code -> code-tunnel -> code-server -> node server-main.js), so
|
|
6
|
+
* killing only the spawned launcher leaves the real server running as orphans.
|
|
7
|
+
* Killing the whole tree avoids that leak. Best-effort and synchronous so it can
|
|
8
|
+
* run inside a shutdown handler right before the process exits.
|
|
9
|
+
*/
|
|
10
|
+
export function killProcessTree(pid: number, signal: NodeJS.Signals = "SIGTERM"): void {
|
|
11
|
+
if (!pid || pid <= 1) return;
|
|
12
|
+
if (process.platform === "win32") {
|
|
13
|
+
// /T kills the process and its child tree; /F forces it.
|
|
14
|
+
spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
// POSIX: signal descendants (leaves first) then the root itself.
|
|
18
|
+
for (const p of [...descendants(pid), pid]) {
|
|
19
|
+
try {
|
|
20
|
+
process.kill(p, signal);
|
|
21
|
+
} catch {
|
|
22
|
+
// already gone
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** PIDs descended from `root`, deepest first (so they can be signalled before
|
|
28
|
+
* their parents). Uses `ps`; returns [] if it's unavailable. */
|
|
29
|
+
function descendants(root: number): number[] {
|
|
30
|
+
let out = "";
|
|
31
|
+
try {
|
|
32
|
+
out = spawnSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8" }).stdout ?? "";
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
const childrenOf = new Map<number, number[]>();
|
|
37
|
+
for (const line of out.split("\n")) {
|
|
38
|
+
const cols = line.trim().split(/\s+/);
|
|
39
|
+
if (cols.length < 2) continue;
|
|
40
|
+
const pid = Number(cols[0]);
|
|
41
|
+
const ppid = Number(cols[1]);
|
|
42
|
+
if (!Number.isFinite(pid) || !Number.isFinite(ppid)) continue;
|
|
43
|
+
const arr = childrenOf.get(ppid);
|
|
44
|
+
if (arr) arr.push(pid);
|
|
45
|
+
else childrenOf.set(ppid, [pid]);
|
|
46
|
+
}
|
|
47
|
+
const result: number[] = [];
|
|
48
|
+
const stack = [root];
|
|
49
|
+
while (stack.length) {
|
|
50
|
+
const p = stack.pop()!;
|
|
51
|
+
for (const c of childrenOf.get(p) ?? []) {
|
|
52
|
+
result.push(c);
|
|
53
|
+
stack.push(c);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return result.reverse();
|
|
57
|
+
}
|
package/src/cli/vscode.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn, type Subprocess } from "bun";
|
|
2
2
|
import { resolveCodeBinary } from "./vscode-install";
|
|
3
|
+
import { killProcessTree } from "./proc";
|
|
3
4
|
|
|
4
5
|
// How long to wait for `code serve-web` to answer. The default is generous
|
|
5
6
|
// because the FIRST run downloads the server component, which can take minutes
|
|
@@ -61,7 +62,10 @@ export async function launchVscode(opts: LaunchOptions): Promise<VscodeServer> {
|
|
|
61
62
|
|
|
62
63
|
const stop = () => {
|
|
63
64
|
try {
|
|
64
|
-
|
|
65
|
+
// serve-web double-forks; kill the whole tree so the real VS Code server
|
|
66
|
+
// doesn't linger as an orphan after the daemon stops.
|
|
67
|
+
if (proc.pid) killProcessTree(proc.pid);
|
|
68
|
+
else proc.kill();
|
|
65
69
|
} catch {
|
|
66
70
|
// ignore
|
|
67
71
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { deriveTags, matchQuery, matchToken, shortRoomLabel } from "./tags";
|
|
3
|
+
|
|
4
|
+
test("deriveTags builds mnemonic tags from meta", () => {
|
|
5
|
+
const tags = deriveTags(
|
|
6
|
+
{ name: "x", host: "mbp", cwd: "/ws/a", kind: "repo", repo: "github.com/o/r", branch: "main" },
|
|
7
|
+
{ roomLabel: "ab12" },
|
|
8
|
+
);
|
|
9
|
+
expect(tags).toContain("room:ab12");
|
|
10
|
+
expect(tags).toContain("host:mbp");
|
|
11
|
+
expect(tags).toContain("kind:repo");
|
|
12
|
+
expect(tags).toContain("repo:github.com/o/r");
|
|
13
|
+
expect(tags).toContain("wt:main");
|
|
14
|
+
expect(tags).toContain("cwd:/ws/a");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("deriveTags defaults kind to repo and omits absent fields", () => {
|
|
18
|
+
const tags = deriveTags({ name: "x", host: "h", cwd: "" });
|
|
19
|
+
expect(tags).toContain("kind:repo");
|
|
20
|
+
expect(tags).not.toContain("cwd:");
|
|
21
|
+
expect(tags.some((t) => t.startsWith("repo:"))).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("key:value matches by tag key + substring value", () => {
|
|
25
|
+
const e = { name: "server", tags: ["repo:github.com/snomiao/codehost", "host:mbp"] };
|
|
26
|
+
expect(matchToken(e, "repo:codehost")).toBe(true);
|
|
27
|
+
expect(matchToken(e, "repo:other")).toBe(false);
|
|
28
|
+
expect(matchToken(e, "host:mbp")).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("bare text is a substring across name and tags", () => {
|
|
32
|
+
const e = { name: "my-laptop", tags: ["repo:github.com/snomiao/codehost"] };
|
|
33
|
+
expect(matchToken(e, "snomiao")).toBe(true);
|
|
34
|
+
expect(matchToken(e, "laptop")).toBe(true);
|
|
35
|
+
expect(matchToken(e, "nope")).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("bare numeric is an exact pid match", () => {
|
|
39
|
+
const e = { name: "x", pid: "1234", tags: [] };
|
|
40
|
+
expect(matchToken(e, "1234")).toBe(true);
|
|
41
|
+
expect(matchToken(e, "123")).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("matchQuery ANDs all tokens", () => {
|
|
45
|
+
const e = { name: "x", tags: ["host:mbp", "repo:github.com/o/codehost", "kind:repo"] };
|
|
46
|
+
expect(matchQuery(e, "codehost host:mbp")).toBe(true);
|
|
47
|
+
expect(matchQuery(e, "codehost host:other")).toBe(false);
|
|
48
|
+
expect(matchQuery(e, "")).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("shortRoomLabel is stable and exactly 4 chars", () => {
|
|
52
|
+
const t = "super-secret-token-value-1234567890";
|
|
53
|
+
expect(shortRoomLabel(t)).toBe(shortRoomLabel(t));
|
|
54
|
+
expect(shortRoomLabel(t)).toHaveLength(4);
|
|
55
|
+
expect(shortRoomLabel("another-token")).not.toBe(shortRoomLabel(t));
|
|
56
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// "Fake-tags": searchable mnemonics derived from a server's advertised meta
|
|
2
|
+
// (host, cwd, repo, branch, room). They exist only so a workspace list is easy
|
|
3
|
+
// to scan, filter, and remember — they are user-mutable and may collide across
|
|
4
|
+
// machines, so the system never addresses anything by them. Canonical identity
|
|
5
|
+
// stays in the peerId + room token.
|
|
6
|
+
//
|
|
7
|
+
// The matcher mirrors `ay ls`: identity-ish fields match exactly, human text
|
|
8
|
+
// matches as a substring, and a `key:value` token matches a tag by key.
|
|
9
|
+
|
|
10
|
+
import type { PeerMeta } from "./signaling";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A short, non-secret label for a room token. The token is a bearer secret —
|
|
14
|
+
* it lives only in the URL fragment and is never sent to a server (see
|
|
15
|
+
* `tokenFromHash`) — so it must never be rendered into the DOM. This derives a
|
|
16
|
+
* stable 4-char tag from it (FNV-1a, base36) for display/filtering instead.
|
|
17
|
+
*/
|
|
18
|
+
export function shortRoomLabel(token: string): string {
|
|
19
|
+
let h = 0x811c9dc5;
|
|
20
|
+
for (let i = 0; i < token.length; i++) {
|
|
21
|
+
h ^= token.charCodeAt(i);
|
|
22
|
+
h = Math.imul(h, 0x01000193);
|
|
23
|
+
}
|
|
24
|
+
return (h >>> 0).toString(36).slice(0, 4).padStart(4, "0");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Build the mnemonic tag list for a server from its advertised metadata. */
|
|
28
|
+
export function deriveTags(meta: PeerMeta | null, opts: { roomLabel?: string } = {}): string[] {
|
|
29
|
+
const tags: string[] = [];
|
|
30
|
+
if (opts.roomLabel) tags.push(`room:${opts.roomLabel}`);
|
|
31
|
+
if (meta?.host) tags.push(`host:${meta.host}`);
|
|
32
|
+
tags.push(`kind:${meta?.kind ?? "repo"}`);
|
|
33
|
+
if (meta?.repo) tags.push(`repo:${meta.repo}`);
|
|
34
|
+
if (meta?.branch) tags.push(`wt:${meta.branch}`);
|
|
35
|
+
if (meta?.cwd) tags.push(`cwd:${meta.cwd}`);
|
|
36
|
+
return tags;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The key part of a `key:value` tag (empty string for an unkeyed tag). */
|
|
40
|
+
export function tagKey(tag: string): string {
|
|
41
|
+
const i = tag.indexOf(":");
|
|
42
|
+
return i < 0 ? "" : tag.slice(0, i);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function tagValue(tag: string): string {
|
|
46
|
+
const i = tag.indexOf(":");
|
|
47
|
+
return i < 0 ? tag : tag.slice(i + 1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface Filterable {
|
|
51
|
+
tags: string[];
|
|
52
|
+
name: string;
|
|
53
|
+
/** Optional numeric id (e.g. a pid); matched exactly by a bare-numeric token. */
|
|
54
|
+
pid?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Match one whitespace-delimited query token against an entry, `ay ls` style:
|
|
59
|
+
* - `key:value` → some tag with that key whose value contains `value` (substring)
|
|
60
|
+
* - bare numeric → exact pid
|
|
61
|
+
* - bare text → substring over the name or any tag (value or key)
|
|
62
|
+
*/
|
|
63
|
+
export function matchToken(e: Filterable, token: string): boolean {
|
|
64
|
+
const tok = token.toLowerCase();
|
|
65
|
+
if (!tok) return true;
|
|
66
|
+
const ci = tok.indexOf(":");
|
|
67
|
+
if (ci > 0) {
|
|
68
|
+
const k = tok.slice(0, ci);
|
|
69
|
+
const v = tok.slice(ci + 1);
|
|
70
|
+
return e.tags.some(
|
|
71
|
+
(t) => tagKey(t).toLowerCase() === k && (v === "" || tagValue(t).toLowerCase().includes(v)),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (/^\d+$/.test(tok)) return e.pid === tok; // identity: exact
|
|
75
|
+
if (e.name.toLowerCase().includes(tok)) return true;
|
|
76
|
+
return e.tags.some((t) => t.toLowerCase().includes(tok)); // text: substring
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Every whitespace-separated token must match (AND), like `ay ls [keyword]`. */
|
|
80
|
+
export function matchQuery(e: Filterable, query: string): boolean {
|
|
81
|
+
const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
82
|
+
return tokens.every((t) => matchToken(e, t));
|
|
83
|
+
}
|
package/src/web/discovery.tsx
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
shareableDeepLink,
|
|
19
19
|
} from "../shared/repo";
|
|
20
20
|
import { addRoom, getRooms, historyFor, recordConnection } from "./history";
|
|
21
|
+
import { deriveTags, matchQuery, shortRoomLabel, tagKey } from "../shared/tags";
|
|
21
22
|
|
|
22
23
|
const TOKEN_KEY = "codehost.token";
|
|
23
24
|
|
|
@@ -107,6 +108,11 @@ export function Discovery() {
|
|
|
107
108
|
const [connected, setConnected] = useState(false);
|
|
108
109
|
const [servers, setServers] = useState<PeerInfo[]>([]);
|
|
109
110
|
|
|
111
|
+
// Fake-tag filter over the workspace list: a free-text box plus a set of
|
|
112
|
+
// pinned tag tokens (chips). Both feed the same `ay ls`-style AND matcher.
|
|
113
|
+
const [filter, setFilter] = useState("");
|
|
114
|
+
const [activeTags, setActiveTags] = useState<string[]>([]);
|
|
115
|
+
|
|
110
116
|
// Active WebRTC connection to one server (Phase 2: echo test).
|
|
111
117
|
const [activePeerId, setActivePeerId] = useState<string | null>(null);
|
|
112
118
|
const [connState, setConnState] = useState<ConnState>("idle");
|
|
@@ -355,6 +361,28 @@ export function Discovery() {
|
|
|
355
361
|
|
|
356
362
|
const activeServer = servers.find((s) => s.peerId === activePeerId);
|
|
357
363
|
|
|
364
|
+
// Annotate each server with its mnemonic fake-tags, then filter. The room
|
|
365
|
+
// token is hashed to a short label — never rendered raw (it's a bearer secret).
|
|
366
|
+
const roomLabel = token ? shortRoomLabel(token) : "";
|
|
367
|
+
const tagged = servers.map((s) => ({
|
|
368
|
+
server: s,
|
|
369
|
+
name: s.meta?.name ?? s.peerId.slice(0, 8),
|
|
370
|
+
tags: deriveTags(s.meta, { roomLabel }),
|
|
371
|
+
}));
|
|
372
|
+
const query = [...activeTags, filter].join(" ");
|
|
373
|
+
const filtered = tagged.filter((t) => matchQuery({ name: t.name, tags: t.tags }, query));
|
|
374
|
+
const toggleTag = (t: string) =>
|
|
375
|
+
setActiveTags((a) => (a.includes(t) ? a.filter((x) => x !== t) : [...a, t]));
|
|
376
|
+
const addTag = (t: string) => setActiveTags((a) => (a.includes(t) ? a : [...a, t]));
|
|
377
|
+
// Suggested chips: the most common identity/location tags across the list.
|
|
378
|
+
const tagFreq = new Map<string, number>();
|
|
379
|
+
for (const t of tagged) for (const tag of t.tags) tagFreq.set(tag, (tagFreq.get(tag) ?? 0) + 1);
|
|
380
|
+
const suggestedTags = [...tagFreq.entries()]
|
|
381
|
+
.sort((a, b) => b[1] - a[1])
|
|
382
|
+
.map(([t]) => t)
|
|
383
|
+
.filter((t) => ["host", "repo", "wt", "kind", "room"].includes(tagKey(t)))
|
|
384
|
+
.slice(0, 12);
|
|
385
|
+
|
|
358
386
|
// Connected view: VS Code in an iframe, served over the tunnel.
|
|
359
387
|
if (iframeSrc && connState === "connected") {
|
|
360
388
|
return (
|
|
@@ -421,25 +449,64 @@ export function Discovery() {
|
|
|
421
449
|
<p style={styles.tokenHint}>Token requires {TOKEN_REQUIREMENTS}.</p>
|
|
422
450
|
)}
|
|
423
451
|
|
|
424
|
-
<
|
|
425
|
-
|
|
452
|
+
<div style={styles.listHead}>
|
|
453
|
+
<h2 style={styles.h2}>Workspaces</h2>
|
|
454
|
+
{token && servers.length > 0 && (
|
|
455
|
+
<span style={styles.count}>
|
|
456
|
+
{filtered.length === tagged.length
|
|
457
|
+
? `${tagged.length}`
|
|
458
|
+
: `${filtered.length} / ${tagged.length}`}
|
|
459
|
+
</span>
|
|
460
|
+
)}
|
|
461
|
+
</div>
|
|
462
|
+
{!token && <p style={styles.dim}>Enter a token to see your workspaces.</p>}
|
|
426
463
|
{token && servers.length === 0 && (
|
|
427
464
|
<p style={styles.dim}>
|
|
428
465
|
No servers online. Run{" "}
|
|
429
466
|
<code style={styles.code}>bunx codehost serve -t {token || "<token>"}</code> on a machine.
|
|
430
467
|
</p>
|
|
431
468
|
)}
|
|
469
|
+
{token && servers.length > 0 && (
|
|
470
|
+
<>
|
|
471
|
+
<input
|
|
472
|
+
value={filter}
|
|
473
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
474
|
+
placeholder="filter… e.g. repo:codehost host:mbp (space = AND)"
|
|
475
|
+
style={styles.search}
|
|
476
|
+
/>
|
|
477
|
+
{(activeTags.length > 0 || suggestedTags.length > 0) && (
|
|
478
|
+
<div style={styles.chipRow}>
|
|
479
|
+
{activeTags.map((t) => (
|
|
480
|
+
<button key={t} style={{ ...styles.chip, ...styles.chipActive }} onClick={() => toggleTag(t)}>
|
|
481
|
+
{t} ✕
|
|
482
|
+
</button>
|
|
483
|
+
))}
|
|
484
|
+
{suggestedTags
|
|
485
|
+
.filter((t) => !activeTags.includes(t))
|
|
486
|
+
.map((t) => (
|
|
487
|
+
<button key={t} style={styles.chip} onClick={() => toggleTag(t)}>
|
|
488
|
+
{t}
|
|
489
|
+
</button>
|
|
490
|
+
))}
|
|
491
|
+
</div>
|
|
492
|
+
)}
|
|
493
|
+
</>
|
|
494
|
+
)}
|
|
432
495
|
<ul style={styles.list}>
|
|
433
|
-
{
|
|
496
|
+
{filtered.map(({ server: s, name, tags }) => {
|
|
434
497
|
const isActive = s.peerId === activePeerId;
|
|
435
498
|
return (
|
|
436
499
|
<li key={s.peerId} style={styles.card}>
|
|
437
500
|
<div style={styles.cardMain}>
|
|
438
|
-
<div style={styles.cardName}>{
|
|
439
|
-
<div style={styles.
|
|
440
|
-
{
|
|
441
|
-
|
|
501
|
+
<div style={styles.cardName}>{name}</div>
|
|
502
|
+
<div style={styles.tagRow}>
|
|
503
|
+
{tags.map((tag) => (
|
|
504
|
+
<button key={tag} style={styles.tag} onClick={() => addTag(tag)} title={`filter by ${tag}`}>
|
|
505
|
+
{tag}
|
|
506
|
+
</button>
|
|
507
|
+
))}
|
|
442
508
|
</div>
|
|
509
|
+
<div style={styles.idLine}>peer {s.peerId.slice(0, 8)}</div>
|
|
443
510
|
{isActive && (
|
|
444
511
|
<div style={styles.echo}>
|
|
445
512
|
{connState === "connecting" && "negotiating WebRTC…"}
|
|
@@ -457,6 +524,9 @@ export function Discovery() {
|
|
|
457
524
|
</li>
|
|
458
525
|
);
|
|
459
526
|
})}
|
|
527
|
+
{token && servers.length > 0 && filtered.length === 0 && (
|
|
528
|
+
<p style={styles.dim}>No workspace matches your filter.</p>
|
|
529
|
+
)}
|
|
460
530
|
</ul>
|
|
461
531
|
</main>
|
|
462
532
|
</div>
|
|
@@ -476,7 +546,26 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
476
546
|
label: { fontSize: 12, color: "#888" },
|
|
477
547
|
input: { flex: 1, background: "#252525", border: "1px solid #3d3d3d", color: "#eee", padding: "8px 10px", borderRadius: 6, fontSize: 13, outline: "none" },
|
|
478
548
|
button: { background: "#0e639c", border: "none", color: "#fff", padding: "8px 16px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
|
|
479
|
-
|
|
549
|
+
listHead: { display: "flex", alignItems: "baseline", gap: 10, margin: "0 0 12px" },
|
|
550
|
+
h2: { fontSize: 14, color: "#aaa", fontWeight: 600, margin: 0 },
|
|
551
|
+
count: { fontSize: 12, color: "#888", fontFamily: "monospace" },
|
|
552
|
+
search: {
|
|
553
|
+
width: "100%", boxSizing: "border-box", background: "#252525", border: "1px solid #3d3d3d",
|
|
554
|
+
color: "#eee", padding: "8px 10px", borderRadius: 6, fontSize: 13, outline: "none",
|
|
555
|
+
fontFamily: "monospace", marginBottom: 10,
|
|
556
|
+
},
|
|
557
|
+
chipRow: { display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 14 },
|
|
558
|
+
chip: {
|
|
559
|
+
fontFamily: "monospace", fontSize: 11.5, padding: "2px 8px", borderRadius: 999,
|
|
560
|
+
border: "1px solid #3d3d3d", background: "transparent", color: "#9aa4af", cursor: "pointer",
|
|
561
|
+
},
|
|
562
|
+
chipActive: { background: "#0e639c", borderColor: "#0e639c", color: "#fff" },
|
|
563
|
+
tagRow: { display: "flex", flexWrap: "wrap", gap: 5, marginTop: 6 },
|
|
564
|
+
tag: {
|
|
565
|
+
fontFamily: "monospace", fontSize: 11, padding: "1px 7px", borderRadius: 999,
|
|
566
|
+
border: "1px solid #3d3d3d", background: "transparent", color: "#9aa4af", cursor: "pointer",
|
|
567
|
+
},
|
|
568
|
+
idLine: { fontFamily: "monospace", fontSize: 11, color: "#666", marginTop: 6 },
|
|
480
569
|
code: { background: "#252525", padding: "2px 6px", borderRadius: 4, fontFamily: "monospace", fontSize: 12 },
|
|
481
570
|
list: { listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 },
|
|
482
571
|
card: { display: "flex", alignItems: "center", gap: 12, background: "#252525", border: "1px solid #3d3d3d", borderRadius: 8, padding: "12px 14px" },
|