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.
Files changed (76) hide show
  1. package/README.md +46 -1
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/static/css/{index.5291f58223.css → index.51f68210a5.css} +1 -1
  4. package/dist/client/static/js/async/{3y.ededeebde8.js → 3y.4c2596f64c.js} +3 -3
  5. package/dist/client/static/js/async/73.308dff4da8.js +1 -0
  6. package/dist/client/static/js/async/{ty.3d93cf5f5f.js → 7g.a7814350c3.js} +1 -1
  7. package/dist/client/static/js/async/a.bce4542392.js +1 -0
  8. package/dist/client/static/js/async/g.8515cbc6f7.js +2 -0
  9. package/dist/client/static/js/async/hn.b645041e00.js +1 -0
  10. package/dist/client/static/js/async/{jg.69a4beade8.js → jg.d86ebd4013.js} +1 -1
  11. package/dist/client/static/js/async/l.12dd34e1be.js +1 -0
  12. package/dist/client/static/js/async/ls.9d9a01026c.js +104 -0
  13. package/dist/client/static/js/async/mw.ca1512563c.js +10 -0
  14. package/dist/client/static/js/async/nk.fc7e58325e.js +1 -0
  15. package/dist/client/static/js/async/{q.7fb4d2f374.js → q.542c184fec.js} +3 -3
  16. package/dist/client/static/js/async/t.cda4891526.js +1 -0
  17. package/dist/client/static/js/async/tr.e878658140.js +309 -0
  18. package/dist/client/static/js/async/uf.24ca3d49c3.js +2 -0
  19. package/dist/client/static/js/{index.0ac93ee878.js → index.47d3b04d5f.js} +7 -7
  20. package/dist/client/static/js/{uy.5f791144ce.js → uy.2e8929ed0c.js} +2 -2
  21. package/package.json +13 -5
  22. package/src/cli/lib/args.js +6 -6
  23. package/src/cli/lib/commands/logs.js +76 -34
  24. package/src/cli/lib/commands/restart.js +30 -9
  25. package/src/cli/lib/commands/serve.js +12 -4
  26. package/src/cli/lib/commands/status.js +40 -18
  27. package/src/cli/lib/commands/stop.js +12 -7
  28. package/src/cli/lib/commands/update.js +2 -5
  29. package/src/cli/lib/paths.js +8 -28
  30. package/src/cli/lib/runtime.js +118 -108
  31. package/src/client/components/common/code-editor/index.tsx +5 -1
  32. package/src/client/components/common/code-surface/index.tsx +49 -1
  33. package/src/client/components/common/code-surface/measure.ts +99 -0
  34. package/src/client/components/layout/RightActivityBar.tsx +42 -69
  35. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +5 -6
  36. package/src/client/components/layout/desktop-layout/index.tsx +11 -9
  37. package/src/client/components/layout/desktop-layout/resizer/index.tsx +22 -29
  38. package/src/client/components/layout/desktop-layout/resizer/utils.test.ts +33 -0
  39. package/src/client/components/layout/desktop-layout/resizer/utils.ts +34 -0
  40. package/src/client/components/mobile/RightSidebar.tsx +42 -119
  41. package/src/client/components/mobile/mobile-main-view/Header.tsx +17 -1
  42. package/src/client/components/workspace/editor/index.tsx +4 -1
  43. package/src/client/hooks/editor/use-file-editor.test.ts +158 -0
  44. package/src/client/hooks/editor/use-file-editor.ts +35 -19
  45. package/src/server/index.ts +51 -1
  46. package/src/server/lib/lifecycle/identity.ts +108 -0
  47. package/src/server/lib/lifecycle/instance.ts +102 -0
  48. package/src/server/lib/lifecycle/occupant.ts +101 -0
  49. package/src/server/lib/lifecycle/paths.ts +50 -0
  50. package/src/server/lib/lifecycle/port-guard.ts +170 -0
  51. package/src/server/lib/lifecycle/probe.ts +121 -0
  52. package/src/server/plugins/static.test.ts +63 -0
  53. package/src/server/plugins/static.ts +28 -4
  54. package/src/server/routes/health/probe.ts +8 -0
  55. package/src/shared/lib/markdown/mermaid.ts +8 -0
  56. package/src/shared/lib/workspace/panel-widths.test.ts +5 -5
  57. package/src/shared/lib/workspace/panel-widths.ts +23 -10
  58. package/src/shared/lib/workspace/right-panels.ts +10 -4
  59. package/dist/client/static/js/async/73.50280ce58d.js +0 -1
  60. package/dist/client/static/js/async/a.e05da304c8.js +0 -10
  61. package/dist/client/static/js/async/ar.1e14da5588.js +0 -1
  62. package/dist/client/static/js/async/g.7a4bbd4798.js +0 -2
  63. package/dist/client/static/js/async/hn.7568bdb58b.js +0 -1
  64. package/dist/client/static/js/async/l.102a97fce6.js +0 -104
  65. package/dist/client/static/js/async/l6.c56640c205.js +0 -1
  66. package/dist/client/static/js/async/nk.6de011832e.js +0 -1
  67. package/dist/client/static/js/async/t.cb3fcf8615.js +0 -1
  68. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  69. package/dist/client/static/js/async/vn.e98e2f3b61.js +0 -309
  70. /package/dist/client/static/js/async/{3y.ededeebde8.js.LICENSE.txt → 3y.4c2596f64c.js.LICENSE.txt} +0 -0
  71. /package/dist/client/static/js/async/{g.7a4bbd4798.js.LICENSE.txt → g.8515cbc6f7.js.LICENSE.txt} +0 -0
  72. /package/dist/client/static/js/async/{a.e05da304c8.js.LICENSE.txt → mw.ca1512563c.js.LICENSE.txt} +0 -0
  73. /package/dist/client/static/js/async/{q.7fb4d2f374.js.LICENSE.txt → q.542c184fec.js.LICENSE.txt} +0 -0
  74. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → uf.24ca3d49c3.js.LICENSE.txt} +0 -0
  75. /package/dist/client/static/js/{index.0ac93ee878.js.LICENSE.txt → index.47d3b04d5f.js.LICENSE.txt} +0 -0
  76. /package/dist/client/static/js/{uy.5f791144ce.js.LICENSE.txt → uy.2e8929ed0c.js.LICENSE.txt} +0 -0
