ompchamber 0.5.0 → 0.6.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/README.md +46 -1
- package/dist/client/index.html +1 -1
- package/dist/client/static/css/{index.5291f58223.css → index.51f68210a5.css} +1 -1
- package/dist/client/static/js/async/{3y.ededeebde8.js → 3y.4c2596f64c.js} +3 -3
- package/dist/client/static/js/async/73.308dff4da8.js +1 -0
- package/dist/client/static/js/async/{ty.3d93cf5f5f.js → 7g.a7814350c3.js} +1 -1
- package/dist/client/static/js/async/a.bce4542392.js +1 -0
- package/dist/client/static/js/async/g.8515cbc6f7.js +2 -0
- package/dist/client/static/js/async/hn.b645041e00.js +1 -0
- package/dist/client/static/js/async/{jg.69a4beade8.js → jg.d86ebd4013.js} +1 -1
- package/dist/client/static/js/async/l.12dd34e1be.js +1 -0
- package/dist/client/static/js/async/ls.9d9a01026c.js +104 -0
- package/dist/client/static/js/async/mw.ca1512563c.js +10 -0
- package/dist/client/static/js/async/nk.fc7e58325e.js +1 -0
- package/dist/client/static/js/async/{q.7fb4d2f374.js → q.542c184fec.js} +3 -3
- package/dist/client/static/js/async/t.cda4891526.js +1 -0
- package/dist/client/static/js/async/tr.e878658140.js +309 -0
- package/dist/client/static/js/async/uf.24ca3d49c3.js +2 -0
- package/dist/client/static/js/{index.0ac93ee878.js → index.47d3b04d5f.js} +7 -7
- package/dist/client/static/js/{uy.5f791144ce.js → uy.2e8929ed0c.js} +2 -2
- package/package.json +13 -5
- package/src/cli/lib/args.js +6 -6
- package/src/cli/lib/commands/logs.js +76 -34
- package/src/cli/lib/commands/restart.js +30 -9
- package/src/cli/lib/commands/serve.js +12 -4
- package/src/cli/lib/commands/status.js +40 -18
- package/src/cli/lib/commands/stop.js +12 -7
- package/src/cli/lib/commands/update.js +2 -5
- package/src/cli/lib/paths.js +8 -28
- package/src/cli/lib/runtime.js +118 -108
- package/src/client/components/common/code-editor/index.tsx +5 -1
- package/src/client/components/common/code-surface/index.tsx +49 -1
- package/src/client/components/common/code-surface/measure.ts +99 -0
- package/src/client/components/layout/RightActivityBar.tsx +42 -69
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +5 -6
- package/src/client/components/layout/desktop-layout/index.tsx +11 -9
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +22 -29
- package/src/client/components/layout/desktop-layout/resizer/utils.test.ts +33 -0
- package/src/client/components/layout/desktop-layout/resizer/utils.ts +34 -0
- package/src/client/components/mobile/RightSidebar.tsx +42 -119
- package/src/client/components/mobile/mobile-main-view/Header.tsx +17 -1
- package/src/client/components/workspace/editor/index.tsx +4 -1
- package/src/client/hooks/editor/use-file-editor.test.ts +158 -0
- package/src/client/hooks/editor/use-file-editor.ts +35 -19
- package/src/server/index.ts +51 -1
- package/src/server/lib/lifecycle/identity.ts +108 -0
- package/src/server/lib/lifecycle/instance.ts +102 -0
- package/src/server/lib/lifecycle/occupant.ts +101 -0
- package/src/server/lib/lifecycle/paths.ts +50 -0
- package/src/server/lib/lifecycle/port-guard.ts +170 -0
- package/src/server/lib/lifecycle/probe.ts +121 -0
- package/src/server/plugins/static.test.ts +63 -0
- package/src/server/plugins/static.ts +28 -4
- package/src/server/routes/health/probe.ts +8 -0
- package/src/shared/lib/markdown/mermaid.ts +8 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +5 -5
- package/src/shared/lib/workspace/panel-widths.ts +23 -10
- package/src/shared/lib/workspace/right-panels.ts +10 -4
- package/dist/client/static/js/async/73.50280ce58d.js +0 -1
- package/dist/client/static/js/async/a.e05da304c8.js +0 -10
- package/dist/client/static/js/async/ar.1e14da5588.js +0 -1
- package/dist/client/static/js/async/g.7a4bbd4798.js +0 -2
- package/dist/client/static/js/async/hn.7568bdb58b.js +0 -1
- package/dist/client/static/js/async/l.102a97fce6.js +0 -104
- package/dist/client/static/js/async/l6.c56640c205.js +0 -1
- package/dist/client/static/js/async/nk.6de011832e.js +0 -1
- package/dist/client/static/js/async/t.cb3fcf8615.js +0 -1
- package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
- package/dist/client/static/js/async/vn.e98e2f3b61.js +0 -309
- /package/dist/client/static/js/async/{3y.ededeebde8.js.LICENSE.txt → 3y.4c2596f64c.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.7a4bbd4798.js.LICENSE.txt → g.8515cbc6f7.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{a.e05da304c8.js.LICENSE.txt → mw.ca1512563c.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.7fb4d2f374.js.LICENSE.txt → q.542c184fec.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → uf.24ca3d49c3.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.0ac93ee878.js.LICENSE.txt → index.47d3b04d5f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{uy.5f791144ce.js.LICENSE.txt → uy.2e8929ed0c.js.LICENSE.txt} +0 -0
package/src/cli/lib/runtime.js
CHANGED
|
@@ -1,111 +1,95 @@
|
|
|
1
|
-
// Runtime helpers for the OMPChamber CLI:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Runtime helpers for the OMPChamber CLI: instance discovery, process identity,
|
|
2
|
+
// server entry resolution, health probing and detached spawning.
|
|
3
|
+
//
|
|
4
|
+
// The instance record is written by the server that owns the port (see
|
|
5
|
+
// src/server/lib/lifecycle/instance.ts), not by the CLI that spawned it, so a
|
|
6
|
+
// server started by `bun run dev` or by `--foreground` is as discoverable as a
|
|
7
|
+
// detached one. Discovery combines that record with a live `/api/health` probe:
|
|
8
|
+
// the probe covers servers started outside the CLI, the record covers servers
|
|
9
|
+
// that are alive but deaf (starting up, hung).
|
|
5
10
|
|
|
6
11
|
import fs from 'node:fs';
|
|
7
12
|
|
|
8
|
-
import {
|
|
13
|
+
import { ensureDataDirs, getLogFilePath } from '@/cli/lib/paths.js';
|
|
9
14
|
import { killChildTree, STOP_TIMEOUT_MS } from '@/cli/lib/process-lifecycle.js';
|
|
15
|
+
import { getProcessState, isProcessAlive } from '@/server/lib/lifecycle/identity';
|
|
16
|
+
import { listInstanceRecords, readInstanceRecord, removeInstanceRecord } from '@/server/lib/lifecycle/instance';
|
|
17
|
+
import { fetchHealth, probeHost } from '@/server/lib/lifecycle/probe';
|
|
10
18
|
import { joinPath, homeDir } from '@/cli/lib/path-utils.js';
|
|
11
19
|
|
|
12
20
|
const HEALTH_INTERVAL_MS = 500;
|
|
13
21
|
const HEALTH_TIMEOUT_MS = 30_000;
|
|
14
22
|
|
|
15
23
|
/**
|
|
16
|
-
*
|
|
24
|
+
* CLI shape of a live instance, so records and probed servers print the same.
|
|
17
25
|
*/
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
try {
|
|
32
|
-
const dir = getRunDir();
|
|
33
|
-
const entries = [];
|
|
34
|
-
for (const file of await fs.promises.readdir(dir)) {
|
|
35
|
-
if (!/^\d+\.json$/.test(file)) continue;
|
|
36
|
-
try {
|
|
37
|
-
const parsed = await Bun.file(joinPath(dir, file)).json();
|
|
38
|
-
if (parsed && typeof parsed === 'object') entries.push(parsed);
|
|
39
|
-
} catch {
|
|
40
|
-
// Skip unreadable or corrupt registry files.
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
return entries;
|
|
44
|
-
} catch {
|
|
45
|
-
return [];
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Atomically write a registry entry (tmp file + rename) with owner-only perms.
|
|
51
|
-
*/
|
|
52
|
-
export function writeRegistry(entry) {
|
|
53
|
-
if (!entry || entry.port === undefined || entry.port === null) {
|
|
54
|
-
throw new Error('writeRegistry requires an entry with a port');
|
|
55
|
-
}
|
|
56
|
-
ensureDataDirs();
|
|
57
|
-
const target = getRegistryPath(entry.port);
|
|
58
|
-
const tmp = `${target}.${process.pid}.tmp`;
|
|
59
|
-
fs.writeFileSync(tmp, JSON.stringify(entry, null, 2), { mode: 0o600 });
|
|
60
|
-
fs.renameSync(tmp, target);
|
|
61
|
-
return target;
|
|
26
|
+
function toLiveInstance({ pid, port, host, mode, launchMode, startedAt, source }) {
|
|
27
|
+
const resolvedHost = host || 'localhost';
|
|
28
|
+
return {
|
|
29
|
+
pid,
|
|
30
|
+
port,
|
|
31
|
+
host: resolvedHost,
|
|
32
|
+
mode: mode ?? 'unknown',
|
|
33
|
+
launchMode: launchMode ?? 'unknown',
|
|
34
|
+
startedAt,
|
|
35
|
+
source: source ?? 'registry',
|
|
36
|
+
logFile: getLogFilePath(port),
|
|
37
|
+
url: `http://${probeHost(resolvedHost)}:${port}`,
|
|
38
|
+
};
|
|
62
39
|
}
|
|
63
40
|
|
|
64
41
|
/**
|
|
65
|
-
*
|
|
42
|
+
* A recorded PID counts as the instance only while it is alive *and* still
|
|
43
|
+
* identifiable: a recycled PID must not resurrect a dead instance. `unknown`
|
|
44
|
+
* (identity unreadable on this platform) is accepted so Windows keeps working.
|
|
66
45
|
*/
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
} catch {
|
|
71
|
-
// Nothing to do: the registry is best-effort state.
|
|
72
|
-
}
|
|
46
|
+
function isRecordLive(record) {
|
|
47
|
+
if (!record) return false;
|
|
48
|
+
return isProcessAlive(record.pid) && getProcessState(record.pid) !== 'mismatched';
|
|
73
49
|
}
|
|
74
50
|
|
|
75
51
|
/**
|
|
76
|
-
*
|
|
52
|
+
* Every recorded instance whose process is still alive. Records left behind by
|
|
53
|
+
* a killed server (SIGKILL skips its cleanup handler) are pruned here, so a
|
|
54
|
+
* recycled PID can never be reported as a running instance.
|
|
77
55
|
*/
|
|
78
|
-
export function
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return false;
|
|
56
|
+
export async function listLiveInstances() {
|
|
57
|
+
const live = [];
|
|
58
|
+
for (const record of listInstanceRecords()) {
|
|
59
|
+
if (isRecordLive(record)) live.push(toLiveInstance(record));
|
|
60
|
+
else removeInstanceRecord(record.port);
|
|
84
61
|
}
|
|
62
|
+
// Directory order is filesystem-dependent; a port-ordered report is stable
|
|
63
|
+
// across runs and keeps multi-instance output readable.
|
|
64
|
+
live.sort((a, b) => a.port - b.port);
|
|
65
|
+
return live;
|
|
85
66
|
}
|
|
86
67
|
|
|
87
68
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
69
|
+
* The live instance on `port`, or the first live instance when no port is
|
|
70
|
+
* given. Falls back to a health probe for servers that never wrote a record.
|
|
90
71
|
*/
|
|
91
72
|
export async function findLiveInstance(port) {
|
|
92
73
|
if (port === null || port === undefined) {
|
|
93
|
-
const
|
|
94
|
-
return
|
|
74
|
+
const [live] = await listLiveInstances();
|
|
75
|
+
return live ?? null;
|
|
95
76
|
}
|
|
96
|
-
const entry = await readRegistry(port);
|
|
97
|
-
if (entry && isProcessAlive(Number(entry.pid))) return entry;
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
77
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
78
|
+
const record = readInstanceRecord(port);
|
|
79
|
+
if (isRecordLive(record)) return toLiveInstance(record);
|
|
80
|
+
if (record) removeInstanceRecord(port);
|
|
81
|
+
|
|
82
|
+
const health = await fetchHealth(port, record?.host);
|
|
83
|
+
if (!health || typeof health.pid !== 'number' || !isProcessAlive(health.pid)) return null;
|
|
84
|
+
return toLiveInstance({
|
|
85
|
+
pid: health.pid,
|
|
86
|
+
port,
|
|
87
|
+
host: health.host ?? record?.host,
|
|
88
|
+
mode: health.mode,
|
|
89
|
+
launchMode: health.launchMode ?? record?.launchMode,
|
|
90
|
+
startedAt: health.startedAt,
|
|
91
|
+
source: 'probe',
|
|
92
|
+
});
|
|
109
93
|
}
|
|
110
94
|
|
|
111
95
|
function isBun(candidate) {
|
|
@@ -147,9 +131,12 @@ export function resolveBunBin() {
|
|
|
147
131
|
*
|
|
148
132
|
* Bun executes the TypeScript entry directly in both modes, so dev and prod
|
|
149
133
|
* differ only by NODE_ENV — the client bundle in `dist/client` is required by
|
|
150
|
-
* the SSR shell either way.
|
|
134
|
+
* the SSR shell either way. `--ompchamber-server` is an identity marker: it
|
|
135
|
+
* makes this process recognizable in `ps` output, which is how a starting
|
|
136
|
+
* server decides whether a busy port belongs to OMPChamber before signalling
|
|
137
|
+
* anything.
|
|
151
138
|
*/
|
|
152
|
-
export function buildServeInvocation({ pkgRoot, mode, port, host }) {
|
|
139
|
+
export function buildServeInvocation({ pkgRoot, mode, port, host, launchMode = 'daemon' }) {
|
|
153
140
|
const entry = joinPath(pkgRoot, 'src', 'server', 'index.ts');
|
|
154
141
|
if (!fs.existsSync(entry)) {
|
|
155
142
|
throw new Error(`Could not locate the server entry at ${entry}.`);
|
|
@@ -159,25 +146,30 @@ export function buildServeInvocation({ pkgRoot, mode, port, host }) {
|
|
|
159
146
|
}
|
|
160
147
|
return {
|
|
161
148
|
file: resolveBunBin(),
|
|
162
|
-
args: [entry],
|
|
149
|
+
args: [entry, '--ompchamber-server'],
|
|
163
150
|
env: {
|
|
164
151
|
...Bun.env,
|
|
165
152
|
NODE_ENV: mode === 'prod' ? 'production' : 'development',
|
|
166
153
|
PORT: String(port),
|
|
167
154
|
HOST: host,
|
|
155
|
+
OMPCHAMBER_LAUNCH_MODE: launchMode,
|
|
168
156
|
},
|
|
169
157
|
};
|
|
170
158
|
}
|
|
171
159
|
|
|
172
160
|
/**
|
|
173
161
|
* Spawn a detached server, stream its output to the per-port log file and
|
|
174
|
-
*
|
|
162
|
+
* return the CLI-facing entry description.
|
|
163
|
+
*
|
|
164
|
+
* The instance record is *not* written here: the server writes it once it
|
|
165
|
+
* actually owns the port, so a spawn that fails (port lost to something else)
|
|
166
|
+
* cannot leave a record pointing at a process that never served.
|
|
175
167
|
*/
|
|
176
|
-
export function spawnDetachedServer({ pkgRoot, mode, port, host }) {
|
|
168
|
+
export function spawnDetachedServer({ pkgRoot, mode, port, host, launchMode = 'daemon' }) {
|
|
177
169
|
ensureDataDirs();
|
|
178
170
|
const logFile = getLogFilePath(port);
|
|
179
171
|
const fd = fs.openSync(logFile, 'a');
|
|
180
|
-
const { file, args, env } = buildServeInvocation({ pkgRoot, mode, port, host });
|
|
172
|
+
const { file, args, env } = buildServeInvocation({ pkgRoot, mode, port, host, launchMode });
|
|
181
173
|
|
|
182
174
|
let child;
|
|
183
175
|
try {
|
|
@@ -194,17 +186,19 @@ export function spawnDetachedServer({ pkgRoot, mode, port, host }) {
|
|
|
194
186
|
fs.closeSync(fd);
|
|
195
187
|
}
|
|
196
188
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
189
|
+
return {
|
|
190
|
+
child,
|
|
191
|
+
entry: {
|
|
192
|
+
pid: child.pid,
|
|
193
|
+
port,
|
|
194
|
+
host,
|
|
195
|
+
mode,
|
|
196
|
+
launchMode,
|
|
197
|
+
startedAt: new Date().toISOString(),
|
|
198
|
+
logFile,
|
|
199
|
+
url: `http://${probeHost(host)}:${port}`,
|
|
200
|
+
},
|
|
205
201
|
};
|
|
206
|
-
writeRegistry(entry);
|
|
207
|
-
return { child, entry };
|
|
208
202
|
}
|
|
209
203
|
|
|
210
204
|
function sleep(ms) {
|
|
@@ -239,7 +233,7 @@ export async function waitForHealth(port, host, timeoutMs = HEALTH_TIMEOUT_MS) {
|
|
|
239
233
|
|
|
240
234
|
function waitForProcessExit(pid, timeoutMs) {
|
|
241
235
|
if (!isProcessAlive(pid)) return Promise.resolve(true);
|
|
242
|
-
const deadline = Date.now() +
|
|
236
|
+
const deadline = Date.now() + timeoutMs;
|
|
243
237
|
return new Promise((resolve) => {
|
|
244
238
|
const tick = () => {
|
|
245
239
|
if (!isProcessAlive(pid)) {
|
|
@@ -257,24 +251,40 @@ function waitForProcessExit(pid, timeoutMs) {
|
|
|
257
251
|
}
|
|
258
252
|
|
|
259
253
|
/**
|
|
260
|
-
* SIGTERM the
|
|
261
|
-
*
|
|
254
|
+
* SIGTERM the process, escalate to SIGKILL after the timeout, then drop the
|
|
255
|
+
* instance record. Resolves true once the PID is gone.
|
|
256
|
+
*
|
|
257
|
+
* Identity is re-checked before signalling: an entry discovered by probe was
|
|
258
|
+
* confirmed by the server itself, but a record-only entry whose PID has since
|
|
259
|
+
* been recycled belongs to a stranger and is never signalled — the stale record
|
|
260
|
+
* is simply removed.
|
|
262
261
|
*/
|
|
263
262
|
export async function stopInstance(entry, { timeoutMs = STOP_TIMEOUT_MS } = {}) {
|
|
264
263
|
const port = entry?.port;
|
|
265
264
|
const pid = Number(entry?.pid);
|
|
266
|
-
|
|
265
|
+
const dropRecord = async () => {
|
|
266
|
+
if (port !== undefined && port !== null) removeInstanceRecord(port);
|
|
267
|
+
};
|
|
267
268
|
|
|
268
|
-
if (Number.isInteger(pid)
|
|
269
|
-
|
|
270
|
-
|
|
269
|
+
if (!Number.isInteger(pid) || pid <= 0 || !isProcessAlive(pid)) {
|
|
270
|
+
await dropRecord();
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const confirmedByProbe = entry?.source === 'probe' || entry?.source === 'registry+probe';
|
|
275
|
+
if (!confirmedByProbe && getProcessState(pid) !== 'matched') {
|
|
276
|
+
await dropRecord();
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const target = { pid, kill: (signal) => process.kill(pid, signal) };
|
|
281
|
+
killChildTree(target, false);
|
|
282
|
+
let gone = await waitForProcessExit(pid, timeoutMs);
|
|
283
|
+
if (!gone) {
|
|
284
|
+
killChildTree(target, true);
|
|
271
285
|
gone = await waitForProcessExit(pid, timeoutMs);
|
|
272
|
-
if (!gone) {
|
|
273
|
-
killChildTree(target, true);
|
|
274
|
-
gone = await waitForProcessExit(pid, timeoutMs);
|
|
275
|
-
}
|
|
276
286
|
}
|
|
277
287
|
|
|
278
|
-
|
|
288
|
+
await dropRecord();
|
|
279
289
|
return gone;
|
|
280
290
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
2
|
-
import type { CSSProperties, TargetedKeyboardEvent } from 'preact';
|
|
2
|
+
import type { CSSProperties, Ref, TargetedKeyboardEvent } from 'preact';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Preact hand-roll of the textarea-over-<pre> highlighting editor
|
|
@@ -38,6 +38,8 @@ export interface CodeEditorProps {
|
|
|
38
38
|
textareaClassName?: string;
|
|
39
39
|
/** Layout styles for the whole editor; typography must match both layers. */
|
|
40
40
|
style?: CSSProperties;
|
|
41
|
+
/** The `<pre>` layer that actually wraps the code — gutter alignment measures it. */
|
|
42
|
+
preRef?: Ref<HTMLPreElement>;
|
|
41
43
|
className?: string;
|
|
42
44
|
placeholder?: string;
|
|
43
45
|
readOnly?: boolean;
|
|
@@ -73,6 +75,7 @@ export function CodeEditor({
|
|
|
73
75
|
preClassName,
|
|
74
76
|
textareaClassName,
|
|
75
77
|
style,
|
|
78
|
+
preRef,
|
|
76
79
|
className,
|
|
77
80
|
placeholder,
|
|
78
81
|
readOnly,
|
|
@@ -314,6 +317,7 @@ export function CodeEditor({
|
|
|
314
317
|
return (
|
|
315
318
|
<div className={className} style={{ position: 'relative', textAlign: 'left', boxSizing: 'border-box', padding: 0, overflow: 'hidden', ...style }}>
|
|
316
319
|
<pre
|
|
320
|
+
ref={preRef}
|
|
317
321
|
className={preClassName}
|
|
318
322
|
aria-hidden="true"
|
|
319
323
|
style={{ ...layerStyle, ...contentStyle, position: 'relative', pointerEvents: 'none' }}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
|
1
2
|
import type { CSSProperties } from 'preact';
|
|
2
3
|
import { CodeEditor } from '@/client/components/common/code-editor';
|
|
4
|
+
import { measureWrappedLines, type WrappedLines } from '@/client/components/common/code-surface/measure';
|
|
3
5
|
import { highlightCode } from '@/shared/lib/code/syntax-highlight';
|
|
4
6
|
import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
|
|
5
7
|
|
|
@@ -25,12 +27,26 @@ interface CodeSurfaceProps {
|
|
|
25
27
|
editorStyle?: CSSProperties;
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
/** Same measurement, so a re-render does not resize every gutter row. */
|
|
31
|
+
function sameWrappedLines(previous: WrappedLines | null, next: WrappedLines | null): boolean {
|
|
32
|
+
if (previous === next) return true;
|
|
33
|
+
if (!previous || !next || previous.rowHeight !== next.rowHeight) return false;
|
|
34
|
+
return (
|
|
35
|
+
previous.heights.length === next.heights.length &&
|
|
36
|
+
previous.heights.every((height, index) => height === next.heights[index])
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
/**
|
|
29
41
|
* The line-number gutter + syntax-highlighted editing surface shared by the
|
|
30
42
|
* desktop editor and the phone's full-screen editor. Callers own only the
|
|
31
43
|
* layout classes (padding, font, zoom) that genuinely differ per layout; the
|
|
32
44
|
* gutter, word-wrap behaviour and Shiki highlighting live here so the two
|
|
33
45
|
* surfaces cannot drift.
|
|
46
|
+
*
|
|
47
|
+
* The gutter spaces its numbers from the editor's measured wrap layout: with
|
|
48
|
+
* word wrap on, a long line covers several rows and every number below it has
|
|
49
|
+
* to move down by the same amount.
|
|
34
50
|
*/
|
|
35
51
|
export function CodeSurface({
|
|
36
52
|
value,
|
|
@@ -47,13 +63,44 @@ export function CodeSurface({
|
|
|
47
63
|
editorStyle,
|
|
48
64
|
}: CodeSurfaceProps) {
|
|
49
65
|
const wrapClass = wordWrap ? WRAP_ON : WRAP_OFF;
|
|
66
|
+
const preRef = useRef<HTMLPreElement | null>(null);
|
|
67
|
+
const [wrapped, setWrapped] = useState<WrappedLines | null>(null);
|
|
50
68
|
useSyntaxReady();
|
|
51
69
|
|
|
70
|
+
// Re-measured on the inputs that move a wrap point: the text, the wrap
|
|
71
|
+
// preference, and the editor's font size / padding.
|
|
72
|
+
const measure = useCallback(() => {
|
|
73
|
+
const pre = preRef.current;
|
|
74
|
+
const next = wordWrap && pre ? measureWrappedLines(pre, value) : null;
|
|
75
|
+
setWrapped((previous) => (sameWrappedLines(previous, next) ? previous : next));
|
|
76
|
+
}, [value, wordWrap, editorPadding, editorStyle?.fontSize]);
|
|
77
|
+
|
|
78
|
+
useLayoutEffect(() => {
|
|
79
|
+
measure();
|
|
80
|
+
}, [measure]);
|
|
81
|
+
|
|
82
|
+
// The wrap width also changes without a re-render — dragging the panel
|
|
83
|
+
// splitter, resizing the window, a zoom that reflows — so the editor's own
|
|
84
|
+
// box drives a re-measure too.
|
|
85
|
+
const measureRef = useRef(measure);
|
|
86
|
+
measureRef.current = measure;
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const pre = preRef.current;
|
|
89
|
+
if (!pre || typeof ResizeObserver === 'undefined') return;
|
|
90
|
+
const observer = new ResizeObserver(() => measureRef.current());
|
|
91
|
+
observer.observe(pre);
|
|
92
|
+
return () => observer.disconnect();
|
|
93
|
+
}, []);
|
|
94
|
+
|
|
52
95
|
return (
|
|
53
96
|
<div className={rootClassName}>
|
|
54
97
|
<div className={gutterClassName} style={gutterStyle}>
|
|
55
98
|
{value.split('\n').map((_, i) => (
|
|
56
|
-
<div
|
|
99
|
+
<div
|
|
100
|
+
key={i + 1}
|
|
101
|
+
className={gutterLineClassName}
|
|
102
|
+
style={wrapped ? { height: wrapped.heights[i], flexShrink: 0 } : undefined}
|
|
103
|
+
>
|
|
57
104
|
{i + 1}
|
|
58
105
|
</div>
|
|
59
106
|
))}
|
|
@@ -64,6 +111,7 @@ export function CodeSurface({
|
|
|
64
111
|
onValueChange={onValueChange}
|
|
65
112
|
highlight={(code) => highlightCode(code, language)}
|
|
66
113
|
padding={editorPadding}
|
|
114
|
+
preRef={preRef}
|
|
67
115
|
textareaClassName={`focus:outline-none ${wrapClass}`}
|
|
68
116
|
preClassName={wrapClass}
|
|
69
117
|
style={editorStyle}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrapped-row measurement for the line-number gutter.
|
|
3
|
+
*
|
|
4
|
+
* The gutter is a column of one-row line numbers, which is only right while a
|
|
5
|
+
* line fits on one row. With word wrap on, the editor lays a long line out over
|
|
6
|
+
* several rows and every number after it would sit above its own line. How many
|
|
7
|
+
* rows a line takes is a layout fact — font metrics, break opportunities, the
|
|
8
|
+
* width the editor actually got — so it is measured rather than estimated: the
|
|
9
|
+
* same lines are laid out once in a hidden mirror that copies the editor's
|
|
10
|
+
* typography, and each line's height is read back.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The mirror has to reproduce the editor's line breaking exactly, so every
|
|
15
|
+
* property that can move a break is copied from the live editor's computed
|
|
16
|
+
* style instead of being restated (a second copy of the font stack would drift).
|
|
17
|
+
*/
|
|
18
|
+
const MIRRORED_PROPERTIES = [
|
|
19
|
+
'direction',
|
|
20
|
+
'fontFamily',
|
|
21
|
+
'fontFeatureSettings',
|
|
22
|
+
'fontKerning',
|
|
23
|
+
'fontSize',
|
|
24
|
+
'fontStyle',
|
|
25
|
+
'fontVariantLigatures',
|
|
26
|
+
'fontWeight',
|
|
27
|
+
'letterSpacing',
|
|
28
|
+
'overflowWrap',
|
|
29
|
+
'tabSize',
|
|
30
|
+
'textRendering',
|
|
31
|
+
'textTransform',
|
|
32
|
+
'whiteSpace',
|
|
33
|
+
'wordBreak',
|
|
34
|
+
'wordSpacing',
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
export interface WrappedLines {
|
|
38
|
+
/** Height in px of a single visual row. */
|
|
39
|
+
rowHeight: number;
|
|
40
|
+
/** Height in px each line of the source occupies once wrapped. */
|
|
41
|
+
heights: number[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let mirrorHost: HTMLDivElement | null = null;
|
|
45
|
+
let mirrorLines: HTMLDivElement[] = [];
|
|
46
|
+
|
|
47
|
+
/** Off-screen mirror, reused across measurements (one per page). */
|
|
48
|
+
function getMirrorHost(): HTMLDivElement {
|
|
49
|
+
if (!mirrorHost || !mirrorHost.isConnected) {
|
|
50
|
+
mirrorHost = document.createElement('div');
|
|
51
|
+
mirrorHost.setAttribute('aria-hidden', 'true');
|
|
52
|
+
mirrorHost.style.cssText =
|
|
53
|
+
'position:fixed;top:0;left:-100000px;visibility:hidden;pointer-events:none;margin:0;border:0;padding:0;box-sizing:border-box;';
|
|
54
|
+
document.body.appendChild(mirrorHost);
|
|
55
|
+
mirrorLines = [];
|
|
56
|
+
}
|
|
57
|
+
return mirrorHost;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Height each line of `value` occupies in `source`'s wrap layout, or null when
|
|
62
|
+
* the layout is not measurable yet (SSR, zero width, unresolved line-height) —
|
|
63
|
+
* callers then fall back to one row per line.
|
|
64
|
+
*/
|
|
65
|
+
export function measureWrappedLines(source: HTMLPreElement, value: string): WrappedLines | null {
|
|
66
|
+
if (typeof document === 'undefined' || !source.isConnected) return null;
|
|
67
|
+
const computed = getComputedStyle(source);
|
|
68
|
+
const rowHeight = Number.parseFloat(computed.lineHeight);
|
|
69
|
+
if (!Number.isFinite(rowHeight) || rowHeight <= 0) return null;
|
|
70
|
+
// The editor's text column: its padding is the gutter's offset, not text area.
|
|
71
|
+
const width =
|
|
72
|
+
source.clientWidth -
|
|
73
|
+
Number.parseFloat(computed.paddingLeft || '0') -
|
|
74
|
+
Number.parseFloat(computed.paddingRight || '0');
|
|
75
|
+
if (width <= 0) return null;
|
|
76
|
+
|
|
77
|
+
const host = getMirrorHost();
|
|
78
|
+
for (const property of MIRRORED_PROPERTIES) host.style[property] = computed[property];
|
|
79
|
+
host.style.lineHeight = `${rowHeight}px`;
|
|
80
|
+
host.style.width = `${width}px`;
|
|
81
|
+
|
|
82
|
+
const lines = value.split('\n');
|
|
83
|
+
while (mirrorLines.length > lines.length) mirrorLines.pop()?.remove();
|
|
84
|
+
while (mirrorLines.length < lines.length) {
|
|
85
|
+
const line = document.createElement('div');
|
|
86
|
+
host.appendChild(line);
|
|
87
|
+
mirrorLines.push(line);
|
|
88
|
+
}
|
|
89
|
+
// Write the whole text first, then read the whole layout: interleaving the
|
|
90
|
+
// two would force a re-layout per line.
|
|
91
|
+
for (let i = 0; i < lines.length; i++) mirrorLines[i].textContent = lines[i];
|
|
92
|
+
const heights = mirrorLines.map((line) => {
|
|
93
|
+
const height = line.getBoundingClientRect().height;
|
|
94
|
+
// An empty line has no line box at all, and sub-pixel wrapping would
|
|
95
|
+
// otherwise leave the gutter fractions of a row out of step.
|
|
96
|
+
return height > rowHeight ? Math.round(height / rowHeight) * rowHeight : rowHeight;
|
|
97
|
+
});
|
|
98
|
+
return { rowHeight, heights };
|
|
99
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { BarChart3, Bot, Files, GitBranch, Globe, Layers, Search, Terminal } from 'lucide-preact';
|
|
2
|
+
import type { ReactNode } from 'preact/compat';
|
|
2
3
|
import { useGitStatus } from '@/client/hooks/workspace/git-status';
|
|
3
4
|
import { GIT_STATUS_POLL_MS } from '@/shared/lib/workspace/refresh-cadence';
|
|
4
|
-
import type
|
|
5
|
+
import { RIGHT_PANEL_TYPES, type RightPanelType } from '@/shared/lib/workspace/right-panels';
|
|
5
6
|
|
|
6
7
|
interface RightActivityBarProps {
|
|
7
8
|
activePanel: RightPanelType;
|
|
@@ -12,83 +13,55 @@ interface RightActivityBarProps {
|
|
|
12
13
|
refreshKey: number;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Icon and tooltip per view. The *order* is not restated here: it comes from
|
|
18
|
+
* RIGHT_PANEL_TYPES, the same list the phone's right-sidebar tab bar renders,
|
|
19
|
+
* so the two layouts cannot list the views differently.
|
|
20
|
+
*/
|
|
21
|
+
const PANEL_META: Record<RightPanelType, { title: string; icon: ReactNode }> = {
|
|
22
|
+
context: { title: 'Context & Telemetry', icon: <Layers size={16} /> },
|
|
23
|
+
files: { title: 'Files', icon: <Files size={16} /> },
|
|
24
|
+
search: { title: 'Search', icon: <Search size={16} /> },
|
|
25
|
+
git: { title: 'Source Control', icon: <GitBranch size={16} /> },
|
|
26
|
+
terminal: { title: 'Terminal (Bun)', icon: <Terminal size={16} /> },
|
|
27
|
+
'user-browser': { title: 'Browser (Anda)', icon: <Globe size={16} /> },
|
|
28
|
+
browser: { title: 'Browser Agent', icon: <Bot size={16} /> },
|
|
29
|
+
usage: { title: 'Usage', icon: <BarChart3 size={16} /> },
|
|
30
|
+
};
|
|
31
|
+
|
|
15
32
|
export function RightActivityBar({ activePanel, onChangePanel, isPanelOpen, hasActiveContext, activeProjectPath, refreshKey }: RightActivityBarProps) {
|
|
16
33
|
const { changes } = useGitStatus(activeProjectPath ?? undefined, '.', refreshKey, hasActiveContext, GIT_STATUS_POLL_MS);
|
|
17
34
|
const hasGitChanges = changes.length > 0;
|
|
18
|
-
const getBtnClass = (panel: RightPanelType) => {
|
|
19
|
-
const base = "relative w-full h-10 flex items-center justify-center transition-colors border-l-2";
|
|
20
|
-
const isActive = isPanelOpen && activePanel === panel;
|
|
21
|
-
return `${base} ${isActive ? 'text-ink border-ink bg-ink/5' : 'text-ink/40 border-transparent hover:text-ink hover:bg-ink/5'}`;
|
|
22
|
-
};
|
|
23
35
|
|
|
24
36
|
return (
|
|
25
37
|
<nav className="w-12 flex-shrink-0 border-l border-ink/10 bg-paper flex flex-col items-center py-3 space-y-2 z-10">
|
|
26
38
|
{/* Top Icons */}
|
|
27
39
|
<div className="flex flex-col items-center space-y-1 w-full">
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
onClick={() => onChangePanel('git')}
|
|
52
|
-
title="Source Control"
|
|
53
|
-
>
|
|
54
|
-
<GitBranch size={16} />
|
|
55
|
-
{hasGitChanges && (
|
|
56
|
-
<span
|
|
57
|
-
className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full bg-info"
|
|
58
|
-
title="Ada perubahan git"
|
|
59
|
-
/>
|
|
60
|
-
)}
|
|
61
|
-
</button>
|
|
62
|
-
<button
|
|
63
|
-
className={getBtnClass('terminal')}
|
|
64
|
-
onClick={() => onChangePanel('terminal')}
|
|
65
|
-
title="Terminal (Bun)"
|
|
66
|
-
>
|
|
67
|
-
<Terminal size={16} />
|
|
68
|
-
</button>
|
|
69
|
-
<button
|
|
70
|
-
className={getBtnClass('user-browser')}
|
|
71
|
-
onClick={() => onChangePanel('user-browser')}
|
|
72
|
-
title="Browser (Anda)"
|
|
73
|
-
>
|
|
74
|
-
<Globe size={16} />
|
|
75
|
-
</button>
|
|
76
|
-
<button
|
|
77
|
-
className={getBtnClass('browser')}
|
|
78
|
-
onClick={() => onChangePanel('browser')}
|
|
79
|
-
title="Browser Agent"
|
|
80
|
-
>
|
|
81
|
-
<Bot size={16} />
|
|
82
|
-
</button>
|
|
83
|
-
<button
|
|
84
|
-
className={getBtnClass('usage')}
|
|
85
|
-
onClick={() => onChangePanel('usage')}
|
|
86
|
-
title="Usage"
|
|
87
|
-
>
|
|
88
|
-
<BarChart3 size={16} />
|
|
89
|
-
</button>
|
|
40
|
+
{RIGHT_PANEL_TYPES.map((panel) => {
|
|
41
|
+
const isActive = isPanelOpen && activePanel === panel;
|
|
42
|
+
return (
|
|
43
|
+
<button
|
|
44
|
+
key={panel}
|
|
45
|
+
className={`relative w-full h-10 flex items-center justify-center transition-colors border-l-2 ${
|
|
46
|
+
isActive
|
|
47
|
+
? 'text-ink border-ink bg-ink/5'
|
|
48
|
+
: 'text-ink/40 border-transparent hover:text-ink hover:bg-ink/5'
|
|
49
|
+
}`}
|
|
50
|
+
onClick={() => onChangePanel(panel)}
|
|
51
|
+
title={PANEL_META[panel].title}
|
|
52
|
+
>
|
|
53
|
+
{PANEL_META[panel].icon}
|
|
54
|
+
{panel === 'git' && hasGitChanges && (
|
|
55
|
+
<span
|
|
56
|
+
className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full bg-info"
|
|
57
|
+
title="Ada perubahan git"
|
|
58
|
+
/>
|
|
59
|
+
)}
|
|
60
|
+
</button>
|
|
61
|
+
);
|
|
62
|
+
})}
|
|
90
63
|
</div>
|
|
91
|
-
|
|
64
|
+
|
|
92
65
|
<div className="flex-1" />
|
|
93
66
|
</nav>
|
|
94
67
|
);
|