ompchamber 3.1.1 → 3.3.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/dist/client/index.html +1 -1
- package/dist/client/index.js +306 -298
- package/dist/client/static/js/index-bsxxt89j.css +1 -0
- package/dist/client/static/js/{index-qhzsybkn.js → index-qkt4z7x0.js} +658 -658
- package/package.json +4 -3
- package/public/sw.js +54 -5
- package/src/cli/lib/output.js +15 -8
- package/src/client/components/common/Modal.tsx +14 -4
- package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
- package/src/client/components/layout/session-sidebar/index.tsx +4 -2
- package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
- package/src/client/components/mobile/SessionSidebar.tsx +4 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +27 -8
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +67 -29
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.test.ts +84 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +59 -12
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
- package/src/client/components/settings/Modal.tsx +45 -23
- package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
- package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
- package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
- package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
- package/src/client/components/settings/categories/McpSettings.tsx +48 -31
- package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
- package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
- package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
- package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
- package/src/client/components/settings/categories/appearance-settings/EditorFontSection.tsx +78 -0
- package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
- package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
- package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
- package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
- package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
- package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/master-detail/index.tsx +68 -0
- package/src/client/components/workspace/SearchPanel.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +19 -12
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
- package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
- package/src/client/components/workspace/diff-panel/index.tsx +36 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
- package/src/client/components/workspace/editor/index.tsx +15 -7
- package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
- package/src/client/components/workspace/terminal-panel/KeyBar.tsx +194 -0
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +24 -0
- package/src/client/components/workspace/terminal-panel/index.tsx +98 -3
- package/src/client/components/workspace/terminal-panel/mount.ts +47 -0
- package/src/client/data/theme/terminal.ts +25 -8
- package/src/client/hooks/chat/omp/session-list.tsx +31 -1
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
- package/src/client/hooks/chat/subagent/index.ts +37 -11
- package/src/client/hooks/editor/font.ts +53 -0
- package/src/client/hooks/editor/use-file-editor.ts +1 -1
- package/src/client/hooks/settings/master-detail.ts +33 -0
- package/src/client/hooks/ui/clipboard.ts +17 -0
- package/src/client/hooks/ui/keyboard-inset.test.ts +46 -0
- package/src/client/hooks/ui/keyboard-inset.ts +113 -0
- package/src/client/hooks/ui/text-overflow.test.ts +152 -0
- package/src/client/hooks/ui/text-overflow.ts +78 -0
- package/src/client/hooks/ui/touch-device.ts +46 -0
- package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
- package/src/client/main.tsx +1 -0
- package/src/server/index.ts +4 -3
- package/src/server/lib/assets/conditional.server.test.ts +74 -0
- package/src/server/lib/assets/conditional.server.ts +54 -0
- package/src/server/lib/assets/dev-assets.server.test.ts +44 -0
- package/src/server/lib/assets/dev-assets.server.ts +121 -0
- package/src/server/lib/assets/fonts.server.ts +9 -4
- package/src/server/lib/bundler/build-errors.server.ts +136 -0
- package/src/server/lib/bundler/build-errors.test.ts +103 -0
- package/src/server/lib/bundler/dependencies.test.ts +134 -0
- package/src/server/lib/db/client.ts +5 -1
- package/src/server/lib/fs/git-diff.ts +34 -10
- package/src/server/lib/fs/git-repos.ts +81 -0
- package/src/server/lib/lifecycle/flock.ts +199 -0
- package/src/server/lib/lifecycle/identity.ts +5 -1
- package/src/server/lib/lifecycle/listener.ts +29 -0
- package/src/server/lib/lifecycle/port-guard.ts +18 -3
- package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
- package/src/server/lib/lifecycle/proc/linux.ts +29 -7
- package/src/server/lib/lifecycle/proc/ps.ts +7 -0
- package/src/server/lib/lifecycle/proc/types.ts +17 -0
- package/src/server/lib/lifecycle/proc/win32.ts +5 -0
- package/src/server/lib/observer-ws.ts +150 -0
- package/src/server/lib/omp/rpc/constants.ts +9 -0
- package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
- package/src/server/lib/omp/session/file-scan.ts +7 -1
- package/src/server/lib/omp/session/files.ts +95 -96
- package/src/server/lib/omp/session/locator.ts +18 -8
- package/src/server/lib/omp/session/messages.ts +7 -7
- package/src/server/lib/omp/session/scan.ts +108 -0
- package/src/server/lib/omp/session/stats.ts +50 -50
- package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
- package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
- package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
- package/src/server/lib/terminal/runtime.server.ts +3 -0
- package/src/server/lib/terminal/session.server.ts +30 -3
- package/src/server/plugins/shell.server.ts +52 -13
- package/src/server/plugins/ssr.ts +43 -7
- package/src/server/plugins/static.ts +51 -18
- package/src/server/routes/agent/command.test.ts +69 -0
- package/src/server/routes/agent/command.ts +14 -0
- package/src/server/routes/agent/ws.test.ts +159 -0
- package/src/server/routes/agent/ws.ts +22 -97
- package/src/server/routes/btw/ws.ts +22 -95
- package/src/server/routes/fs/git.ts +61 -70
- package/src/server/routes/omp/session-stats.ts +1 -1
- package/src/server/routes/telemetry/tokens.ts +6 -4
- package/src/server/routes/terminal/run.ts +4 -8
- package/src/server/routes/terminal/ws.ts +24 -2
- package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
- package/src/shared/lib/chat/omp/agent-events.ts +10 -0
- package/src/shared/lib/code/editor/typography.test.ts +93 -0
- package/src/shared/lib/code/editor/typography.ts +86 -0
- package/src/shared/lib/code/language.ts +146 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
- package/src/shared/lib/code/syntax-highlight.ts +0 -122
- package/src/shared/lib/fonts/NOTICE.md +44 -0
- package/src/shared/lib/fonts/nerd-symbols.css +55 -0
- package/src/shared/lib/fonts/nerd-symbols.test.ts +134 -0
- package/src/shared/lib/fonts/symbols-nerd-font-mono.woff2 +0 -0
- package/src/shared/lib/fs/diff-parser.test.ts +5 -5
- package/src/shared/lib/fs/diff-parser.ts +67 -42
- package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
- package/src/shared/lib/omp/subagent/parse.ts +17 -0
- package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
- package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
- package/src/shared/lib/settings/diff.test.ts +1 -1
- package/src/shared/lib/workspace/terminal/keys.test.ts +189 -0
- package/src/shared/lib/workspace/terminal/keys.ts +225 -0
- package/src/shared/types/omp/subagent.ts +6 -0
- package/src/shared/types/settings/state.ts +7 -16
- package/dist/client/static/js/index-5yfre1kw.css +0 -1
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Font files served straight from the installed packages
|
|
7
|
+
* Font files served straight from the installed packages — plus the one the
|
|
8
|
+
* chamber vendors itself (`src/shared/lib/fonts`, the Nerd Font symbols face).
|
|
8
9
|
*
|
|
9
10
|
* The client stylesheet reaches its `@font-face` sources through the bundler's
|
|
10
11
|
* CSS loader (`lib/bundler/css.ts`), which rewrites every one of them to
|
|
11
|
-
* `<FONT_ROUTE_PREFIX><basename>`. Nothing
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* `<FONT_ROUTE_PREFIX><basename>`. Nothing is copied at build time: a font is
|
|
13
|
+
* addressed by its own basename, and this module resolves that basename back to
|
|
14
|
+
* the directory that ships it.
|
|
14
15
|
*
|
|
15
16
|
* A directory index rather than a path built from the request, because the
|
|
16
17
|
* request must never choose a directory. The index is built once, lazily, by
|
|
@@ -35,6 +36,10 @@ export const FONT_ROUTE_PREFIX = '/fonts/';
|
|
|
35
36
|
const FONT_SOURCES = [
|
|
36
37
|
'node_modules/katex/dist/fonts',
|
|
37
38
|
'node_modules/@fontsource/fira-code/files',
|
|
39
|
+
// The one font the chamber vendors itself rather than borrowing from a
|
|
40
|
+
// package: the Nerd Font symbols face the terminal falls back to. See
|
|
41
|
+
// `src/shared/lib/fonts/nerd-symbols.css`.
|
|
42
|
+
'src/shared/lib/fonts',
|
|
38
43
|
] as const;
|
|
39
44
|
|
|
40
45
|
/**
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Why the shell's bundle failed, in the terms the person who broke it needs.
|
|
8
|
+
*
|
|
9
|
+
* Bun renders an HTML route by bundling it. When the bundle fails, the route
|
|
10
|
+
* answers with Bun's own "Build Failed" page, which holds the file, line,
|
|
11
|
+
* message and offending source line — but only inside a JavaScript payload the
|
|
12
|
+
* page decodes at runtime (`Uint8Array.from(atob(…))`, a private binary format
|
|
13
|
+
* with no server-side reader). Scraping that would be a bet on Bun's internals.
|
|
14
|
+
*
|
|
15
|
+
* `Bun.build` is the supported way to the same facts: a probe over the same
|
|
16
|
+
* entrypoint with the same plugins returns `logs` carrying `message` and
|
|
17
|
+
* `position {file,line,column,lineText}`. What it reports is what `Bun.build`
|
|
18
|
+
* rejects, and that set covers every shape the route 500s on — measured against
|
|
19
|
+
* the route on Bun 1.4.2, an unresolved import that survives tree shaking and a
|
|
20
|
+
* syntax error both fail the route and are both reported here. It is not a
|
|
21
|
+
* subset of the route's failures, and deliberately so: a missing named export is
|
|
22
|
+
* answered `200` by the route while Bun serves a 104-byte stub that only calls
|
|
23
|
+
* `location.reload()` in place of the bundle, so the page cannot run. That is a
|
|
24
|
+
* real failure of the bundle and is reported as one.
|
|
25
|
+
*
|
|
26
|
+
* `target: 'browser'` because that is the bundle the page is served; `bun`
|
|
27
|
+
* agreed with it on every case measured.
|
|
28
|
+
*
|
|
29
|
+
* Runs only after the shell has already failed, so its cost lands on a page that
|
|
30
|
+
* is broken anyway and never on a healthy request.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { join, relative } from 'path';
|
|
34
|
+
import type { BuildOutput, BunPlugin } from 'bun';
|
|
35
|
+
|
|
36
|
+
import { packageRoot } from '@/server/lib/assets/fonts.server';
|
|
37
|
+
|
|
38
|
+
/** The shell bundle's entrypoint, relative to the package root. */
|
|
39
|
+
const SHELL_ENTRYPOINT = 'index.html';
|
|
40
|
+
|
|
41
|
+
/** Bun's own config file, and the section naming the bundle's plugins. */
|
|
42
|
+
const BUNFIG = 'bunfig.toml';
|
|
43
|
+
|
|
44
|
+
type BundleLog = BuildOutput['logs'][number];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The plugin list Bun uses for this bundle, read from the file Bun reads.
|
|
48
|
+
*
|
|
49
|
+
* The probe has to bundle what the route bundles: a plugin missing here would
|
|
50
|
+
* make it describe a build nobody is served — or succeed where the route fails,
|
|
51
|
+
* which reports nothing at all.
|
|
52
|
+
*/
|
|
53
|
+
async function bundlePlugins(root: string): Promise<BunPlugin[]> {
|
|
54
|
+
const path = join(root, BUNFIG);
|
|
55
|
+
if (!(await Bun.file(path).exists())) return [];
|
|
56
|
+
|
|
57
|
+
let configured: unknown;
|
|
58
|
+
try {
|
|
59
|
+
const config = Bun.TOML.parse(await Bun.file(path).text()) as {
|
|
60
|
+
serve?: { static?: { plugins?: unknown } };
|
|
61
|
+
};
|
|
62
|
+
configured = config.serve?.static?.plugins;
|
|
63
|
+
} catch {
|
|
64
|
+
// A config Bun cannot parse is the build's own error to report.
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
if (!Array.isArray(configured)) return [];
|
|
68
|
+
|
|
69
|
+
const plugins: BunPlugin[] = [];
|
|
70
|
+
for (const entry of configured) {
|
|
71
|
+
if (typeof entry !== 'string') continue;
|
|
72
|
+
// A relative path is resolved from the package root; a bare specifier is
|
|
73
|
+
// left to the resolver, exactly as Bun reads it. Dynamic because the
|
|
74
|
+
// specifier is whatever the config names, not a module known here.
|
|
75
|
+
const specifier = entry.startsWith('.') ? join(root, entry) : entry;
|
|
76
|
+
try {
|
|
77
|
+
const module = (await import(specifier)) as { default?: BunPlugin };
|
|
78
|
+
if (module.default) plugins.push(module.default);
|
|
79
|
+
} catch {
|
|
80
|
+
// Same: an unimportable plugin surfaces through the build, not here.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return plugins;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A path as the reader knows it: relative to the package root, falling back to
|
|
88
|
+
* the absolute path when the file is not under it. The fallback matters on
|
|
89
|
+
* macOS, where `tmpdir()` is `/var/…` while the same file resolves to
|
|
90
|
+
* `/private/var/…` — a lexically-relative path there is `../../../private/var/…`,
|
|
91
|
+
* which is longer than what it replaces.
|
|
92
|
+
*/
|
|
93
|
+
function displayPath(file: string, root: string): string {
|
|
94
|
+
const relativePath = relative(root, file);
|
|
95
|
+
return relativePath && !relativePath.startsWith('..') ? relativePath : file;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** One error as `file:line:column message` plus the offending source line. */
|
|
99
|
+
function formatLog(log: BundleLog, root: string): string {
|
|
100
|
+
const position = log.position;
|
|
101
|
+
if (!position?.file) return log.message;
|
|
102
|
+
const where = `${displayPath(position.file, root)}:${position.line}:${position.column}`;
|
|
103
|
+
const source = position.lineText.trim();
|
|
104
|
+
// The source line travels with the position: a file and column alone still
|
|
105
|
+
// leave the reader hunting for the edit.
|
|
106
|
+
return source ? `${where} ${log.message}\n ${source}` : `${where} ${log.message}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The bundle errors behind a shell that would not render, or null when this
|
|
111
|
+
* build is not the reason (a clean bundle means the failure lies elsewhere).
|
|
112
|
+
*/
|
|
113
|
+
export async function describeShellBuildFailure(root = packageRoot()): Promise<string | null> {
|
|
114
|
+
const entrypoint = join(root, SHELL_ENTRYPOINT);
|
|
115
|
+
if (!(await Bun.file(entrypoint).exists())) return null;
|
|
116
|
+
|
|
117
|
+
let result: BuildOutput;
|
|
118
|
+
try {
|
|
119
|
+
result = await Bun.build({
|
|
120
|
+
entrypoints: [entrypoint],
|
|
121
|
+
target: 'browser',
|
|
122
|
+
plugins: await bundlePlugins(root),
|
|
123
|
+
// `throw: false` is what makes the failures readable: it returns them as
|
|
124
|
+
// `logs` instead of rejecting with an AggregateError, and keeps the probe
|
|
125
|
+
// from printing a second copy of an error Bun has already printed.
|
|
126
|
+
throw: false,
|
|
127
|
+
});
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return `Rebuilding the client bundle failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
130
|
+
}
|
|
131
|
+
if (result.success) return null;
|
|
132
|
+
|
|
133
|
+
const errors = result.logs.filter((log) => log.level === 'error');
|
|
134
|
+
if (errors.length === 0) return null;
|
|
135
|
+
return errors.map((log) => formatLog(log, root)).join('\n');
|
|
136
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
7
|
+
import { mkdtempSync, rmSync } from 'fs';
|
|
8
|
+
import { tmpdir } from 'os';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
|
|
11
|
+
import { describeShellBuildFailure } from '@/server/lib/bundler/build-errors.server';
|
|
12
|
+
|
|
13
|
+
let root: string;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
root = mkdtempSync(join(tmpdir(), 'omp-build-errors-'));
|
|
17
|
+
Bun.write(join(root, 'index.html'), '<!doctype html><html><body><script type="module" src="./entry.ts"></script></body></html>');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
rmSync(root, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('describeShellBuildFailure', () => {
|
|
25
|
+
test('names the file, position and message of a syntax error', async () => {
|
|
26
|
+
await Bun.write(join(root, 'entry.ts'), 'const a = 1;\nexport const broken = ;\n');
|
|
27
|
+
|
|
28
|
+
const detail = await describeShellBuildFailure(root);
|
|
29
|
+
|
|
30
|
+
expect(detail).toContain('entry.ts:2:23');
|
|
31
|
+
expect(detail).toContain('Unexpected ;');
|
|
32
|
+
// The offending source line travels with the message: a file and column
|
|
33
|
+
// alone still leave the reader hunting for the edit.
|
|
34
|
+
expect(detail).toContain('export const broken = ;');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('reports an unresolved import that the bundle would use', async () => {
|
|
38
|
+
await Bun.write(join(root, 'entry.ts'), 'import { a } from "./nowhere";\nconsole.log(a);\n');
|
|
39
|
+
|
|
40
|
+
expect(await describeShellBuildFailure(root)).toContain('Could not resolve: "./nowhere"');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('reports a missing named export, which the route serves as a reload stub', async () => {
|
|
44
|
+
// The route answers 200 for this, so a status-only check calls it healthy —
|
|
45
|
+
// but Bun replaces the bundle with a 104-byte stub that only calls
|
|
46
|
+
// `location.reload()`, so the page cannot run. It is a real failure of the
|
|
47
|
+
// bundle and is reported as one.
|
|
48
|
+
await Bun.write(join(root, 'dep.ts'), 'export const a = 1;\n');
|
|
49
|
+
await Bun.write(join(root, 'entry.ts'), 'import { zzz } from "./dep";\nconsole.log(zzz);\n');
|
|
50
|
+
|
|
51
|
+
expect(await describeShellBuildFailure(root)).toContain('No matching export');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('stays silent for an unresolved import the bundle would drop anyway', async () => {
|
|
55
|
+
// Tree-shaken away, so the route serves the page fine (verified: 200). A
|
|
56
|
+
// probe that reported it would send the reader after a build error that is
|
|
57
|
+
// not why the page is broken.
|
|
58
|
+
await Bun.write(join(root, 'entry.ts'), 'import x from "./nowhere";\n');
|
|
59
|
+
|
|
60
|
+
expect(await describeShellBuildFailure(root)).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('returns null for a bundle that builds, so the reason stays the status alone', async () => {
|
|
64
|
+
await Bun.write(join(root, 'entry.ts'), 'console.log("ok");\n');
|
|
65
|
+
|
|
66
|
+
expect(await describeShellBuildFailure(root)).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('returns null when there is no shell entrypoint to build', async () => {
|
|
70
|
+
rmSync(join(root, 'index.html'));
|
|
71
|
+
|
|
72
|
+
expect(await describeShellBuildFailure(root)).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('loads the plugins named in bunfig.toml, as the route does', async () => {
|
|
76
|
+
// A plugin that fails a file the route would bundle: if the probe ignored
|
|
77
|
+
// bunfig it would report a clean build and hide the real failure.
|
|
78
|
+
await Bun.write(
|
|
79
|
+
join(root, 'probe-plugin.ts'),
|
|
80
|
+
`import type { BunPlugin } from 'bun';
|
|
81
|
+
const plugin: BunPlugin = {
|
|
82
|
+
name: 'probe',
|
|
83
|
+
setup(build) {
|
|
84
|
+
build.onLoad({ filter: /\\.probe\\.ts$/ }, () => ({ contents: 'export const = ;', loader: 'ts' }));
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
export default plugin;
|
|
88
|
+
`,
|
|
89
|
+
);
|
|
90
|
+
await Bun.write(join(root, 'bunfig.toml'), '[serve.static]\nplugins = ["./probe-plugin.ts"]\n');
|
|
91
|
+
await Bun.write(join(root, 'entry.ts'), 'import "./thing.probe";\n');
|
|
92
|
+
await Bun.write(join(root, 'thing.probe.ts'), 'export const ok = 1;\n');
|
|
93
|
+
|
|
94
|
+
expect(await describeShellBuildFailure(root)).not.toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('survives a bunfig it cannot parse', async () => {
|
|
98
|
+
await Bun.write(join(root, 'bunfig.toml'), '[serve.static\nplugins = broken\n');
|
|
99
|
+
await Bun.write(join(root, 'entry.ts'), 'console.log("ok");\n');
|
|
100
|
+
|
|
101
|
+
expect(await describeShellBuildFailure(root)).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Every bare import in `src/` must resolve from a published install.
|
|
8
|
+
*
|
|
9
|
+
* The bundler plugin (`css.ts`) is **runtime server code**, not build tooling:
|
|
10
|
+
* `bunfig.toml` names it in `[serve.static].plugins`, so `Bun.serve` loads it
|
|
11
|
+
* whenever an HTML route is bundled — which is every `ompchamber serve` in
|
|
12
|
+
* development and every `bun run start`. It therefore has to be loadable from
|
|
13
|
+
* an install that carries only `dependencies`.
|
|
14
|
+
*
|
|
15
|
+
* It was not. `@tailwindcss/postcss` was declared a devDependency and `postcss`
|
|
16
|
+
* was declared nowhere at all, which no local run could reveal — a source
|
|
17
|
+
* checkout and the developer's own `bun install` both have the whole tree — but
|
|
18
|
+
* the published package does not. Measured against a production-only install of
|
|
19
|
+
* the published tarball: the server came up and answered `503` on every page,
|
|
20
|
+
* with `error: Failed to load plugins for Bun.serve: Cannot find package
|
|
21
|
+
* 'postcss'` in its log and nothing in the shell to say why. The same install
|
|
22
|
+
* from `bun add -g` failed one dependency later on `@tailwindcss/postcss`.
|
|
23
|
+
*
|
|
24
|
+
* The invariant is therefore: a module reachable from a runtime entry point may
|
|
25
|
+
* only import a bare specifier that `dependencies` declares (or a Node/Bun
|
|
26
|
+
* builtin). `devDependencies` is the wrong home for anything the running server
|
|
27
|
+
* loads, however build-shaped it looks.
|
|
28
|
+
*
|
|
29
|
+
* Scope is deliberately `src/**` rather than "what the bundler reaches": the
|
|
30
|
+
* published tarball ships all of `src` and the CLI executes from it too, so a
|
|
31
|
+
* static import anywhere in the tree is a real requirement. Type-only imports
|
|
32
|
+
* are excluded because `scanImports` erases them — they cost nothing at runtime.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { describe, expect, test } from 'bun:test';
|
|
36
|
+
import { readFileSync } from 'node:fs';
|
|
37
|
+
import { builtinModules } from 'node:module';
|
|
38
|
+
import { join } from 'node:path';
|
|
39
|
+
|
|
40
|
+
import { packageRoot } from '@/server/lib/assets/fonts.server';
|
|
41
|
+
|
|
42
|
+
/** Files whose imports are not part of any runtime path. */
|
|
43
|
+
const EXCLUDED = /\.test\.|\.test-util\.|test-util\./;
|
|
44
|
+
|
|
45
|
+
/** `@scope/name` keeps two segments; a bare package keeps one. */
|
|
46
|
+
function packageName(specifier: string): string {
|
|
47
|
+
const segments = specifier.split('/');
|
|
48
|
+
return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const BUILTINS = new Set(builtinModules);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolved from the running module, not the cwd — the same helper the bundler
|
|
55
|
+
* plugin uses, so this test answers the same question whether it is run from the
|
|
56
|
+
* repo root, from `dist/client`, or through `bun test <path>` elsewhere.
|
|
57
|
+
*/
|
|
58
|
+
const ROOT = packageRoot();
|
|
59
|
+
|
|
60
|
+
const pkg = (await Bun.file(join(ROOT, 'package.json')).json()) as {
|
|
61
|
+
dependencies?: Record<string, string>;
|
|
62
|
+
devDependencies?: Record<string, string>;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const RUNTIME = new Set(Object.keys(pkg.dependencies ?? {}));
|
|
66
|
+
const DEV = new Set(Object.keys(pkg.devDependencies ?? {}));
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Bun's transpiler is the parser here, so the answer matches what the bundler
|
|
70
|
+
* itself sees: `@/` aliases, relative paths and `node:`/`bun:` prefixes are
|
|
71
|
+
* filtered below, and a type-only import never appears at all.
|
|
72
|
+
*/
|
|
73
|
+
const transpiler = new Bun.Transpiler({
|
|
74
|
+
loader: 'tsx',
|
|
75
|
+
tsconfig: readFileSync(join(ROOT, 'tsconfig.json'), 'utf8'),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const sources = [...new Bun.Glob('src/**/*.{ts,tsx,js}').scanSync({ cwd: ROOT })]
|
|
79
|
+
.filter((file) => !EXCLUDED.test(file));
|
|
80
|
+
|
|
81
|
+
/** Every bare specifier each file imports, keyed by the file that imports it. */
|
|
82
|
+
async function bareImports(file: string): Promise<string[]> {
|
|
83
|
+
let imports: Bun.Import[];
|
|
84
|
+
try {
|
|
85
|
+
imports = transpiler.scanImports(readFileSync(join(ROOT, file), 'utf8'));
|
|
86
|
+
} catch {
|
|
87
|
+
// A shebang line (`src/cli/ompchamber.js`) is not parseable as a module;
|
|
88
|
+
// its imports are covered by the files it imports.
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
return imports
|
|
92
|
+
// `require-call` entries are the JSX runtime Bun injects into the scan, not
|
|
93
|
+
// a specifier the source names; the runtime itself is `preact`, which is
|
|
94
|
+
// declared.
|
|
95
|
+
.filter((entry) => entry.kind !== 'require-call')
|
|
96
|
+
.map((entry) => entry.path)
|
|
97
|
+
.filter((path) => !path.startsWith('.') && !path.startsWith('@/'))
|
|
98
|
+
.filter((path) => !path.startsWith('bun:') && !path.startsWith('node:'))
|
|
99
|
+
.filter((path) => !BUILTINS.has(packageName(path)));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe('runtime imports are declared as dependencies', () => {
|
|
103
|
+
test('no shipped module imports a bare specifier the package does not declare', async () => {
|
|
104
|
+
const violations: string[] = [];
|
|
105
|
+
|
|
106
|
+
for (const file of sources) {
|
|
107
|
+
for (const specifier of await bareImports(file)) {
|
|
108
|
+
const name = packageName(specifier);
|
|
109
|
+
if (RUNTIME.has(name)) continue;
|
|
110
|
+
violations.push(`${file} imports '${specifier}' — ${DEV.has(name) ? 'declared in devDependencies' : 'not declared in package.json'}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Printed rather than merely counted: the fix is per-specifier, and the
|
|
115
|
+
// message has to say which list to move it to.
|
|
116
|
+
expect(violations).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('the bundler plugin is a runtime dependency of the server', () => {
|
|
120
|
+
// The specific regression, pinned by name. `bunfig.toml` is what makes the
|
|
121
|
+
// plugin runtime code, so the two are read together: if the plugin ever
|
|
122
|
+
// stops being a `serve.static` entry, this assertion is what says the
|
|
123
|
+
// dependency placement no longer has to hold.
|
|
124
|
+
const bunfig = Bun.TOML.parse(readFileSync(join(ROOT, 'bunfig.toml'), 'utf8')) as {
|
|
125
|
+
serve?: { static?: { plugins?: string[] } };
|
|
126
|
+
};
|
|
127
|
+
expect(bunfig.serve?.static?.plugins).toContain('./src/server/lib/bundler/css.ts');
|
|
128
|
+
|
|
129
|
+
for (const name of ['@tailwindcss/postcss', 'postcss']) {
|
|
130
|
+
expect(RUNTIME.has(name)).toBe(true);
|
|
131
|
+
expect(DEV.has(name)).toBe(false);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -44,7 +44,11 @@ export function createDb(path: string): DbClient {
|
|
|
44
44
|
return prep(sql).all(...(params as never[])) as T[];
|
|
45
45
|
},
|
|
46
46
|
async run(sql: string, params: any[] = []) {
|
|
47
|
-
|
|
47
|
+
// Through `prep`, like `get`/`all`: `Database.run` does not cache its
|
|
48
|
+
// query (it prepares and finalizes per call), which cost every INSERT /
|
|
49
|
+
// UPDATE / DELETE a compile — measured 0.881 µs/op against 0.627 µs/op
|
|
50
|
+
// for the cached statement.
|
|
51
|
+
const result = prep(sql).run(...(params as never[]));
|
|
48
52
|
return { changes: result.changes, lastID: Number(result.lastInsertRowid) };
|
|
49
53
|
},
|
|
50
54
|
async exec(sql: string) {
|
|
@@ -2,20 +2,32 @@ import path from 'path';
|
|
|
2
2
|
import type { FileDiffData } from '@/shared/types/git';
|
|
3
3
|
import { runShell, shellOk } from '@/server/lib/fs/shell';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Context lines requested when the reader wants the whole file rather than the
|
|
7
|
+
* hunks. `--unified` takes any count, so this is a cap on the FILE, not on the
|
|
8
|
+
* change: git emits every line it can reach around the edits.
|
|
9
|
+
*/
|
|
10
|
+
const FULL_CONTEXT = 1000000;
|
|
11
|
+
|
|
5
12
|
/** Runs one git diff probe and returns its stdout ('' on failure). */
|
|
6
|
-
async function gitOut(command: string, cwd: string, timeout: number): Promise<string> {
|
|
7
|
-
const result = await runShell(command, { cwd, timeout, maxBuffer
|
|
13
|
+
async function gitOut(command: string, cwd: string, timeout: number, maxBuffer: number = 1024 * 1024 * 2): Promise<string> {
|
|
14
|
+
const result = await runShell(command, { cwd, timeout, maxBuffer });
|
|
8
15
|
return shellOk(result) ? result.stdout : '';
|
|
9
16
|
}
|
|
10
17
|
|
|
11
18
|
/**
|
|
12
19
|
* Fetch git diff for a specific file in the working directory or index.
|
|
20
|
+
*
|
|
21
|
+
* `fullContext` asks git for the entire file rather than the surrounding hunks,
|
|
22
|
+
* which is what the panel's "Full Code" mode renders — the diff comes back in
|
|
23
|
+
* the same unified format, so no second rendering path exists.
|
|
13
24
|
*/
|
|
14
25
|
export async function fetchWorkingFileDiff(
|
|
15
26
|
targetDir: string,
|
|
16
27
|
file: string,
|
|
17
28
|
staged: boolean = false,
|
|
18
|
-
requestedStatus?: string
|
|
29
|
+
requestedStatus?: string,
|
|
30
|
+
fullContext: boolean = false
|
|
19
31
|
): Promise<FileDiffData> {
|
|
20
32
|
const cleanFile = file.replace(/^[./\\]+/, '').replace(/\\/g, '/');
|
|
21
33
|
const fullPath = path.join(targetDir, cleanFile);
|
|
@@ -45,21 +57,33 @@ export async function fetchWorkingFileDiff(
|
|
|
45
57
|
oldContent = await gitOut(`git show HEAD:"${cleanFile}"`, targetDir, 5000)
|
|
46
58
|
|| await gitOut(`git show :0:"${cleanFile}"`, targetDir, 5000);
|
|
47
59
|
|
|
60
|
+
// `-U<n>` on every probe: the full-code mode asks for the whole file, and
|
|
61
|
+
// the diff-only mode keeps git's own default by passing no flag at all.
|
|
62
|
+
const ctx = fullContext ? ` -U${FULL_CONTEXT}` : '';
|
|
63
|
+
// Full-context output is the file, not the change, so a fixed buffer would
|
|
64
|
+
// truncate a large file's diff — and a killed process is not a success, so
|
|
65
|
+
// `gitOut` reports it as no diff at all and the synthesized whole-file
|
|
66
|
+
// rewrite below takes over silently. Sized off the content already read, so
|
|
67
|
+
// it cannot exceed roughly twice the file.
|
|
68
|
+
const buf = fullContext
|
|
69
|
+
? Math.max(1024 * 1024 * 16, (oldContent.length + newContent.length) * 2 + 1024 * 1024)
|
|
70
|
+
: 1024 * 1024 * 2;
|
|
71
|
+
|
|
48
72
|
if (staged) {
|
|
49
73
|
// 1. Try staged diff (index vs HEAD), 2. HEAD diff, 3. unstaged diff
|
|
50
|
-
diff = await gitOut(`git diff --cached -- "${cleanFile}"`, targetDir, 10000)
|
|
51
|
-
|| await gitOut(`git diff HEAD -- "${cleanFile}"`, targetDir, 10000)
|
|
52
|
-
|| await gitOut(`git diff -- "${cleanFile}"`, targetDir, 10000);
|
|
74
|
+
diff = await gitOut(`git diff${ctx} --cached -- "${cleanFile}"`, targetDir, 10000, buf)
|
|
75
|
+
|| await gitOut(`git diff${ctx} HEAD -- "${cleanFile}"`, targetDir, 10000, buf)
|
|
76
|
+
|| await gitOut(`git diff${ctx} -- "${cleanFile}"`, targetDir, 10000, buf);
|
|
53
77
|
} else if (status === '??' || status === 'U' || status === '?') {
|
|
54
78
|
// Untracked file: --no-index exits 1 when a diff exists, so read stdout
|
|
55
79
|
// directly instead of relying on the exit code.
|
|
56
|
-
const untracked = await runShell(`git diff --no-index /dev/null "${cleanFile}"`, { cwd: targetDir, timeout: 10000, maxBuffer:
|
|
80
|
+
const untracked = await runShell(`git diff${ctx} --no-index /dev/null "${cleanFile}"`, { cwd: targetDir, timeout: 10000, maxBuffer: buf });
|
|
57
81
|
diff = untracked.stdout.trim() ? untracked.stdout : '';
|
|
58
82
|
} else {
|
|
59
83
|
// 1. Working tree diff, 2. cached diff, 3. HEAD diff
|
|
60
|
-
diff = await gitOut(`git diff -- "${cleanFile}"`, targetDir, 10000)
|
|
61
|
-
|| await gitOut(`git diff --cached -- "${cleanFile}"`, targetDir, 10000)
|
|
62
|
-
|| await gitOut(`git diff HEAD -- "${cleanFile}"`, targetDir, 10000);
|
|
84
|
+
diff = await gitOut(`git diff${ctx} -- "${cleanFile}"`, targetDir, 10000, buf)
|
|
85
|
+
|| await gitOut(`git diff${ctx} --cached -- "${cleanFile}"`, targetDir, 10000, buf)
|
|
86
|
+
|| await gitOut(`git diff${ctx} HEAD -- "${cleanFile}"`, targetDir, 10000, buf);
|
|
63
87
|
}
|
|
64
88
|
|
|
65
89
|
// 4. If git diff is still empty but new content exists:
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Nested-repository discovery for the right-panel repo picker.
|
|
8
|
+
*
|
|
9
|
+
* A workspace is often a folder OF repositories (`projects/<name>/<sub>/.git`),
|
|
10
|
+
* so the picker lists every repo under the scoped root. The walk is expensive
|
|
11
|
+
* on a deep tree, so it runs in the BACKGROUND after the loader has already
|
|
12
|
+
* answered with the root's own status; the client polls until `pending` clears.
|
|
13
|
+
* Results are cached per scoped root for the life of the process.
|
|
14
|
+
*
|
|
15
|
+
* The `find` prunes `node_modules` (the one directory guaranteed to hold
|
|
16
|
+
* unrelated `.git` directories, and the one that makes the walk unbounded).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { runShell } from '@/server/lib/fs/shell';
|
|
20
|
+
|
|
21
|
+
const MAX_GIT_DEPTH = 8;
|
|
22
|
+
|
|
23
|
+
async function getRepos(rootDir: string): Promise<string[]> {
|
|
24
|
+
try {
|
|
25
|
+
// Search deep enough to discover nested/child git repos inside a monorepo
|
|
26
|
+
// workspace (e.g. projects/<name>/<sub>/.git) while pruning node_modules.
|
|
27
|
+
const result = await runShell(
|
|
28
|
+
`find . -maxdepth ${MAX_GIT_DEPTH} -name node_modules -prune -o -name .git -type d -print`,
|
|
29
|
+
{ cwd: rootDir, timeout: 12000, maxBuffer: 1024 * 1024 }
|
|
30
|
+
);
|
|
31
|
+
const discovered = result.stdout
|
|
32
|
+
.trim()
|
|
33
|
+
.split('\n')
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.map(line => {
|
|
36
|
+
const cleaned = line.replace(/^\.\//, '').replace(/\/\.git$/, '');
|
|
37
|
+
return cleaned === '.git' || !cleaned ? '.' : cleaned;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const uniqueRepos = Array.from(new Set(discovered));
|
|
41
|
+
if (uniqueRepos.includes('.')) {
|
|
42
|
+
return ['.', ...uniqueRepos.filter(r => r !== '.')];
|
|
43
|
+
}
|
|
44
|
+
return uniqueRepos.length ? uniqueRepos : ['.'];
|
|
45
|
+
} catch {
|
|
46
|
+
return ['.'];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface RepoDiscovery {
|
|
51
|
+
repos: string[];
|
|
52
|
+
done: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const repoDiscovery = new Map<string, RepoDiscovery>();
|
|
56
|
+
|
|
57
|
+
/** Begin the background walk for `rootDir`; a no-op once one has started. */
|
|
58
|
+
export function startRepoScan(rootDir: string): void {
|
|
59
|
+
if (repoDiscovery.has(rootDir)) return;
|
|
60
|
+
const d: RepoDiscovery = { repos: ['.'], done: false };
|
|
61
|
+
repoDiscovery.set(rootDir, d);
|
|
62
|
+
void getRepos(rootDir)
|
|
63
|
+
.then(repos => { d.repos = repos; d.done = true; })
|
|
64
|
+
.catch(() => { d.repos = ['.']; d.done = true; });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Drop the cached result for `rootDir` and start a fresh walk. Used by the
|
|
69
|
+
* repo-switcher's refresh button, which must not serve the previous answer.
|
|
70
|
+
*/
|
|
71
|
+
export function rescanRepos(rootDir: string): void {
|
|
72
|
+
repoDiscovery.delete(rootDir);
|
|
73
|
+
startRepoScan(rootDir);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The discovered repos for `rootDir`, and whether the walk is still running. */
|
|
77
|
+
export function discoveredRepos(rootDir: string): { repos: string[]; pending: boolean } {
|
|
78
|
+
const d = repoDiscovery.get(rootDir);
|
|
79
|
+
if (!d) return { repos: ['.'], pending: false };
|
|
80
|
+
return { repos: d.done ? d.repos : ['.'], pending: !d.done };
|
|
81
|
+
}
|