@@ -0,0 +1,170 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Port guarding for a starting OMPChamber server.
8
+ *
9
+ * One port is served by exactly one process, and **a starting instance never
10
+ * stops another one** — not a CLI server, not a dev run. An occupant that
11
+ * identifies itself as OMPChamber makes the newcomer exit with the ways to pick
12
+ * a different port; freeing a port is what `ompchamber stop --port <port>` is
13
+ * for, done deliberately and per port.
14
+ *
15
+ * The refusal is load-bearing rather than defensive: a second bind is *not*
16
+ * refused by the runtime (Elysia's Bun adapter hardcodes `reusePort: true`), so
17
+ * two servers would coexist on one port and only the first-bound socket would
18
+ * receive connections. The newcomer would look healthy in its own log while the
19
+ * browser kept talking to the old build; the guard stops that state from being
20
+ * created in the first place.
21
+ */
22
+
23
+ import fs from 'fs';
24
+
25
+ import { getProcessState } from '@/server/lib/lifecycle/identity';
26
+ import { describeOccupant, findOccupant } from '@/server/lib/lifecycle/occupant';
27
+ import { getLockPath } from '@/server/lib/lifecycle/paths';
28
+ import { isPortAvailable } from '@/server/lib/lifecycle/probe';
29
+
30
+ const LOCK_WAIT_MS = 3_000;
31
+ /** Grace period for a port that is settling (a server that just exited, or one that is starting). */
32
+ const SETTLE_WAIT_MS = 3_000;
33
+
34
+ export class PortInUseError extends Error {
35
+ constructor(message: string) {
36
+ super(message);
37
+ this.name = 'PortInUseError';
38
+ }
39
+ }
40
+
41
+ export interface PortLock {
42
+ release: () => void;
43
+ }
44
+
45
+ /**
46
+ * Exclusive per-port startup lock.
47
+ *
48
+ * Two starters racing on one free port would each probe it as free and then
49
+ * fight over the bind, with the loser reporting a bare `EADDRINUSE` against a
50
+ * process it cannot identify yet. Holding the lock across probe-and-bind makes
51
+ * the loser fail with a message that names the actual situation. A lock whose
52
+ * holder is dead (or is not OMPChamber, i.e. a recycled PID) is reclaimed.
53
+ *
54
+ * Returns null when another starter holds the lock through the wait window.
55
+ */
56
+ export async function acquirePortLock(port: number, waitMs = LOCK_WAIT_MS): Promise<PortLock | null> {
57
+ const lockPath = getLockPath(port);
58
+ const deadline = Date.now() + waitMs;
59
+ for (;;) {
60
+ try {
61
+ fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx', mode: 0o600 });
62
+ return { release: () => releasePortLock(lockPath) };
63
+ } catch (error) {
64
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') return null;
65
+ }
66
+
67
+ let holder = Number.NaN;
68
+ try {
69
+ holder = Number(fs.readFileSync(lockPath, 'utf8').trim());
70
+ } catch {
71
+ continue;
72
+ }
73
+
74
+ const holderState = Number.isFinite(holder) && holder > 0 ? getProcessState(holder) : 'dead';
75
+ if (holderState === 'dead' || holderState === 'mismatched') {
76
+ fs.rmSync(lockPath, { force: true });
77
+ continue;
78
+ }
79
+ if (holder === process.pid) return { release: () => releasePortLock(lockPath) };
80
+ if (Date.now() >= deadline) return null;
81
+ await Bun.sleep(150);
82
+ }
83
+ }
84
+
85
+ function releasePortLock(lockPath: string): void {
86
+ try {
87
+ if (fs.readFileSync(lockPath, 'utf8').trim() === String(process.pid)) fs.rmSync(lockPath, { force: true });
88
+ } catch {
89
+ // Already gone, or never ours to remove.
90
+ }
91
+ }
92
+
93
+ export interface ClaimPortOptions {
94
+ port: number;
95
+ host: string;
96
+ /**
97
+ * Binds the port; must throw when the OS refuses. Retried while the port
98
+ * looks occupied by something that is not an identified OMPChamber instance.
99
+ */
100
+ listen: () => Promise<void>;
101
+ }
102
+
103
+ /**
104
+ * Bind `port`, or fail with an actionable error.
105
+ *
106
+ * The bind is the authoritative question ("can this process listen here") and
107
+ * `listen` is what answers it; the probe only short-circuits the common case.
108
+ * When either says the port is taken, the occupant is identified so the error
109
+ * can name it — and then the process exits, leaving every other instance
110
+ * running.
111
+ */
112
+ export async function claimPort({ port, host, listen }: ClaimPortOptions): Promise<void> {
113
+ const deadline = Date.now() + SETTLE_WAIT_MS;
114
+ let bindError: unknown = null;
115
+
116
+ for (;;) {
117
+ if (await isPortAvailable(port, host)) {
118
+ try {
119
+ await listen();
120
+ return;
121
+ } catch (error) {
122
+ bindError = error;
123
+ }
124
+ }
125
+
126
+ const occupant = await findOccupant(port, host);
127
+
128
+ // `bun run --hot` re-evaluates this entry inside the running process, so the
129
+ // port is held by *us* and Bun swaps the fetch handler on the next `listen`.
130
+ // Treating that as a conflict would kill the dev loop on every save.
131
+ if (occupant && occupant.pid === process.pid) {
132
+ await listen();
133
+ return;
134
+ }
135
+
136
+ if (occupant) throw await buildPortInUseError(port, host, bindError);
137
+
138
+ if (Date.now() >= deadline) throw await buildPortInUseError(port, host, bindError);
139
+ await Bun.sleep(200);
140
+ }
141
+ }
142
+
143
+ /** Every way to point this server at another port. */
144
+ function portGuidance(): string {
145
+ return [
146
+ ' ompchamber serve --port 3001',
147
+ ' OMPCHAMBER_PORT=3001 ompchamber serve',
148
+ ' PORT=3001 bun run dev # or: PORT=3001 bun run start',
149
+ ].join('\n');
150
+ }
151
+
152
+ async function buildPortInUseError(port: number, host: string, bindError: unknown): Promise<PortInUseError> {
153
+ const occupant = await findOccupant(port, host);
154
+ const bindDetail = bindError instanceof Error ? `\n Bind error: ${bindError.message}` : '';
155
+
156
+ if (occupant) {
157
+ return new PortInUseError(
158
+ `Port ${port} is already served by OMPChamber ${describeOccupant(occupant)}.\n`
159
+ + ` Nothing was stopped — a starting instance never stops another one.\n`
160
+ + ` Stop it first: ompchamber stop --port ${port}\n`
161
+ + ` Or start on another port:\n${portGuidance()}`,
162
+ );
163
+ }
164
+
165
+ return new PortInUseError(
166
+ `Port ${port} is in use by another process (no OMPChamber instance answered on it).${bindDetail}\n`
167
+ + ` Find it with: lsof -nP -iTCP:${port} -sTCP:LISTEN\n`
168
+ + ` Start on another port:\n${portGuidance()}`,
169
+ );
170
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Port occupancy probing.
8
+ *
9
+ * Availability is decided by *attempting a bind*, not by inspecting a PID file.
10
+ * That matters on Bun: `Bun.serve` — and therefore Elysia's `listen`, which
11
+ * hardcodes `reusePort: true` — lets a second process bind a port already in
12
+ * use, so the only reliable question a starter can ask the OS is "can I bind
13
+ * this?" (`net.createServer` never sets SO_REUSEPORT, so a second bind fails as
14
+ * it should, regardless of who is already listening).
15
+ */
16
+
17
+ import net from 'net';
18
+
19
+ const HEALTH_TIMEOUT_MS = 1500;
20
+
21
+ /**
22
+ * Address a local probe should use for a bind host.
23
+ *
24
+ * `0.0.0.0`/`::` are bind-any addresses and cannot be connected to; loopback is
25
+ * the address that reaches the same listener.
26
+ */
27
+ export function probeHost(host?: string | null): string {
28
+ const value = (host ?? '').trim();
29
+ if (value.length === 0 || value === '0.0.0.0' || value === '::' || value === '[::]') return '127.0.0.1';
30
+ return value;
31
+ }
32
+
33
+ /**
34
+ * True when `port` can still be bound on `host`.
35
+ *
36
+ * The probe binds *the same address the server will bind* — a wildcard server
37
+ * is probed with a wildcard bind. Probing loopback instead would ask a
38
+ * different question: `net` sets SO_REUSEADDR, so a loopback bind can succeed
39
+ * while `0.0.0.0:<port>` is already taken, and the port would look free right
40
+ * up until the real `listen` failed.
41
+ */
42
+ export async function isPortAvailable(port: number, host?: string | null): Promise<boolean> {
43
+ if (!Number.isFinite(port) || port <= 0) return false;
44
+ const bindHost = (host ?? '').trim() || 'localhost';
45
+ const { promise, resolve } = Promise.withResolvers<boolean>();
46
+ const probe = net.createServer();
47
+ probe.unref();
48
+ probe.once('error', () => resolve(false));
49
+ probe.listen({ port, host: bindHost }, () => {
50
+ probe.close(() => resolve(true));
51
+ });
52
+ return await promise;
53
+ }
54
+
55
+ /** Poll until the port can be bound, or the deadline passes. */
56
+ export async function waitForPortFree(
57
+ port: number,
58
+ host: string | null | undefined,
59
+ timeoutMs: number,
60
+ intervalMs = 150,
61
+ ): Promise<boolean> {
62
+ const deadline = Date.now() + Math.max(0, timeoutMs);
63
+ for (;;) {
64
+ if (await isPortAvailable(port, host)) return true;
65
+ if (Date.now() >= deadline) return false;
66
+ await Bun.sleep(intervalMs);
67
+ }
68
+ }
69
+
70
+ export interface HealthPayload {
71
+ service: string;
72
+ pid?: number;
73
+ version?: string;
74
+ startedAt?: string;
75
+ uptime?: number;
76
+ mock?: boolean;
77
+ runtime?: string;
78
+ mode?: string;
79
+ port?: number;
80
+ host?: string;
81
+ }
82
+
83
+ /**
84
+ * `GET /api/health` for an OMPChamber listener, or null.
85
+ *
86
+ * Only payloads that identify themselves as OMPChamber are returned — every
87
+ * caller here is asking "is an OMPChamber server on this port", so a foreign
88
+ * HTTP service answers null rather than a half-trusted object.
89
+ */
90
+ export async function fetchHealth(
91
+ port: number,
92
+ host?: string | null,
93
+ timeoutMs = HEALTH_TIMEOUT_MS,
94
+ ): Promise<HealthPayload | null> {
95
+ if (!Number.isFinite(port) || port <= 0) return null;
96
+ try {
97
+ const response = await fetch(`http://${probeHost(host)}:${port}/api/health`, {
98
+ headers: { accept: 'application/json' },
99
+ signal: AbortSignal.timeout(timeoutMs),
100
+ });
101
+ if (!response.ok) return null;
102
+ const body: unknown = await response.json();
103
+ if (!body || typeof body !== 'object') return null;
104
+ const payload = body as Record<string, unknown>;
105
+ if (payload.service !== 'ompchamber') return null;
106
+ return {
107
+ service: 'ompchamber',
108
+ pid: typeof payload.pid === 'number' ? payload.pid : undefined,
109
+ version: typeof payload.version === 'string' ? payload.version : undefined,
110
+ startedAt: typeof payload.startedAt === 'string' ? payload.startedAt : undefined,
111
+ uptime: typeof payload.uptime === 'number' ? payload.uptime : undefined,
112
+ mock: typeof payload.mock === 'boolean' ? payload.mock : undefined,
113
+ runtime: typeof payload.runtime === 'string' ? payload.runtime : undefined,
114
+ mode: typeof payload.mode === 'string' ? payload.mode : undefined,
115
+ port: typeof payload.port === 'number' ? payload.port : undefined,
116
+ host: typeof payload.host === 'string' ? payload.host : undefined,
117
+ };
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
@@ -0,0 +1,63 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
3
+ import { tmpdir } from 'os';
4
+ import { join } from 'path';
5
+
6
+ import { servePublicAsset } from '@/server/plugins/static';
7
+
8
+ // Two roots, as they exist in real life: `public/` is the source folder a
9
+ // checkout has, `dist/client` is the build output an installed package ships.
10
+ const CHECKOUT_PUBLIC = join(mkdtempSync(join(tmpdir(), 'omp-static-')), 'public');
11
+ const BUILD_OUTPUT = join(mkdtempSync(join(tmpdir(), 'omp-static-')), 'dist/client');
12
+ const ROOTS = [CHECKOUT_PUBLIC, BUILD_OUTPUT] as const;
13
+
14
+ beforeAll(() => {
15
+ mkdirSync(CHECKOUT_PUBLIC, { recursive: true });
16
+ mkdirSync(BUILD_OUTPUT, { recursive: true });
17
+ writeFileSync(join(CHECKOUT_PUBLIC, 'icon.svg'), '<svg id="checkout"/>');
18
+ writeFileSync(join(BUILD_OUTPUT, 'icon.svg'), '<svg id="build"/>');
19
+ writeFileSync(join(BUILD_OUTPUT, 'manifest.webmanifest'), '{"name":"OMPChamber"}');
20
+ writeFileSync(join(BUILD_OUTPUT, 'index.html'), '<!doctype html><title>shell</title>');
21
+ });
22
+
23
+ afterAll(() => {
24
+ rmSync(CHECKOUT_PUBLIC, { recursive: true, force: true });
25
+ rmSync(BUILD_OUTPUT, { recursive: true, force: true });
26
+ });
27
+
28
+ describe('servePublicAsset', () => {
29
+ // The bug this guards: an installed `ompchamber` has no `public/` folder, so a
30
+ // lookup confined to it answered the HTML shell for `/icon.svg` and the About
31
+ // modal rendered a broken image.
32
+ test('falls back to the build output when the source folder has no copy', async () => {
33
+ const response = await servePublicAsset('/manifest.webmanifest', ROOTS);
34
+ expect(response?.headers.get('content-type')).toBe('application/manifest+json');
35
+ expect(await response?.text()).toBe('{"name":"OMPChamber"}');
36
+ });
37
+
38
+ test('prefers the source folder so a dev edit lands without a rebuild', async () => {
39
+ const response = await servePublicAsset('/icon.svg', ROOTS);
40
+ expect(response?.headers.get('content-type')).toBe('image/svg+xml');
41
+ expect(await response?.text()).toBe('<svg id="checkout"/>');
42
+ });
43
+
44
+ test('leaves the HTML shell to the SSR route', async () => {
45
+ expect(await servePublicAsset('/index.html', ROOTS)).toBeNull();
46
+ });
47
+
48
+ test('keeps a sibling of the root unreachable', async () => {
49
+ const outside = join(BUILD_OUTPUT, '..', 'secret.txt');
50
+ writeFileSync(outside, 'not an asset');
51
+ expect(await servePublicAsset('/../secret.txt', [BUILD_OUTPUT])).toBeNull();
52
+ expect(await servePublicAsset('/%2e%2e/secret.txt', [BUILD_OUTPUT])).toBeNull();
53
+ });
54
+
55
+ test('short-caches the fallback, whose filename carries no content hash', async () => {
56
+ const response = await servePublicAsset('/manifest.webmanifest', ROOTS);
57
+ expect(response?.headers.get('cache-control')).toBe('public, max-age=3600');
58
+ });
59
+
60
+ test('returns null for a missing asset so the request reaches the app', async () => {
61
+ expect(await servePublicAsset('/nope.svg', ROOTS)).toBeNull();
62
+ });
63
+ });
@@ -68,14 +68,38 @@ async function serveFile(filePath: string, cacheControl: string): Promise<Respon
68
68
  });
69
69
  }
70
70
 
71
+ /** Roots searched for a non-`/static/` asset, in preference order. */
72
+ const PUBLIC_ROOTS = [PUBLIC_ROOT, CLIENT_ROOT] as const;
73
+
74
+ /**
75
+ * The first readable copy of `pathname` under `roots`, or null.
76
+ *
77
+ * Non-`/static/` paths are the public assets (`icon.svg`, the web manifest, the
78
+ * service worker, the touch icons). They exist twice: `public/` in a checkout
79
+ * and `dist/client/` in the build — and only the latter ships, because
80
+ * `package.json#files` lists `src`, `dist/client` and `tsconfig.json`. Serving
81
+ * `public/` alone therefore 404s every one of them into the SSR shell when the
82
+ * chamber runs from an installed package. The source folder is tried first so a
83
+ * dev edit lands without a rebuild; the build output is the fallback. HTML is
84
+ * excluded so `/index.html` keeps going through the SSR route that injects the
85
+ * theme and bootstrap, and the cache stays short because these filenames carry
86
+ * no content hash.
87
+ */
88
+ export async function servePublicAsset(pathname: string, roots: readonly string[]): Promise<Response | null> {
89
+ for (const root of roots) {
90
+ const filePath = resolveWithin(root, pathname);
91
+ if (!filePath || filePath.endsWith('.html')) continue;
92
+ const response = await serveFile(filePath, SHORT);
93
+ if (response) return response;
94
+ }
95
+ return null;
96
+ }
97
+
71
98
  /** A file for `pathname`, or null when the request should reach the app. */
72
99
  export async function tryServeStatic(pathname: string): Promise<Response | null> {
73
100
  if (pathname.startsWith('/static/')) {
74
101
  const filePath = resolveWithin(CLIENT_ROOT, pathname);
75
102
  return filePath ? serveFile(filePath, ASSET_CACHE) : null;
76
103
  }
77
-
78
- const publicPath = resolveWithin(PUBLIC_ROOT, pathname);
79
- if (!publicPath) return null;
80
- return serveFile(publicPath, SHORT);
104
+ return servePublicAsset(pathname, PUBLIC_ROOTS);
81
105
  }
@@ -21,6 +21,14 @@ export async function loader() {
21
21
  mock: isMockMode(),
22
22
  runtime: 'bun',
23
23
  bun: Bun.version,
24
+ // Identity of the listener itself, read by the CLI and by a starting
25
+ // server deciding whether this port belongs to OMPChamber: `mode` and
26
+ // `launchMode` are what `status` prints, and the pair with `pid` is what
27
+ // separates a dev server from a stale published build.
28
+ mode: Bun.env.NODE_ENV === 'production' ? 'prod' : 'dev',
29
+ launchMode: Bun.env.OMPCHAMBER_LAUNCH_MODE ?? 'direct',
30
+ port: Number(Bun.env.PORT) || 3000,
31
+ host: Bun.env.HOST || 'localhost',
24
32
  },
25
33
  { headers: NO_STORE_HEADERS },
26
34
  );
@@ -69,6 +69,14 @@ async function loadMermaid(themeKey: ThemeKey): Promise<MermaidModule['default']
69
69
  // diagram types this app sees, so pin it here and alias `elkjs` to a
70
70
  // stub in the build (`src/client/stubs/elkjs.ts`).
71
71
  layout: 'dagre',
72
+ // On a parse/draw failure mermaid draws its own "Syntax error in text /
73
+ // mermaid version 12.x" diagram into a temp `<div id="d<render-id>">`
74
+ // appended to `document.body`, and throws BEFORE removing it — so every
75
+ // failed render leaks a full-size error SVG into the page (the stray
76
+ // bottom banner). `suppressErrorRendering` makes that path drop the temp
77
+ // elements first; `renderBlock`'s catch already degrades the block to
78
+ // its raw source, which is the only fallback we want.
79
+ suppressErrorRendering: true,
72
80
  flowchart: { useMaxWidth: false, wrappingWidth: 100000 },
73
81
  sequence: { useMaxWidth: false },
74
82
  });
@@ -11,7 +11,6 @@ describe('normalizePanelWidths', () => {
11
11
  test('keeps every panel slot from a current blob', () => {
12
12
  const raw = {
13
13
  left: 342,
14
- chat: 700,
15
14
  editor: 620,
16
15
  diff: 900,
17
16
  right: { files: 300, browser: 804 },
@@ -29,8 +28,10 @@ describe('normalizePanelWidths', () => {
29
28
  });
30
29
 
31
30
  test('drops unusable entries instead of trusting the blob', () => {
31
+ // `chat` is an obsolete slot — the chat column is the filler now — and a
32
+ // blob written before that must not resurrect it.
32
33
  const widths = normalizePanelWidths(
33
- { left: -10, chat: '700', editor: 620.4, diff: null, right: { files: 0, bogus: 300, git: 260 } },
34
+ { left: -10, chat: 700, editor: 620.4, diff: null, right: { files: 0, bogus: 300, git: 260 } },
34
35
  'files',
35
36
  );
36
37
 
@@ -47,7 +48,6 @@ describe('mergePanelWidths', () => {
47
48
  test('merging one view keeps every other remembered width', () => {
48
49
  const stored = {
49
50
  left: 342,
50
- chat: 700,
51
51
  editor: 620,
52
52
  diff: 900,
53
53
  right: { files: 300, git: 260 },
@@ -63,10 +63,10 @@ describe('mergePanelWidths', () => {
63
63
 
64
64
  test('a resized panel replaces only its own slot', () => {
65
65
  const merged = mergePanelWidths(
66
- { chat: 700, editor: 620, diff: 900, right: { files: 300 } },
66
+ { left: 300, editor: 620, diff: 900, right: { files: 300 } },
67
67
  { editor: 480 },
68
68
  );
69
69
 
70
- expect(merged).toEqual({ chat: 700, editor: 480, diff: 900, right: { files: 300 } });
70
+ expect(merged).toEqual({ left: 300, editor: 480, diff: 900, right: { files: 300 } });
71
71
  });
72
72
  });
@@ -2,9 +2,11 @@
2
2
  * Per-panel width state for the desktop layout.
3
3
  *
4
4
  * Every resizable surface remembers its OWN width: the session sidebar, the
5
- * chat column, the editor panel (source and diff tabs separately, since a diff
6
- * wants room a file view does not) and each right-panel view. Collapsing those
7
- * into one slot per group is what made switching a panel look like a reset.
5
+ * editor panel (source and diff tabs separately, since a diff wants room a
6
+ * file view does not) and each right-panel view (see `right-panels.ts`).
7
+ * Collapsing those into one slot per group is what made switching a panel look
8
+ * like a reset. The chat column is the group's filler — flexbox sizes it — so
9
+ * it has no remembered width, only a floor it may not be pushed below.
8
10
  *
9
11
  * Sizes are pixels — the unit the panel constraints are written in — and are
10
12
  * persisted as `app_settings.desktopLayoutSizes`.
@@ -17,8 +19,6 @@ export type EditorWidthMode = 'editor' | 'diff';
17
19
  export interface PanelWidths {
18
20
  /** Session sidebar. */
19
21
  left?: number;
20
- /** Chat timeline column, the inner stack's first panel. */
21
- chat?: number;
22
22
  /** Editor panel while a source file tab is active. */
23
23
  editor?: number;
24
24
  /** Editor panel while a diff tab is active. */
@@ -28,13 +28,26 @@ export interface PanelWidths {
28
28
  }
29
29
 
30
30
  /** Fallbacks for panels that have never been resized. */
31
- export const DEFAULT_PANEL_WIDTHS: Record<EditorWidthMode | 'left' | 'chat', number> = {
31
+ export const DEFAULT_PANEL_WIDTHS: Record<EditorWidthMode | 'left', number> = {
32
32
  left: 268,
33
- chat: 540,
34
- editor: 536,
35
- diff: 536,
33
+ editor: 600,
34
+ // Side-by-side diffs need both halves readable at once: `SplitView` states a
35
+ // 700px floor of its own, so anything below it scrolls sideways on open.
36
+ diff: 720,
36
37
  };
37
38
 
39
+ /**
40
+ * Panel floors and ceilings. The sidebar ceiling and the chat floor are a pair:
41
+ * `MIN_CHAT_PANEL_WIDTH` is what stops the workspace stack from eating the
42
+ * conversation, and it is also why the sidebar cannot open past 520 and still
43
+ * leave the editor (300) and a browser view (320) room on a 1440px display.
44
+ */
45
+ export const MIN_LEFT_PANEL_WIDTH = 200;
46
+ export const MAX_LEFT_PANEL_WIDTH = 520;
47
+ export const MIN_CHAT_PANEL_WIDTH = 420;
48
+ export const MIN_EDITOR_PANEL_WIDTH = 300;
49
+ export const MAX_EDITOR_PANEL_WIDTH = 1200;
50
+
38
51
  function pixelWidth(value: unknown): number | undefined {
39
52
  return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.round(value) : undefined;
40
53
  }
@@ -50,7 +63,7 @@ export function normalizePanelWidths(raw: unknown, legacyView: RightPanelType):
50
63
  const source = raw as Record<string, unknown>;
51
64
 
52
65
  const widths: PanelWidths = {};
53
- for (const key of ['left', 'chat', 'editor', 'diff'] as const) {
66
+ for (const key of ['left', 'editor', 'diff'] as const) {
54
67
  const value = pixelWidth(source[key]);
55
68
  if (value !== undefined) widths[key] = value;
56
69
  }
@@ -4,14 +4,16 @@
4
4
  *
5
5
  * The list lives here rather than in the activity bar component so the layout
6
6
  * can key one remembered width per view (`panel-widths.ts`) and validate a
7
- * stored blob without importing UI.
7
+ * stored blob without importing UI. It is also the single source of the views'
8
+ * *order*: the desktop activity bar, the desktop panel stack and the phone's
9
+ * tab bar all render from it, so the two layouts cannot drift apart.
8
10
  */
9
11
  export const RIGHT_PANEL_TYPES = [
12
+ 'context',
10
13
  'files',
11
14
  'search',
12
15
  'git',
13
16
  'terminal',
14
- 'context',
15
17
  'user-browser',
16
18
  'browser',
17
19
  'usage',
@@ -24,7 +26,9 @@ export const DEFAULT_RIGHT_PANEL_WIDTHS: Record<RightPanelType, number> = {
24
26
  files: 268,
25
27
  search: 268,
26
28
  git: 268,
27
- terminal: 536,
29
+ // 80 columns at the terminal's 12px Fira Code (≈7.2px/char) need ≈600px
30
+ // before the scrollbar; the old 536 landed at ≈74 columns and wrapped.
31
+ terminal: 640,
28
32
  context: 536,
29
33
  'user-browser': 804,
30
34
  browser: 804,
@@ -36,7 +40,9 @@ export const MIN_RIGHT_PANEL_WIDTHS: Record<RightPanelType, number> = {
36
40
  files: 200,
37
41
  search: 200,
38
42
  git: 260,
39
- terminal: 200,
43
+ // A 200px terminal is ≈27 columns — below every shell's assumption; 320
44
+ // holds a two-pane TUI or a git log with the graph.
45
+ terminal: 320,
40
46
  context: 420,
41
47
  'user-browser': 320,
42
48
  browser: 320,
@@ -1 +0,0 @@
1
- "use strict";(self.rspackChunkompchamber=self.rspackChunkompchamber||[]).push([[73],{tc(e,r,t){var n=t("I6l"),a=t("P0");t("MM"),t("Yr");var s=t("M4"),p=t("Gt"),l=t("Xe"),i=(0,l.WG)().RailroadEbnf.parser.LangiumParser,o=(0,p.K)(e=>{let r=e.alternatives.map(m);return 1===r.length?r[0]:{type:"choice",alternatives:r}},"transformChoice"),m=(0,p.K)(e=>{let r=e.elements.map(f);return 1===r.length?r[0]:{type:"sequence",elements:r}},"transformSequence"),u=(0,p.K)(e=>{switch(e.$type){case"EbnfTerminal":return{type:"terminal",value:e.value};case"EbnfNonTerminal":return{type:"nonterminal",name:e.name};case"EbnfSpecial":return{type:"special",text:e.text};case"EbnfGroup":return o(e.element);case"EbnfOptional":return{type:"optional",element:o(e.element)};case"EbnfRepetition":return{type:"repetition",element:o(e.element),min:0,max:1/0};default:throw Error(`Unsupported EBNF primary node: ${e.$type}`)}},"transformPrimary"),c=(0,p.K)((e,r)=>{switch(r.$type){case"EbnfOptionalPostfix":return{type:"optional",element:e};case"EbnfZeroOrMorePostfix":return{type:"repetition",element:e,min:0,max:1/0};case"EbnfOneOrMorePostfix":return{type:"repetition",element:e,min:1,max:1/0};case"EbnfExceptionPostfix":return{type:"sequence",elements:[e,{type:"terminal",value:"-"},u(r.except)]};default:throw Error(`Unsupported EBNF postfix node: ${r.$type}`)}},"transformPostfix"),f=(0,p.K)(e=>e.postfixes.reduce((e,r)=>c(e,r),u(e.base)),"transformTerm"),d=(0,p.K)(e=>({name:e.name,definition:o(e.definition)}),"transformRule"),b=(0,p.K)(e=>{(0,a.S)(e,n.db),e.title&&n.db.setTitle(e.title),e.rules.map(e=>n.db.addRule(d(e)))},"populateDb"),y={parser:{parse:(0,p.K)(e=>{n.db.clear(),s.R.debug("[EBNF Parser] Starting Langium parse");let r=i.parse(e);if(r.lexerErrors.length>0||r.parserErrors.length>0)throw new l.zg(r);let t=r.value;s.R.debug("[EBNF Parser] Parsed rules:",t.rules.length),b(t),s.R.debug("[EBNF Parser] Parse complete")},"parse"),parser:{yy:n.db}},db:n.db,renderer:n.U,styles:n.$};t.d(r,{diagram:()=>y})}}]);
@@ -1,10 +0,0 @@
1
- /*! LICENSE: a.e05da304c8.js.LICENSE.txt */
2
- "use strict";(self.rspackChunkompchamber=self.rspackChunkompchamber||[]).push([["a"],{HO(e,t,n){n.d(t,{P:()=>u});var r=n("j"),i=n("H");let s=`
3
- .code-editor-native-textarea {
4
- -webkit-text-fill-color: transparent;
5
- }
6
- .code-editor-native-textarea::placeholder {
7
- -webkit-text-fill-color: var(--theme-ink);
8
- opacity: 0.45;
9
- }
10
- `;function a(){return(0,r.Y)("style",{dangerouslySetInnerHTML:{__html:s}})}function l(e,t){return e.substring(0,t).split("\n")}function o(e){let{value:t,onValueChange:n,highlight:s,padding:o=0,preClassName:c,textareaClassName:d,style:u,className:p,placeholder:h,readOnly:f,autoFocus:m}=e,x=(0,i.li)(null),b=(0,i.li)({stack:[],offset:-1}),[g,y]=(0,i.J0)(!0);(0,i.vJ)(()=>{let e=x.current;if(!e)return;let t={value:e.value,selectionStart:e.selectionStart,selectionEnd:e.selectionEnd,timestamp:Date.now()},{stack:n,offset:r}=b.current;n.length&&r>-1&&(b.current.stack=n.slice(0,r+1)),b.current.stack.push(t),b.current.offset=b.current.stack.length-1},[t]);let k=e=>{let t=x.current,r=b.current.stack[b.current.offset];if(r&&t&&(b.current.stack[b.current.offset]={...r,selectionStart:t.selectionStart,selectionEnd:t.selectionEnd}),b.current.stack.push({...e,timestamp:Date.now()}),b.current.offset=b.current.stack.length-1,b.current.stack.length>100){let e=b.current.stack.length-100;b.current.stack=b.current.stack.slice(e),b.current.offset=Math.max(b.current.offset-e,0)}n(e.value),t&&(t.value=e.value,t.selectionStart=e.selectionStart,t.selectionEnd=e.selectionEnd)},v={paddingTop:o,paddingRight:o,paddingBottom:o,paddingLeft:o},w=s(t),N={margin:0,border:0,background:"none",boxSizing:"inherit",display:"inherit",fontFamily:"inherit",fontSize:"inherit",fontStyle:"inherit",fontVariantLigatures:"inherit",fontWeight:"inherit",letterSpacing:"inherit",lineHeight:"inherit",tabSize:"inherit",textIndent:"inherit",textRendering:"inherit",textTransform:"inherit",whiteSpace:"pre-wrap",wordBreak:"keep-all",overflowWrap:"break-word"};return(0,r.FD)("div",{className:p,style:{position:"relative",textAlign:"left",boxSizing:"border-box",padding:0,overflow:"hidden",...u},children:[(0,r.Y)("pre",{className:c,"aria-hidden":"true",style:{...N,...v,position:"relative",pointerEvents:"none"},dangerouslySetInnerHTML:{__html:w+"<br />"}}),(0,r.Y)("textarea",{ref:x,className:`code-editor-native-textarea${d?` ${d}`:""}`,style:{...N,...v,position:"absolute",top:0,left:0,height:"100%",width:"100%",resize:"none",color:"inherit",overflow:"hidden",WebkitTextFillColor:"transparent"},value:t,onChange:e=>{let{value:t,selectionStart:r,selectionEnd:i}=e.target,{stack:s,offset:a}=b.current,o=Date.now();s.length&&a>-1&&(b.current.stack=s.slice(0,a+1));let c=b.current.stack[b.current.offset];if(c&&o-c.timestamp<3e3){let e=/[^a-z0-9]([a-z0-9]+)$/i,s=l(c.value,c.selectionStart).pop()?.match(e),a=l(t,r).pop()?.match(e);if(s?.[1]&&a?.[1]?.startsWith(s[1])){b.current.stack[b.current.offset]={value:t,selectionStart:r,selectionEnd:i,timestamp:o},n(t);return}}b.current.stack.push({value:t,selectionStart:r,selectionEnd:i,timestamp:o}),b.current.offset=b.current.stack.length-1,n(t)},onKeyDown:e=>{if("Escape"===e.key)return void e.currentTarget.blur();let{value:t,selectionStart:r,selectionEnd:i}=e.currentTarget,s=/(Mac|iPhone|iPod|iPad)/i.test(navigator.platform),a=/Win/i.test(navigator.platform);if("Tab"===e.key&&g){if(e.preventDefault(),e.shiftKey){let e=l(t,r),n=e.length-1,s=l(t,i).length-1,a=t.split("\n").map((e,t)=>t>=n&&t<=s&&e.startsWith(" ")?e.substring(2):e).join("\n");if(a!==t){let s=e[n];k({value:a,selectionStart:s?.startsWith(" ")?r-2:r,selectionEnd:i-(t.length-a.length)})}}else if(r!==i){let e=l(t,r),n=e.length-1,s=l(t,i).length-1,a=e[n];k({value:t.split("\n").map((e,t)=>t>=n&&t<=s?" "+e:e).join("\n"),selectionStart:a&&/\S/.test(a)?r+2:r,selectionEnd:i+2*(s-n+1)})}else{let e=r+2;k({value:t.substring(0,r)+" "+t.substring(i),selectionStart:e,selectionEnd:e})}return}if("Backspace"===e.key&&r===i){if(t.substring(0,r).endsWith(" ")){e.preventDefault();let n=r-2;k({value:t.substring(0,r-2)+t.substring(i),selectionStart:n,selectionEnd:n})}return}if("Enter"===e.key&&r===i){let n=l(t,r).pop(),s=n?.match(/^\s+/);if(s?.[0]){e.preventDefault();let n="\n"+s[0],a=r+n.length;k({value:t.substring(0,r)+n+t.substring(i),selectionStart:a,selectionEnd:a})}return}if((s?e.metaKey&&"z"===e.key.toLowerCase():e.ctrlKey&&"z"===e.key.toLowerCase())&&!e.shiftKey&&!e.altKey){e.preventDefault();let{stack:t,offset:r}=b.current,i=t[r-1];i&&x.current&&(x.current.value=i.value,x.current.selectionStart=i.selectionStart,x.current.selectionEnd=i.selectionEnd,n(i.value),b.current.offset=Math.max(r-1,0));return}if((s?e.metaKey&&e.shiftKey&&"z"===e.key.toLowerCase():a?e.ctrlKey&&"y"===e.key.toLowerCase():e.ctrlKey&&e.shiftKey&&"z"===e.key.toLowerCase())&&!e.altKey){e.preventDefault();let{stack:t,offset:r}=b.current,i=t[r+1];i&&x.current&&(x.current.value=i.value,x.current.selectionStart=i.selectionStart,x.current.selectionEnd=i.selectionEnd,n(i.value),b.current.offset=Math.min(r+1,t.length-1));return}e.ctrlKey&&"m"===e.key.toLowerCase()&&(!s||e.shiftKey)&&(e.preventDefault(),y(e=>!e))},placeholder:h,readOnly:f,autoFocus:m,autoCapitalize:"off",autoComplete:"off",autoCorrect:"off",spellcheck:!1,"data-gramm":!1}),(0,r.Y)(a,{})]})}var c=n("DU"),d=n("Po");function u(e){let{value:t,onValueChange:n,language:i,wordWrap:s,rootClassName:a,gutterClassName:l,gutterStyle:u,gutterLineClassName:p,editorWrapperClassName:h,editorClassName:f,editorPadding:m=0,editorStyle:x}=e,b=s?"!whitespace-pre-wrap !break-words":"!whitespace-pre !break-normal";return(0,d.w)(),(0,r.FD)("div",{className:a,children:[(0,r.Y)("div",{className:l,style:u,children:t.split("\n").map((e,t)=>(0,r.Y)("div",{className:p,children:t+1},t+1))}),(0,r.Y)("div",{className:h,children:(0,r.Y)(o,{value:t,onValueChange:n,highlight:e=>(0,c.wY)(e,i),padding:m,textareaClassName:`focus:outline-none ${b}`,preClassName:b,style:x,className:f})})]})}},Xg(e,t,n){n.d(t,{M:()=>S});var r=n("j"),i=n("H"),s=n("X"),a=n("FL"),l=n("DU"),o=n("l"),c=n("y");let d=(0,c.A)({name:"text-align-justify",size:24,node:[["path",{d:"M3 5h18",key:"1u36vt"}],["path",{d:"M3 12h18",key:"1i2n21"}],["path",{d:"M3 19h18",key:"awlh7x"}]],aliases:["align-justify"]}),u=(0,c.A)({name:"space",size:24,node:[["path",{d:"M22 17v1c0 .5-.5 1-1 1H3c-.5 0-1-.5-1-1v-1",key:"lt2kga"}]]});var p=n("n"),h=n("z"),f=n("b0"),m=n("I"),x=n("DV"),b=n("r"),g=n("AB"),y=n("ob"),k=n("LQ");function v(e){let{filePath:t,status:n,isStaged:i,viewMode:s,ignoreWhitespace:a,additions:l,deletions:c,isLoading:v,isCopied:w,onToggleViewMode:N,onToggleWhitespace:Y,onStageUnstage:D,onDiscard:S,onOpenInEditor:F,onRefresh:z,onCopyDiff:T}=e,C=t.split("/").pop()||t,L=(0,k.R)(n,i);return(0,r.FD)("div",{className:"flex items-center justify-between px-3 py-1.5 bg-paper border-b border-ink/10 text-xs font-sans select-none flex-shrink-0 overflow-x-auto no-scrollbar",children:[(0,r.FD)("div",{className:"hidden md:flex items-center space-x-2 min-w-0 pr-2",children:[(0,r.Y)(y.o,{name:C,size:14,className:"flex-shrink-0"}),(0,r.Y)("span",{className:"font-mono text-ink font-medium truncate",title:t,children:C}),(0,r.FD)("span",{className:`px-1.5 py-0.5 text-[10px] font-mono font-semibold rounded border ${L.badgeBgClass} flex-shrink-0`,children:[L.charStatus," ",L.label]}),(l>0||c>0)&&(0,r.FD)("div",{className:"flex items-center space-x-1.5 text-[11px] font-mono flex-shrink-0 ml-1",children:[l>0&&(0,r.FD)("span",{className:"text-success font-semibold",children:["+",l]}),c>0&&(0,r.FD)("span",{className:"text-error font-semibold",children:["-",c]})]})]}),(0,r.FD)("div",{className:"flex items-center space-x-1 text-ink/70 flex-shrink-0 ml-auto",children:[(0,r.FD)("div",{className:"flex items-center bg-canvas rounded border border-ink/15 p-0.5 mr-1",children:[(0,r.FD)("button",{type:"button",onClick:N,className:`px-2 py-0.5 rounded text-[11px] font-medium flex items-center space-x-1 transition-colors ${"split"===s?"bg-paper text-ink shadow-xs":"text-ink/60 hover:text-ink"}`,title:"Split (side-by-side) Diff",children:[(0,r.Y)(o.A,{size:12}),(0,r.Y)("span",{className:"hidden sm:inline",children:"Split"})]}),(0,r.FD)("button",{type:"button",onClick:N,className:`px-2 py-0.5 rounded text-[11px] font-medium flex items-center space-x-1 transition-colors ${"unified"===s?"bg-paper text-ink shadow-xs":"text-ink/60 hover:text-ink"}`,title:"Unified Diff",children:[(0,r.Y)(d,{size:12}),(0,r.Y)("span",{className:"hidden sm:inline",children:"Unified"})]})]}),(0,r.Y)("button",{type:"button",onClick:Y,className:`p-1.5 rounded transition-colors ${a?"bg-ink/10 text-ink":"text-ink/50 hover:text-ink hover:bg-ink/5"}`,title:a?"Whitespace ignored":"Ignore whitespace",children:(0,r.Y)(u,{size:13})}),(0,r.Y)("button",{type:"button",onClick:z,className:"p-1.5 text-ink/50 hover:text-ink hover:bg-ink/5 rounded transition-colors",title:"Refresh Diff",disabled:v,children:(0,r.Y)(p.A,{size:13,className:v?"animate-spin":""})}),(0,r.Y)("button",{type:"button",onClick:T,className:"p-1.5 text-ink/50 hover:text-ink hover:bg-ink/5 rounded transition-colors",title:"Copy Diff Text",children:w?(0,r.Y)(h.A,{size:13,className:"text-success"}):(0,r.Y)(f.A,{size:13})}),!i&&(0,r.Y)("button",{type:"button",onClick:S,className:"p-1.5 text-ink/50 hover:text-error hover:bg-error/10 rounded transition-colors",title:"Discard changes in this file",children:(0,r.Y)(m.A,{size:13})}),(0,r.Y)("button",{type:"button",onClick:D,className:"px-2 py-1 text-[11px] font-medium rounded border border-ink/15 hover:bg-ink/5 flex items-center space-x-1 transition-colors text-ink",title:i?"Unstage file":"Stage file",children:i?(0,r.FD)(r.FK,{children:[(0,r.Y)(x.A,{size:12}),(0,r.Y)("span",{children:"Unstage"})]}):(0,r.FD)(r.FK,{children:[(0,r.Y)(b.A,{size:12}),(0,r.Y)("span",{children:"Stage"})]})}),F&&(0,r.FD)("button",{type:"button",onClick:F,className:"px-2 py-1 text-[11px] font-medium rounded bg-ink text-canvas hover:bg-ink/90 flex items-center space-x-1 transition-colors shadow-xs ml-1",title:"Open in Code Editor",children:[(0,r.Y)(g.A,{size:12}),(0,r.Y)("span",{className:"hidden sm:inline",children:"Edit File"})]})]})]})}var w=n("Po");function N(e){let{lines:t,language:n}=e,s=(0,w.w)(),a=(0,i.Kr)(()=>{let e=Array(t.length).fill(""),r=-1,i=i=>{if(r<0)return;let s=(0,l.O9)(t.slice(r,i).map(e=>e.text).join("\n"),n);for(let t=r;t<i;t++)e[t]=s[t-r]??"";r=-1};return t.forEach((e,t)=>{"meta"===e.type?i(t):r<0&&(r=t)}),i(t.length),e},[t,n,s]);return 0===t.length?(0,r.Y)("div",{className:"flex-1 flex items-center justify-center text-ink/40 font-mono text-xs p-8",children:"No differences between working file and target."}):(0,r.Y)("div",{className:"w-full h-full overflow-auto font-mono text-xs select-text bg-paper text-ink code-surface",children:(0,r.Y)("table",{className:"w-max min-w-full border-collapse",children:(0,r.Y)("tbody",{children:t.map((e,t)=>{if("meta"===e.type)return(0,r.FD)("tr",{className:"bg-canvas border-y border-ink/10 text-meta select-none",children:[(0,r.Y)("td",{className:"w-12 text-center py-1 px-2 text-[10px] opacity-60 border-r border-ink/10 font-mono",children:"..."}),(0,r.Y)("td",{className:"w-12 text-center py-1 px-2 text-[10px] opacity-60 border-r border-ink/10 font-mono",children:"..."}),(0,r.Y)("td",{className:"py-1 px-3 text-[11px] font-semibold text-meta font-mono tracking-tight",children:e.text})]},t);let n="add"===e.type,i="del"===e.type;return(0,r.FD)("tr",{className:`transition-colors group ${n?"bg-success/10 hover:bg-success/15":i?"bg-error/10 hover:bg-error/15":"hover:bg-ink/[0.03]"}`,children:[(0,r.Y)("td",{className:`w-12 py-0.5 px-2 text-right text-[10px] select-none border-r border-ink/10 font-mono ${i?"bg-error/15 text-error font-semibold":n?"bg-success/15 text-success/70":"bg-canvas/50 text-ink/40"}`,children:e.oldLineNumber||""}),(0,r.Y)("td",{className:`w-12 py-0.5 px-2 text-right text-[10px] select-none border-r border-ink/10 font-mono ${n?"bg-success/15 text-success font-semibold":i?"bg-error/15 text-error/70":"bg-canvas/50 text-ink/40"}`,children:e.newLineNumber||""}),(0,r.FD)("td",{className:"py-0.5 px-3 whitespace-pre text-ink pr-6",children:[(0,r.Y)("span",{className:`inline-block w-4 text-center select-none font-bold ${n?"text-success":i?"text-error":"opacity-0"}`,children:n?"+":i?"-":" "}),(0,r.Y)("span",{className:"shiki",dangerouslySetInnerHTML:{__html:e.text&&a[t]||"&nbsp;"}})]})]},t)})})})})}function Y(e){let{rows:t,language:n}=e,s=(0,w.w)(),a=(0,i.Kr)(()=>{let e=Array(t.length).fill(""),r=Array(t.length).fill(""),i=-1,s=s=>{if(i<0)return;let a=t.slice(i,s),o=(0,l.O9)(a.map(e=>e.left?.text??"").join("\n"),n),c=(0,l.O9)(a.map(e=>e.right?.text??"").join("\n"),n);for(let t=i;t<s;t++)e[t]=o[t-i]??"",r[t]=c[t-i]??"";i=-1};return t.forEach((e,t)=>{e.isMeta?s(t):i<0&&(i=t)}),s(t.length),{left:e,right:r}},[t,n,s]);return 0===t.length?(0,r.Y)("div",{className:"flex-1 flex items-center justify-center text-ink/40 font-mono text-xs p-8",children:"No differences between working file and target."}):(0,r.Y)("div",{className:"w-full h-full overflow-auto font-mono text-xs select-text bg-paper text-ink code-surface",children:(0,r.FD)("div",{className:"w-max min-w-full min-w-[700px]",children:[(0,r.FD)("div",{className:"grid grid-cols-2 sticky top-0 z-10 bg-canvas border-b border-ink/10 text-[11px] text-ink/70 font-sans select-none shadow-xs",children:[(0,r.Y)("div",{className:"px-3 py-1 font-medium border-r border-ink/10 flex items-center justify-between",children:(0,r.Y)("span",{children:"Original (HEAD / Base)"})}),(0,r.Y)("div",{className:"px-3 py-1 font-medium flex items-center justify-between",children:(0,r.Y)("span",{children:"Modified (Working Tree)"})})]}),(0,r.Y)("table",{className:"w-full border-collapse",children:(0,r.Y)("tbody",{children:t.map((e,t)=>{if(e.isMeta)return(0,r.Y)("tr",{className:"bg-canvas border-y border-ink/10 text-meta select-none",children:(0,r.Y)("td",{colSpan:4,className:"py-1 px-3 text-[11px] font-semibold text-meta text-center font-mono",children:e.metaText})},t);let n=e.left,i=e.right,s=n?.type==="del",l=i?.type==="add";return(0,r.FD)("tr",{className:"border-b border-ink/[0.04]",children:[(0,r.Y)("td",{className:`w-12 py-0.5 px-2 text-right text-[10px] select-none border-r border-ink/10 font-mono ${s?"bg-error/15 text-error font-semibold":n?"bg-canvas/50 text-ink/40":"bg-canvas/80 text-transparent"}`,children:n?.lineNumber||""}),(0,r.Y)("td",{className:`min-w-[320px] py-0.5 px-2 whitespace-pre border-r border-ink/10 text-ink ${s?"bg-error/10":n?"hover:bg-ink/[0.02]":"bg-canvas/40 select-none"}`,children:n?(0,r.FD)(r.FK,{children:[(0,r.Y)("span",{className:`inline-block w-4 text-center select-none font-bold ${s?"text-error":"opacity-0"}`,children:s?"-":" "}),(0,r.Y)("span",{className:"shiki",dangerouslySetInnerHTML:{__html:n.text&&a.left[t]||"&nbsp;"}})]}):(0,r.Y)("span",{className:"opacity-0",children:" "})}),(0,r.Y)("td",{className:`w-12 py-0.5 px-2 text-right text-[10px] select-none border-r border-ink/10 font-mono ${l?"bg-success/15 text-success font-semibold":i?"bg-canvas/50 text-ink/40":"bg-canvas/80 text-transparent"}`,children:i?.lineNumber||""}),(0,r.Y)("td",{className:`min-w-[320px] py-0.5 px-2 whitespace-pre text-ink ${l?"bg-success/10":i?"hover:bg-ink/[0.02]":"bg-canvas/40 select-none"}`,children:i?(0,r.FD)(r.FK,{children:[(0,r.Y)("span",{className:`inline-block w-4 text-center select-none font-bold ${l?"text-success":"opacity-0"}`,children:l?"+":" "}),(0,r.Y)("span",{className:"shiki",dangerouslySetInnerHTML:{__html:i.text&&a.right[t]||"&nbsp;"}})]}):(0,r.Y)("span",{className:"opacity-0",children:" "})})]},t)})})})]})})}var D=n("p");function S(e){let{filePath:t,status:n="M",isStaged:o=!1,root:c,repo:d=".",onOpenInEditor:u,onFileSaved:h,className:f=""}=e,[m,x]=(0,a.LO)("diff.viewMode","unified"),[b,g]=(0,a.LO)("diff.ignoreWhitespace",!1),[y,k]=(0,i.J0)(""),[w,S]=(0,i.J0)(n),[F,z]=(0,i.J0)(o),[T,C]=(0,i.J0)(!0),[L,j]=(0,i.J0)(null),[A,M]=(0,i.J0)(!1),[E,W]=(0,i.J0)(!1),$=(0,s.L)(),J=(0,i.hb)(async()=>{if(t){C(!0),j(null);try{let e=new URLSearchParams({fileDiff:"1",file:t,staged:F?"1":"0",repo:d||"."});(n||w)&&e.set("status",n||w),c&&e.set("root",c);let r=await fetch(`/api/fs/git?${e.toString()}`),i=await r.json();i.success?(k(i.diff||""),i.status&&S(i.status)):j(i.error||"Failed to load file diff")}catch(e){j(e.message||"Error fetching diff")}finally{C(!1)}}},[t,F,d,c]);(0,i.vJ)(()=>{S(n),z(o)},[n,o,t]),(0,i.vJ)(()=>{J()},[J]);let K=function(e){let t=arguments.length>1&&void 0!==arguments[1]&&arguments[1],n=[],r=[],i=0,s=0;if(!e||!e.trim())return{lines:n,splitRows:r,additions:i,deletions:s};let a=e.split(/\r?\n/),l=a.some(e=>e.startsWith("@@"))?a:[`@@ -1,${a.length} +1,${a.length} @@`,...a],o=1,c=1,d=[],u=[],p=()=>{let e=Math.max(d.length,u.length);for(let t=0;t<e;t++)r.push({left:d[t]?{type:"del",text:d[t].text,lineNumber:d[t].lineNumber}:void 0,right:u[t]?{type:"add",text:u[t].text,lineNumber:u[t].lineNumber}:void 0});d=[],u=[]};for(let e of l)if(!(e.startsWith("diff --git")||e.startsWith("index ")||e.startsWith("---")||e.startsWith("+++")||e.startsWith("new file mode")||e.startsWith("deleted file mode")||e.startsWith("similarity index")||e.startsWith("old mode")||e.startsWith("new mode")||e.startsWith("\\ No newline"))){if(e.startsWith("@@")){p();let t=e.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);t&&(o=parseInt(t[1],10),c=parseInt(t[2],10)),n.push({type:"meta",text:e}),r.push({isMeta:!0,metaText:e});continue}if(e.startsWith("+")){let r=e.slice(1),s=t?r.trim():r;i++,n.push({type:"add",text:s,newLineNumber:c}),u.push({text:s,lineNumber:c}),c++}else if(e.startsWith("-")){let r=e.slice(1),i=t?r.trim():r;s++,n.push({type:"del",text:i,oldLineNumber:o}),d.push({text:i,lineNumber:o}),o++}else{p();let i=e.startsWith(" ")?e.slice(1):e,s=t?i.trim():i;n.push({type:"context",text:s,oldLineNumber:o,newLineNumber:c}),r.push({left:{type:"context",text:s,lineNumber:o},right:{type:"context",text:s,lineNumber:c}}),o++,c++}}return p(),{lines:n,splitRows:r,additions:i,deletions:s}}(y,b),O=(0,l.sQ)(t);return(0,r.FD)("div",{className:`flex flex-col h-full bg-paper select-text relative font-sans ${f}`,children:[(0,r.Y)(v,{filePath:t,status:w,isStaged:F,viewMode:m,ignoreWhitespace:b,additions:K.additions,deletions:K.deletions,isLoading:T,isCopied:A,onToggleViewMode:()=>{x("unified"===m?"split":"unified")},onToggleWhitespace:()=>{g(!b)},onStageUnstage:()=>{let e=F?"unstage":"stage",n=new FormData;n.set("actionType",e),n.set("file",t),n.set("repo",d||"."),c&&n.set("root",c),$.submit(n,{method:"POST",action:"/api/fs/git"}),z(!F),setTimeout(()=>{J(),h?.()},300)},onDiscard:()=>{W(!0)},onOpenInEditor:u,onRefresh:J,onCopyDiff:()=>{y&&(navigator.clipboard.writeText(y),M(!0),setTimeout(()=>M(!1),2e3))}}),(0,r.Y)("div",{className:"flex-1 overflow-hidden relative flex flex-col bg-paper",children:T?(0,r.FD)("div",{className:"flex-1 flex items-center justify-center space-x-2 text-ink/50 text-xs font-mono",children:[(0,r.Y)(p.A,{size:14,className:"animate-spin"}),(0,r.Y)("span",{children:"Loading file diff..."})]}):L?(0,r.FD)("div",{className:"flex-1 flex flex-col items-center justify-center p-6 text-center space-y-2",children:[(0,r.Y)(D.A,{size:20,className:"text-error"}),(0,r.Y)("p",{className:"text-xs text-error font-medium",children:L}),(0,r.Y)("button",{onClick:J,className:"px-3 py-1 text-xs bg-ink/10 hover:bg-ink/15 text-ink rounded font-sans transition-colors",children:"Retry"})]}):"split"===m?(0,r.Y)(Y,{rows:K.splitRows,language:O}):(0,r.Y)(N,{lines:K.lines,language:O})}),E&&(0,r.Y)("div",{className:"fixed inset-0 bg-ink/20 z-50 flex items-center justify-center p-4 backdrop-blur-xs",children:(0,r.FD)("div",{className:"bg-paper rounded-lg shadow-xl p-4 w-full max-w-sm border border-ink/15",children:[(0,r.Y)("h3",{className:"font-semibold text-ink mb-1.5 text-sm",children:"Discard Changes?"}),(0,r.FD)("p",{className:"text-xs text-ink/70 mb-4",children:["Are you sure you want to discard all unstaged changes in ",(0,r.Y)("span",{className:"font-mono font-medium text-ink",children:t}),"? This cannot be undone."]}),(0,r.FD)("div",{className:"flex justify-end space-x-2",children:[(0,r.Y)("button",{type:"button",onClick:()=>W(!1),className:"px-3 py-1.5 text-xs text-ink hover:bg-ink/5 rounded transition-colors",children:"Cancel"}),(0,r.Y)("button",{type:"button",onClick:()=>{W(!1);let e=new FormData;e.set("actionType","discard"),e.set("file",t),e.set("repo",d||"."),c&&e.set("root",c),$.submit(e,{method:"POST",action:"/api/fs/git"}),setTimeout(()=>{J(),h?.()},300)},className:"px-3 py-1.5 text-xs bg-error text-white rounded hover:bg-error/90 font-medium transition-colors",children:"Discard Changes"})]})]})})]})}},XY(e,t,n){var r=n("H"),i=n("DU");function s(e){return String(e.id??e.path??e.name)}function a(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},{fallback:n,reportLoadError:a=!1,flushOnUnmount:l=!1,reportSaveError:o=!1,downloadMimeType:c="text/plain",onFileSaved:d}=t,[u,p]=(0,r.J0)({}),[h,f]=(0,r.J0)({}),[m,x]=(0,r.J0)(!1),[b,g]=(0,r.J0)(null),[y,k]=(0,r.J0)("idle"),[v,w]=(0,r.J0)(!1),N=e?s(e):"",Y=e?u[N]??e.content??"":"",D=Y!==(h[N]??Y),S=(0,r.li)(new Set),F=(0,r.li)(null),z=(0,r.hb)((e,t)=>{p(n=>({...n,[e]:t})),f(n=>({...n,[e]:t}))},[]),T=(0,r.hb)(async(e,t)=>{if(!e.path)return;k("saving");let n=new FormData;n.append("actionType","save"),n.append("path",e.path),n.append("content",t),e.root&&n.append("root",e.root),e.repo&&"."!==e.repo&&n.append("repo",e.repo);try{let r=await fetch("/api/fs/action",{method:"POST",body:n}),i=await r.json().catch(()=>null);r.ok&&i?.success?(k("saved"),f(n=>({...n,[s(e)]:t})),d?.()):k(o?"error":"idle")}catch{k(o?"error":"idle")}},[d,o]),C=(0,r.li)(T);C.current=T,(0,r.vJ)(()=>{if(!e)return;let t=s(e);if(S.current.has(t))return;if(S.current.add(t),void 0!==e.content){z(t,e.content),g(null);return}if(!e.path)return a?void g("No file path supplied for this attachment."):void z(t,n?n(e.name):"");let r=!1;x(!0),g(null);let i=new URLSearchParams({path:e.path});return e.root&&i.set("root",e.root),e.repo&&"."!==e.repo&&i.set("repo",e.repo),fetch(`/api/fs/read?${i.toString()}`).then(async e=>{let t=await e.json().catch(()=>null);if(a){if(!e.ok||!t||"string"!=typeof t.content)throw Error(t?.error||`HTTP ${e.status}`);return t.content}return"string"==typeof t?.content?t.content:null}).then(i=>{r||z(t,i??(n?n(e.name):""))}).catch(i=>{r||(a?(z(t,""),g(i instanceof Error?i.message:"Failed to read file")):z(t,n?n(e.name):""))}).finally(()=>{r||x(!1)}),()=>{r=!0}},[N,e,n,a,z]),(0,r.vJ)(()=>{if("saved"!==y)return;let e=window.setTimeout(()=>k("idle"),1500);return()=>window.clearTimeout(e)},[y]);let L=(0,r.hb)(t=>{if(!e)return;let n=s(e);if(p(e=>({...e,[n]:t})),!e.path)return;F.current&&window.clearTimeout(F.current.timer);let r=window.setTimeout(()=>{F.current=null,C.current(e,t)},800);F.current={timer:r,file:e,content:t}},[e]),j=(0,r.hb)(()=>{if(!e)return;let t=u[s(e)];void 0!==t&&(F.current&&(window.clearTimeout(F.current.timer),F.current=null),C.current(e,t))},[e,u]),A=(0,r.hb)(()=>{e&&navigator.clipboard&&navigator.clipboard.writeText(u[s(e)]??"").then(()=>{w(!0),window.setTimeout(()=>w(!1),1500)}).catch(()=>{})},[e,u]),M=(0,r.hb)(()=>{if(!e)return;let t=new Blob([u[s(e)]??""],{type:c}),n=URL.createObjectURL(t),r=document.createElement("a");r.href=n,r.download=e.name,r.click(),URL.revokeObjectURL(n)},[e,u,c]),E=(0,r.hb)(()=>{N&&(S.current.delete(N),p(e=>{if(void 0===e[N])return e;let t={...e};return delete t[N],t}))},[N]),W=(0,r.hb)(()=>{S.current.clear(),p({}),f({})},[]),$=(0,r.hb)(e=>{let t=new Set(e.map(String));for(let e of Array.from(S.current))t.has(e)||S.current.delete(e);p(e=>{let n=Object.keys(e);if(n.every(e=>t.has(e)))return e;let r={};for(let i of n)t.has(i)&&(r[i]=e[i]);return r})},[]);return(0,r.vJ)(()=>{if(l)return()=>{let e=F.current;e&&(window.clearTimeout(e.timer),F.current=null,C.current(e.file,e.content))}},[l]),{content:Y,onChange:L,isLoading:m,loadError:b,saveStatus:y,copied:v,isDirty:D,language:e?(0,i.sQ)(e.name):"javascript",saveNow:j,copy:A,download:M,reload:E,reset:W,retain:$}}n.d(t,{n:()=>a})},Soe(e,t,n){let r=(0,n("y").A)({name:"eye-off",size:24,node:[["path",{d:"M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49",key:"ct8e1f"}],["path",{d:"M14.084 14.158a3 3 0 0 1-4.242-4.242",key:"151rxh"}],["path",{d:"M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143",key:"13bj9a"}],["path",{d:"m2 2 20 20",key:"1ooewy"}]]});n.d(t,{},{A:r})},DV(e,t,n){let r=(0,n("y").A)({name:"minus",size:24,node:[["path",{d:"M5 12h14",key:"1ays0h"}]]});n.d(t,{},{A:r})},yh(e,t,n){let r=(0,n("y").A)({name:"text-wrap",size:24,node:[["path",{d:"m16 16-3 3 3 3",key:"117b85"}],["path",{d:"M3 12h14.5a1 1 0 0 1 0 7H13",key:"18xa6z"}],["path",{d:"M3 19h6",key:"1ygdsz"}],["path",{d:"M3 5h18",key:"1u36vt"}]],aliases:["wrap-text"]});n.d(t,{},{A:r})},na(e,t,n){let r=(0,n("y").A)({name:"zoom-in",size:24,node:[["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}],["line",{x1:"21",x2:"16.65",y1:"21",y2:"16.65",key:"13gj7c"}],["line",{x1:"11",x2:"11",y1:"8",y2:"14",key:"1vmskp"}],["line",{x1:"8",x2:"14",y1:"11",y2:"11",key:"durymu"}]]});n.d(t,{},{A:r})},hj(e,t,n){let r=(0,n("y").A)({name:"zoom-out",size:24,node:[["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}],["line",{x1:"21",x2:"16.65",y1:"21",y2:"16.65",key:"13gj7c"}],["line",{x1:"8",x2:"14",y1:"11",y2:"11",key:"durymu"}]]});n.d(t,{},{A:r})}}]);