ompchamber 3.2.0 → 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 +239 -230
- package/dist/client/static/js/index-bsxxt89j.css +1 -0
- package/dist/client/static/js/{index-1q6165r3.js → index-qkt4z7x0.js} +640 -640
- package/package.json +4 -3
- package/public/sw.js +54 -5
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +27 -8
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.test.ts +84 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +14 -5
- package/src/client/components/settings/Modal.tsx +10 -17
- package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
- package/src/client/components/settings/categories/appearance-settings/EditorFontSection.tsx +78 -0
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +17 -11
- package/src/client/components/workspace/editor/index.tsx +15 -7
- 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/editor/font.ts +53 -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/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/lifecycle/listener.ts +29 -0
- 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/shared/lib/code/editor/typography.test.ts +93 -0
- package/src/shared/lib/code/editor/typography.ts +86 -0
- 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/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/settings/state.ts +7 -16
- package/dist/client/static/js/index-01dv383b.css +0 -1
|
@@ -1,26 +1,47 @@
|
|
|
1
1
|
import { Elysia } from 'elysia';
|
|
2
2
|
import { getDb } from '@/server/db.server';
|
|
3
3
|
import { tryServeStatic } from '@/server/plugins/static';
|
|
4
|
-
import { renderShell } from '@/server/plugins/shell.server';
|
|
4
|
+
import { renderShell, SHELL_MARKER } from '@/server/plugins/shell.server';
|
|
5
|
+
import {
|
|
6
|
+
DEV_ASSETS_ENABLED,
|
|
7
|
+
devAssetUpstreamPath,
|
|
8
|
+
rewriteDevAssetUrls,
|
|
9
|
+
serveDevAsset,
|
|
10
|
+
} from '@/server/lib/assets/dev-assets.server';
|
|
11
|
+
import { listenerUrl } from '@/server/lib/lifecycle/listener';
|
|
5
12
|
import { FONT_STYLESHEET_ROUTE } from '@/server/lib/assets/font-css.server';
|
|
6
13
|
import { resolveTheme } from '@/shared/lib/theme/catalog';
|
|
7
14
|
import { THEME_STYLE_ELEMENT_ID, themeStyleSheet } from '@/shared/lib/theme/css';
|
|
8
15
|
|
|
16
|
+
/** HTML-escape a string for interpolation into the failure page's `<pre>`. */
|
|
17
|
+
function escapeHtmlText(value: string): string {
|
|
18
|
+
return value
|
|
19
|
+
.replace(/&/g, '&')
|
|
20
|
+
.replace(/</g, '<')
|
|
21
|
+
.replace(/>/g, '>')
|
|
22
|
+
.replace(/"/g, '"');
|
|
23
|
+
}
|
|
24
|
+
|
|
9
25
|
/**
|
|
10
26
|
* Actionable message instead of a bare 500 when the shell cannot be rendered.
|
|
11
27
|
*
|
|
12
28
|
* The shell is a Bun HTML route now, not a file on disk, so "not found" is no
|
|
13
29
|
* longer the failure mode — a bundle error is, and Bun renders its own error
|
|
14
|
-
* page for that
|
|
15
|
-
*
|
|
30
|
+
* page for that, with the file and line locked inside a payload only the
|
|
31
|
+
* browser can decode. `plugins/shell.server.ts` rebuilds the entrypoint to get
|
|
32
|
+
* those facts in readable form and passes them here as the reason, so this page
|
|
33
|
+
* names the edit that broke the bundle rather than the status it produced.
|
|
16
34
|
*/
|
|
17
35
|
function shellUnavailableResponse(reason: string): Response {
|
|
36
|
+
// Escaped, not interpolated raw: the reason carries the failed build's own
|
|
37
|
+
// source lines, which are arbitrary code — a `<` in one would otherwise
|
|
38
|
+
// truncate the page at the first tag.
|
|
18
39
|
return new Response(
|
|
19
40
|
`<!doctype html><meta charset="utf-8"><title>OMPChamber</title>
|
|
20
41
|
<body style="font:14px/1.6 ui-monospace,monospace;padding:2rem">
|
|
21
42
|
<h1 style="font-size:1.1rem">Shell not available</h1>
|
|
22
43
|
<p>The server could not render its HTML shell.</p>
|
|
23
|
-
<pre style="background:#f4f1ea;padding:1rem;border-radius:6px">${reason}</pre>
|
|
44
|
+
<pre style="background:#f4f1ea;padding:1rem;border-radius:6px">${escapeHtmlText(reason)}</pre>
|
|
24
45
|
</body>`,
|
|
25
46
|
{ status: 503, headers: HTML_HEADERS },
|
|
26
47
|
);
|
|
@@ -83,7 +104,17 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
|
|
|
83
104
|
});
|
|
84
105
|
}
|
|
85
106
|
|
|
86
|
-
|
|
107
|
+
// The dev asset proxy, before the static lookup: its prefix is this server's
|
|
108
|
+
// own, and Bun's table never sees it. See `lib/assets/dev-assets.server`.
|
|
109
|
+
if (DEV_ASSETS_ENABLED && devAssetUpstreamPath(pathname)) {
|
|
110
|
+
const base = listenerUrl();
|
|
111
|
+
if (base) {
|
|
112
|
+
const proxied = await serveDevAsset(request, pathname, base);
|
|
113
|
+
if (proxied) return proxied;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const asset = await tryServeStatic(pathname, request);
|
|
87
118
|
if (asset) return asset;
|
|
88
119
|
|
|
89
120
|
const settings = await readSettings();
|
|
@@ -110,13 +141,18 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
|
|
|
110
141
|
// loader and fail the same way.
|
|
111
142
|
const fontLink = `<link rel="stylesheet" href="${FONT_STYLESHEET_ROUTE}">`;
|
|
112
143
|
|
|
144
|
+
// Bun's dev asset URLs move to this server's prefix so the proxy above can add
|
|
145
|
+
// the caching headers Bun omits. Rewritten BEFORE the bootstrap is injected,
|
|
146
|
+
// so a `/_bun/` string inside the settings payload is left alone.
|
|
147
|
+
const shellHtml = DEV_ASSETS_ENABLED ? rewriteDevAssetUrls(rendered.html) : rendered.html;
|
|
148
|
+
|
|
113
149
|
return new Response(
|
|
114
|
-
|
|
150
|
+
shellHtml
|
|
115
151
|
.replace('data-theme="paper"', `data-theme="${theme.id}"`)
|
|
116
152
|
.replace('data-theme-variant="light"', `data-theme-variant="${theme.variant}"`)
|
|
117
153
|
.replace('<meta name="theme-color" content="#faf8f3" />', `<meta name="theme-color" content="${theme.canvas}" />`)
|
|
118
154
|
.replace('<!--app-head-->', themeStyle + fontLink)
|
|
119
|
-
.replace(
|
|
155
|
+
.replace(SHELL_MARKER, `<script>window.__OMP_BOOTSTRAP__=${bootstrap}</script>`),
|
|
120
156
|
{ headers: HTML_HEADERS },
|
|
121
157
|
);
|
|
122
158
|
});
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { extname, join, normalize, resolve, sep } from 'path';
|
|
2
2
|
|
|
3
|
+
import { notModified } from '@/server/lib/assets/conditional.server';
|
|
3
4
|
import { FONT_STYLESHEET_ROUTE, serveFontFile, serveFontStylesheet } from '@/server/lib/assets/font-css.server';
|
|
4
5
|
import { FONT_ROUTE_PREFIX, packageRoot } from '@/server/lib/assets/fonts.server';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* File serving for `public/` and the font routes.
|
|
8
9
|
*
|
|
9
|
-
* The client bundle is NOT served from here
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* The client bundle is NOT served from here. Bun's own routing owns the assets
|
|
11
|
+
* it emits — `/_bun/asset/*` and `/_bun/client/*` in development, `/chunk-*` in
|
|
12
|
+
* production — and answers them from its bundle table before a request reaches
|
|
13
|
+
* Elysia. In development those responses are reached a second way, through
|
|
14
|
+
* `lib/assets/dev-assets.server.ts`: Bun's table wins for the paths it declares,
|
|
15
|
+
* so the shell points at `/_dev-assets/*` and that module fetches the bytes back
|
|
16
|
+
* from the listener to add the `Cache-Control` and `ETag`/`304` handling Bun's
|
|
17
|
+
* dev asset routes lack. `dist/client` no longer exists, so the `/static/`
|
|
13
18
|
* branch and its content-hashed caching went with it.
|
|
14
19
|
*
|
|
15
20
|
* Hand-rolled rather than `@elysiajs/static`: that plugin registers its own
|
|
@@ -61,7 +66,17 @@ function resolveWithin(root: string, pathname: string): string | null {
|
|
|
61
66
|
return candidate;
|
|
62
67
|
}
|
|
63
68
|
|
|
64
|
-
|
|
69
|
+
/**
|
|
70
|
+
* The file's own bytes, or null when it cannot be read.
|
|
71
|
+
*
|
|
72
|
+
* `If-None-Match` is answered from a weak validator derived from the file's
|
|
73
|
+
* size and mtime. A content hash would be stronger, but it would mean reading
|
|
74
|
+
* every asset on every request to compute it — the opposite of the point.
|
|
75
|
+
* Size+mtime is what `plugins/static.ts` can produce from the `stat` it already
|
|
76
|
+
* performs, and a wrong `304` from it needs both a same-size edit and a
|
|
77
|
+
* preserved mtime, which is not something a build or an editor does.
|
|
78
|
+
*/
|
|
79
|
+
async function serveFile(filePath: string, cacheControl: string, request?: Request): Promise<Response | null> {
|
|
65
80
|
let stat;
|
|
66
81
|
try {
|
|
67
82
|
stat = await Bun.file(filePath).stat();
|
|
@@ -71,14 +86,24 @@ async function serveFile(filePath: string, cacheControl: string): Promise<Respon
|
|
|
71
86
|
if (!stat.isFile()) return null;
|
|
72
87
|
|
|
73
88
|
const type = MIME[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
89
|
+
const lastModified = stat.mtime.toUTCString();
|
|
90
|
+
// Weak on purpose: the validator identifies the file as the same version, and
|
|
91
|
+
// byte equality is not what it promises.
|
|
92
|
+
const etag = `W/"${stat.size}-${Math.floor(stat.mtimeMs / 1000)}"`;
|
|
93
|
+
const headers = {
|
|
94
|
+
'content-type': type,
|
|
95
|
+
'cache-control': cacheControl,
|
|
96
|
+
'last-modified': lastModified,
|
|
97
|
+
etag,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
if (request && notModified(request, etag, stat.mtimeMs)) {
|
|
101
|
+
// No `content-length`: a 304 describes the validator, not a body.
|
|
102
|
+
return new Response(null, { status: 304, headers });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return new Response(Bun.file(filePath).stream(), {
|
|
106
|
+
headers: { ...headers, 'content-length': String(stat.size) },
|
|
82
107
|
});
|
|
83
108
|
}
|
|
84
109
|
|
|
@@ -90,18 +115,22 @@ async function serveFile(filePath: string, cacheControl: string): Promise<Respon
|
|
|
90
115
|
* going through the SSR route that injects the theme and bootstrap, and the
|
|
91
116
|
* cache stays short because these filenames carry no content hash.
|
|
92
117
|
*/
|
|
93
|
-
export async function servePublicAsset(
|
|
118
|
+
export async function servePublicAsset(
|
|
119
|
+
pathname: string,
|
|
120
|
+
roots: readonly string[],
|
|
121
|
+
request?: Request,
|
|
122
|
+
): Promise<Response | null> {
|
|
94
123
|
for (const root of roots) {
|
|
95
124
|
const filePath = resolveWithin(root, pathname);
|
|
96
125
|
if (!filePath || filePath.endsWith('.html')) continue;
|
|
97
|
-
const response = await serveFile(filePath, SHORT);
|
|
126
|
+
const response = await serveFile(filePath, SHORT, request);
|
|
98
127
|
if (response) return response;
|
|
99
128
|
}
|
|
100
129
|
return null;
|
|
101
130
|
}
|
|
102
131
|
|
|
103
132
|
/** A file for `pathname`, or null when the request should reach the app. */
|
|
104
|
-
export async function tryServeStatic(pathname: string): Promise<Response | null> {
|
|
133
|
+
export async function tryServeStatic(pathname: string, request?: Request): Promise<Response | null> {
|
|
105
134
|
// The production bundle's own assets. `scripts/build-client.ts` emits the
|
|
106
135
|
// server into `dist/client` with its chunks under `dist/client/static`, and
|
|
107
136
|
// the generated HTML references them as `./static/...` — a page URL of `/`
|
|
@@ -110,7 +139,7 @@ export async function tryServeStatic(pathname: string): Promise<Response | null>
|
|
|
110
139
|
// `/_bun/client/*`) and never reach here.
|
|
111
140
|
if (pathname.startsWith('/static/')) {
|
|
112
141
|
const filePath = resolveWithin(CLIENT_ROOT, pathname);
|
|
113
|
-
return filePath ? serveFile(filePath, ASSET_CACHE) : null;
|
|
142
|
+
return filePath ? serveFile(filePath, ASSET_CACHE, request) : null;
|
|
114
143
|
}
|
|
115
144
|
// The extracted `@font-face` stylesheet and the files it names. Bun's CSS
|
|
116
145
|
// loader cannot carry either — every local `url()` is resolved, so a bundled
|
|
@@ -118,9 +147,13 @@ export async function tryServeStatic(pathname: string): Promise<Response | null>
|
|
|
118
147
|
// than emitted. Both live outside `public/`: the stylesheet is assembled from
|
|
119
148
|
// the faces `lib/bundler/css.ts` wrote, and the files come from the installed
|
|
120
149
|
// packages.
|
|
150
|
+
//
|
|
151
|
+
// Neither is given a conditional here: the stylesheet is `no-store` (nothing
|
|
152
|
+
// is ever stored to revalidate) and a font file is `immutable` (the browser
|
|
153
|
+
// does not ask again). A validator on either would be dead code.
|
|
121
154
|
if (pathname === FONT_STYLESHEET_ROUTE) return serveFontStylesheet();
|
|
122
155
|
if (pathname.startsWith(FONT_ROUTE_PREFIX)) {
|
|
123
156
|
return serveFontFile(decodeURIComponent(pathname.slice(FONT_ROUTE_PREFIX.length)));
|
|
124
157
|
}
|
|
125
|
-
return servePublicAsset(pathname, [PUBLIC_ROOT]);
|
|
158
|
+
return servePublicAsset(pathname, [PUBLIC_ROOT], request);
|
|
126
159
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The editor typography contract.
|
|
8
|
+
*
|
|
9
|
+
* These are the properties that make one set of constants serve two layouts at
|
|
10
|
+
* any zoom level. Each one has a silent failure mode:
|
|
11
|
+
*
|
|
12
|
+
* - A px line-height does not scale with the font size, so zooming the editor
|
|
13
|
+
* leaves the rows at the size they were chosen for at 12px — cramped at 24,
|
|
14
|
+
* loose at 8. A ratio cannot.
|
|
15
|
+
* - The stack must name the bundled face first: `Fira Code` is the only entry
|
|
16
|
+
* the app ships, so anything ahead of it would either be a font nobody has or
|
|
17
|
+
* silently change the ligatures the whole console is typeset in.
|
|
18
|
+
* - The bounds must bracket the default, or the editor opens at a size its own
|
|
19
|
+
* zoom controls cannot return to (the first click jumps).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, expect, test } from 'bun:test';
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
EDITOR_DEFAULT_FONT_FAMILY,
|
|
26
|
+
EDITOR_DEFAULT_FONT_SIZE,
|
|
27
|
+
EDITOR_FONT_CHOICES,
|
|
28
|
+
EDITOR_FONT_FAMILY,
|
|
29
|
+
EDITOR_LINE_HEIGHT,
|
|
30
|
+
EDITOR_MAX_FONT_SIZE,
|
|
31
|
+
EDITOR_MIN_FONT_SIZE,
|
|
32
|
+
editorFontStack,
|
|
33
|
+
} from '@/shared/lib/code/editor/typography';
|
|
34
|
+
|
|
35
|
+
describe('editor typography', () => {
|
|
36
|
+
test('the line box is a ratio, so it scales with the font size', () => {
|
|
37
|
+
expect(typeof EDITOR_LINE_HEIGHT).toBe('number');
|
|
38
|
+
expect(EDITOR_LINE_HEIGHT).toBeGreaterThan(1);
|
|
39
|
+
expect(EDITOR_LINE_HEIGHT).toBeLessThan(3);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('the stack leads with the bundled face and ends in a generic', () => {
|
|
43
|
+
expect(EDITOR_FONT_FAMILY.startsWith('"Fira Code"')).toBe(true);
|
|
44
|
+
expect(EDITOR_FONT_FAMILY.trimEnd().endsWith('monospace')).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('the zoom bounds bracket the size the editor opens at', () => {
|
|
48
|
+
expect(EDITOR_MIN_FONT_SIZE).toBeLessThan(EDITOR_DEFAULT_FONT_SIZE);
|
|
49
|
+
expect(EDITOR_MAX_FONT_SIZE).toBeGreaterThan(EDITOR_DEFAULT_FONT_SIZE);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('editorFontStack', () => {
|
|
54
|
+
test('the default and an empty choice both yield the shared stack', () => {
|
|
55
|
+
expect(editorFontStack(EDITOR_DEFAULT_FONT_FAMILY)).toBe(EDITOR_FONT_FAMILY);
|
|
56
|
+
expect(editorFontStack('')).toBe(EDITOR_FONT_FAMILY);
|
|
57
|
+
expect(editorFontStack(undefined)).toBe(EDITOR_FONT_FAMILY);
|
|
58
|
+
expect(editorFontStack(' ')).toBe(EDITOR_FONT_FAMILY);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a chosen face is preferred, with the bundled stack still behind it', () => {
|
|
62
|
+
const stack = editorFontStack('JetBrains Mono');
|
|
63
|
+
expect(stack.startsWith('"JetBrains Mono"')).toBe(true);
|
|
64
|
+
// The fallback is what keeps an uninstalled choice from breaking the editor.
|
|
65
|
+
expect(stack).toContain(EDITOR_FONT_FAMILY);
|
|
66
|
+
expect(stack.trimEnd().endsWith('monospace')).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('a multi-word choice is quoted, so it stays one family name', () => {
|
|
70
|
+
expect(editorFontStack('Courier New').startsWith('"Courier New"')).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('the default choice is one the picker offers', () => {
|
|
74
|
+
expect(EDITOR_FONT_CHOICES.map((choice) => choice.id)).toContain(EDITOR_DEFAULT_FONT_FAMILY);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('exactly one offered face is the bundled one, and it is the default', () => {
|
|
78
|
+
const bundled = EDITOR_FONT_CHOICES.filter((choice) => choice.bundled);
|
|
79
|
+
expect(bundled.map((choice) => choice.id)).toEqual([EDITOR_DEFAULT_FONT_FAMILY]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('every choice resolves to a stack that still ends in the bundled face', () => {
|
|
83
|
+
// The picker offers faces the app does not ship, so the guarantee that makes
|
|
84
|
+
// that safe is per-choice: a device without the chosen face must land on
|
|
85
|
+
// Fira Code rather than on a system serif or a blank cell.
|
|
86
|
+
for (const choice of EDITOR_FONT_CHOICES) {
|
|
87
|
+
const stack = editorFontStack(choice.id);
|
|
88
|
+
expect(stack.startsWith(`"${choice.id}"`)).toBe(true);
|
|
89
|
+
expect(stack).toContain(EDITOR_FONT_FAMILY);
|
|
90
|
+
expect(stack.trimEnd().endsWith('monospace')).toBe(true);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The editor's typography, in one place.
|
|
8
|
+
*
|
|
9
|
+
* The desktop editor and the phone's full-screen editor are the same surface on
|
|
10
|
+
* two layouts, so their text has to be the same text — the same face and the
|
|
11
|
+
* same line box. It was not: the stack was written out as a literal twice in
|
|
12
|
+
* the desktop editor and replaced by a bare `monospace` on the phone, which
|
|
13
|
+
* dropped Fira Code (and the ligatures the whole console is typeset in) and put
|
|
14
|
+
* the phone's line numbers in a different face from the code beside them — the
|
|
15
|
+
* gutter was already `font-mono`.
|
|
16
|
+
*
|
|
17
|
+
* `EDITOR_LINE_HEIGHT` is a ratio rather than a pixel value for the reason the
|
|
18
|
+
* desktop editor already used one: the zoom control changes the font size, and
|
|
19
|
+
* a fixed pixel line box keeps the rows at the size they were chosen for at
|
|
20
|
+
* 12px — cramped at 18px, loose at 10px. It has to be applied to the gutter as
|
|
21
|
+
* well as the editor, because with word wrap off the gutter rows carry no
|
|
22
|
+
* explicit height and take their spacing from their own line-height; a gutter
|
|
23
|
+
* pinned to a different value drifts out of register with the lines it counts.
|
|
24
|
+
*
|
|
25
|
+
* The two layouts keep their own padding and gutter width — a phone has less
|
|
26
|
+
* room, and the padding is chrome, not type. Everything that decides how a
|
|
27
|
+
* glyph is drawn lives here.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Fira Code first (bundled — see `fira-code-fonts.css`), then faces a system may already have. */
|
|
31
|
+
export const EDITOR_FONT_FAMILY = '"Fira Code", "JetBrains Mono", "SF Mono", Consolas, monospace';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The faces the editor font picker offers, in the order it lists them.
|
|
35
|
+
*
|
|
36
|
+
* Each entry is a CSS family name. The editor puts the chosen one FIRST in its
|
|
37
|
+
* stack and keeps the rest of `EDITOR_FONT_FAMILY` behind it, so a face the
|
|
38
|
+
* device does not have falls through to the bundled Fira Code rather than
|
|
39
|
+
* rendering a blank or a system serif — which is what makes this setting safe
|
|
40
|
+
* to offer without shipping every font in the list. Only Fira Code is bundled;
|
|
41
|
+
* the others are names a system may or may not have, and the picker says so.
|
|
42
|
+
*
|
|
43
|
+
* `id` is the stored value and is deliberately the CSS name itself: a mapping
|
|
44
|
+
* table would add a second thing to keep in sync, and the name is already the
|
|
45
|
+
* only thing the renderer needs.
|
|
46
|
+
*/
|
|
47
|
+
export const EDITOR_FONT_CHOICES = [
|
|
48
|
+
{ id: 'Fira Code', label: 'Fira Code', bundled: true },
|
|
49
|
+
{ id: 'JetBrains Mono', label: 'JetBrains Mono', bundled: false },
|
|
50
|
+
{ id: 'SF Mono', label: 'SF Mono', bundled: false },
|
|
51
|
+
{ id: 'Menlo', label: 'Menlo', bundled: false },
|
|
52
|
+
{ id: 'Consolas', label: 'Consolas', bundled: false },
|
|
53
|
+
{ id: 'Monaco', label: 'Monaco', bundled: false },
|
|
54
|
+
{ id: 'Courier New', label: 'Courier New', bundled: false },
|
|
55
|
+
] as const;
|
|
56
|
+
|
|
57
|
+
/** The stored setting's default; also the first entry the picker lists. */
|
|
58
|
+
export const EDITOR_DEFAULT_FONT_FAMILY = 'Fira Code';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The editor's font stack with `family` preferred.
|
|
62
|
+
*
|
|
63
|
+
* The chosen face is quoted (the list carries multi-word names) and placed
|
|
64
|
+
* ahead of the shared stack, so an unknown or uninstalled name degrades to the
|
|
65
|
+
* bundled Fira Code instead of replacing it. An empty or absent choice yields
|
|
66
|
+
* the shared stack unchanged, which is what a settings row that predates this
|
|
67
|
+
* option holds.
|
|
68
|
+
*/
|
|
69
|
+
export function editorFontStack(family?: string | null): string {
|
|
70
|
+
const chosen = (family ?? '').trim();
|
|
71
|
+
if (!chosen || chosen === EDITOR_DEFAULT_FONT_FAMILY) return EDITOR_FONT_FAMILY;
|
|
72
|
+
return `"${chosen}", ${EDITOR_FONT_FAMILY}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Line box as a multiple of the font size; apply to the editor AND its gutter. */
|
|
76
|
+
export const EDITOR_LINE_HEIGHT = 1.5;
|
|
77
|
+
|
|
78
|
+
/** Font size in px both editors open at. */
|
|
79
|
+
export const EDITOR_DEFAULT_FONT_SIZE = 12;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Zoom bounds, in px. Shared so the two layouts offer the same range — a phone
|
|
83
|
+
* clamped to a narrower one is a different editor, not a smaller one.
|
|
84
|
+
*/
|
|
85
|
+
export const EDITOR_MIN_FONT_SIZE = 8;
|
|
86
|
+
export const EDITOR_MAX_FONT_SIZE = 24;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Bundled font: Symbols Nerd Font Mono
|
|
2
|
+
|
|
3
|
+
`symbols-nerd-font-mono.woff2` is a subset of **SymbolsNerdFontMono-Regular.ttf**
|
|
4
|
+
from [Nerd Fonts](https://github.com/ryanoasis/nerd-fonts) **v3.5.1**, built by
|
|
5
|
+
`scripts/nerd-symbols-font.mjs`. It is the fallback face for the terminal's
|
|
6
|
+
private-use glyphs (powerline separators, devicons, Font Awesome, Material
|
|
7
|
+
Design icons); see `nerd-symbols.css` for why it is needed and how it is scoped.
|
|
8
|
+
|
|
9
|
+
Subset coverage — the private-use areas plus the handful of Nerd-specific
|
|
10
|
+
codepoints outside them:
|
|
11
|
+
|
|
12
|
+
U+E000-F8FF BMP private use
|
|
13
|
+
U+F0000-FFFFD Supplementary private use area-A (Material Design Icons)
|
|
14
|
+
U+23FB-23FE, U+2630, U+2665, U+26A1, U+276C-2771, U+2B58
|
|
15
|
+
|
|
16
|
+
## Licences
|
|
17
|
+
|
|
18
|
+
The font is a patched build combining several upstream icon sets. Each is
|
|
19
|
+
redistributed here under its own licence, unchanged in substance (the subset
|
|
20
|
+
keeps the glyph outlines verbatim; only `layout-features` and hinting are
|
|
21
|
+
dropped, which are not licence terms).
|
|
22
|
+
|
|
23
|
+
| Icon set | Upstream | Licence |
|
|
24
|
+
|--------------------------|-------------------------------------------------------|-------------|
|
|
25
|
+
| Codicons | https://github.com/microsoft/vscode-codicons | CC BY 4.0 |
|
|
26
|
+
| Devicons | https://github.com/devicons/devicon | MIT |
|
|
27
|
+
| extraglyphs | https://github.com/source-foundry/Hack | MIT |
|
|
28
|
+
| Font Awesome | https://github.com/FortAwesome/Font-Awesome | CC BY 4.0 |
|
|
29
|
+
| Font Awesome Extension | https://github.com/AndreLZGava/font-awesome-extension | MIT |
|
|
30
|
+
| Font Logos | https://github.com/lukas-w/font-logos | unlicensed |
|
|
31
|
+
| Material Design Icons | https://github.com/Templarian/MaterialDesign-Font | Apache 2.0 |
|
|
32
|
+
| Octicons | https://github.com/primer/octicons | MIT |
|
|
33
|
+
| Seti and original | https://github.com/jesseweed/seti-ui | MIT |
|
|
34
|
+
| Pomicons | https://github.com/gabrielelana/pomicons | OFL 1.1 RFN |
|
|
35
|
+
| Powerline Extra Symbols | https://github.com/ryanoasis/powerline-extra-symbols | MIT |
|
|
36
|
+
| Powerline Symbols | https://github.com/powerline/powerline | MIT |
|
|
37
|
+
|
|
38
|
+
The Nerd Fonts project itself (the patching, the font metadata and the
|
|
39
|
+
non-icon glyphs) is MIT, Copyright (c) 2014 Ryan L McIntyre. Its licence text
|
|
40
|
+
accompanies the upstream release at
|
|
41
|
+
https://github.com/ryanoasis/nerd-fonts/blob/master/LICENSE.
|
|
42
|
+
|
|
43
|
+
"Font Logos" is marked unlicensed upstream; it ships inside the Nerd Fonts
|
|
44
|
+
release that this subset is taken from, and is redistributed on that basis.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* The bundled Nerd Font symbols face.
|
|
8
|
+
*
|
|
9
|
+
* A shell prompt is drawn with private-use symbols — powerline separators,
|
|
10
|
+
* devicons, Font Awesome, Material Design icons — that no coding webfont
|
|
11
|
+
* contains. `@fontsource/fira-code`'s latin subset maps 226 codepoints and not
|
|
12
|
+
* one of them is above U+FFFF, so the terminal's stack can only reach them
|
|
13
|
+
* through a Nerd Font. Until now the stack named installed families only
|
|
14
|
+
* (`XTERM_GLYPH_FALLBACKS`), which means the glyphs appeared on the machine
|
|
15
|
+
* that had one installed and were hollow tofu boxes on every other device — a
|
|
16
|
+
* phone, a second laptop, a colleague's browser.
|
|
17
|
+
*
|
|
18
|
+
* This face closes that gap without taking the choice away: it is declared LAST
|
|
19
|
+
* in the stack, after every installed Nerd Font family, so a machine that
|
|
20
|
+
* already has one renders from it and never fetches these bytes.
|
|
21
|
+
*
|
|
22
|
+
* `unicode-range` is what makes "never fetches" true rather than aspirational.
|
|
23
|
+
* A face whose range no laid-out glyph falls in is never downloaded, so a page
|
|
24
|
+
* that renders only Latin text (or a desktop where an installed family wins)
|
|
25
|
+
* issues no request at all. Verified: a latin-only document with this
|
|
26
|
+
* stylesheet loaded fetched only `fira-code-latin-400-normal.woff2` and left
|
|
27
|
+
* this face `unloaded`; a document laying out U+E0B0 with an installed family
|
|
28
|
+
* ahead of it fetched nothing.
|
|
29
|
+
*
|
|
30
|
+
* `font-display: block` rather than `swap`, unlike the text faces beside it: a
|
|
31
|
+
* swapped symbol paints a tofu box first and repaints, and in a terminal that
|
|
32
|
+
* box is exactly the artifact this face exists to remove. `mount.ts` awaits the
|
|
33
|
+
* face before opening xterm for the same reason — see `ensureNerdGlyphs`.
|
|
34
|
+
*
|
|
35
|
+
* Generated by `bun run scripts/nerd-symbols-font.mjs` from
|
|
36
|
+
* `SymbolsNerdFontMono-Regular.ttf` (Nerd Fonts v3.5.1). See NOTICE.md in this
|
|
37
|
+
* directory for the icon-set licences.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
@font-face {
|
|
41
|
+
font-family: 'OMPChamber Nerd Symbols';
|
|
42
|
+
font-style: normal;
|
|
43
|
+
font-weight: 400;
|
|
44
|
+
font-display: block;
|
|
45
|
+
unicode-range:
|
|
46
|
+
U+E000-F8FF,
|
|
47
|
+
U+F0000-FFFFD,
|
|
48
|
+
U+23FB-23FE,
|
|
49
|
+
U+2630,
|
|
50
|
+
U+2665,
|
|
51
|
+
U+26A1,
|
|
52
|
+
U+276C-2771,
|
|
53
|
+
U+2B58;
|
|
54
|
+
src: url('./symbols-nerd-font-mono.woff2') format('woff2');
|
|
55
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The terminal's Nerd Font coverage is one contract split across three files,
|
|
8
|
+
* and every way it can break is silent at runtime:
|
|
9
|
+
*
|
|
10
|
+
* - The bundled face's family name is written twice — in the stack
|
|
11
|
+
* (`XTERM_FONT_FAMILY`) and in the `@font-face` (`nerd-symbols.css`). If they
|
|
12
|
+
* drift, the face loads and nothing uses it: prompts render as tofu boxes
|
|
13
|
+
* with no error anywhere.
|
|
14
|
+
* - The face must stay LAST in the stack. Moved ahead of the installed families
|
|
15
|
+
* it would win on every machine, which both changes the glyph shapes a user
|
|
16
|
+
* with a Nerd Font already sees AND makes every desktop download 1.1 MB it
|
|
17
|
+
* never needed. That is a bandwidth regression with no visible symptom on the
|
|
18
|
+
* developer's own laptop.
|
|
19
|
+
* - The font file and its `unicode-range` must agree. A range that names
|
|
20
|
+
* codepoints the subset dropped leaves those glyphs as tofu even though the
|
|
21
|
+
* face is loaded and "working".
|
|
22
|
+
*
|
|
23
|
+
* These assertions are cheap and each one pins a failure this change was made
|
|
24
|
+
* to prevent, so they are permanent rather than throwaway.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, expect, test } from 'bun:test';
|
|
28
|
+
import { readFileSync } from 'node:fs';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
NERD_SYMBOLS_FAMILY,
|
|
33
|
+
XTERM_FONT_FAMILY,
|
|
34
|
+
XTERM_GLYPH_FALLBACKS,
|
|
35
|
+
} from '@/client/data/theme/terminal';
|
|
36
|
+
|
|
37
|
+
const FONT_DIR = import.meta.dir;
|
|
38
|
+
const CSS = readFileSync(join(FONT_DIR, 'nerd-symbols.css'), 'utf8');
|
|
39
|
+
const WOFF2 = join(FONT_DIR, 'symbols-nerd-font-mono.woff2');
|
|
40
|
+
|
|
41
|
+
/** Parse `unicode-range: a, b, c;` into codepoint intervals. */
|
|
42
|
+
function declaredRanges(css: string): Array<[number, number]> {
|
|
43
|
+
const block = css.match(/unicode-range:\s*([^;]+);/);
|
|
44
|
+
if (!block) throw new Error('nerd-symbols.css declares no unicode-range');
|
|
45
|
+
return block[1]
|
|
46
|
+
.split(',')
|
|
47
|
+
.map((entry) => entry.trim())
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.map((entry) => {
|
|
50
|
+
const [start, end] = entry.split('-');
|
|
51
|
+
const lo = Number.parseInt(start.replace(/^U\+/i, ''), 16);
|
|
52
|
+
const hi = end ? Number.parseInt(end.replace(/^U\+/i, ''), 16) : lo;
|
|
53
|
+
return [lo, hi] as [number, number];
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('bundled Nerd Font symbols face', () => {
|
|
58
|
+
test('the stylesheet declares the family the terminal stack names', () => {
|
|
59
|
+
const declared = CSS.match(/font-family:\s*'([^']+)'/)?.[1];
|
|
60
|
+
expect(declared).toBe(NERD_SYMBOLS_FAMILY);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('the face is LAST in the stack, after every installed family', () => {
|
|
64
|
+
// A local Nerd Font must win, so the bundled face is only reached on a
|
|
65
|
+
// machine that has none — which is also what keeps the desktop from
|
|
66
|
+
// downloading it (its unicode-range is PUA-only, so it is never fetched
|
|
67
|
+
// when another family supplies the glyph).
|
|
68
|
+
expect(XTERM_FONT_FAMILY.indexOf(NERD_SYMBOLS_FAMILY)).toBeGreaterThan(
|
|
69
|
+
XTERM_FONT_FAMILY.indexOf('PowerlineSymbols'),
|
|
70
|
+
);
|
|
71
|
+
// Only the generic fallbacks may follow it.
|
|
72
|
+
const after = XTERM_FONT_FAMILY.slice(XTERM_FONT_FAMILY.indexOf(NERD_SYMBOLS_FAMILY));
|
|
73
|
+
expect(after).not.toMatch(/Nerd Font Mono'/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('every installed family is still offered before the bundled one', () => {
|
|
77
|
+
for (const family of ['FiraCode Nerd Font Mono', 'JetBrainsMono Nerd Font', 'Symbols Nerd Font Mono']) {
|
|
78
|
+
expect(XTERM_GLYPH_FALLBACKS).toContain(family);
|
|
79
|
+
expect(XTERM_FONT_FAMILY.indexOf(family)).toBeLessThan(
|
|
80
|
+
XTERM_FONT_FAMILY.indexOf(NERD_SYMBOLS_FAMILY),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('the subset actually covers the ranges the stylesheet claims', () => {
|
|
86
|
+
// Reading the cmap needs fonttools, which is not a project dependency. The
|
|
87
|
+
// absence is a legitimate skip; a *failure* of the probe is not, so the two
|
|
88
|
+
// are told apart rather than both being swallowed as "no fonttools".
|
|
89
|
+
const available = Bun.spawnSync([
|
|
90
|
+
'python3',
|
|
91
|
+
'-c',
|
|
92
|
+
'import fontTools, brotli',
|
|
93
|
+
]);
|
|
94
|
+
if (available.exitCode !== 0) {
|
|
95
|
+
console.warn('skipping font coverage check: python3 fonttools/brotli unavailable');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const probe = Bun.spawnSync([
|
|
100
|
+
'python3',
|
|
101
|
+
'-c',
|
|
102
|
+
`
|
|
103
|
+
import json
|
|
104
|
+
from fontTools.ttLib import TTFont
|
|
105
|
+
|
|
106
|
+
cmap = set(TTFont(${JSON.stringify(WOFF2)}).getBestCmap())
|
|
107
|
+
ranges = ${JSON.stringify(declaredRanges(CSS))}
|
|
108
|
+
uncovered = [r for r in ranges if not any(cp in cmap for cp in range(r[0], r[1] + 1))]
|
|
109
|
+
# The prompt glyphs this face exists for.
|
|
110
|
+
required = [0xE0B0, 0xE0B4, 0xE0B6, 0xE606, 0xE7A8, 0xE718, 0xF015, 0xF03E, 0xF121B, 0xF0001]
|
|
111
|
+
print(json.dumps({ "codepoints": len(cmap), "uncovered": uncovered, "missing": [c for c in required if c not in cmap] }))
|
|
112
|
+
`,
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
// fonttools is known to be importable here, so a non-zero exit is a real
|
|
116
|
+
// failure (a corrupt subset, a bad path) and must not read as a skip.
|
|
117
|
+
expect(probe.stderr.toString()).toBe('');
|
|
118
|
+
|
|
119
|
+
const result = JSON.parse(probe.stdout.toString()) as {
|
|
120
|
+
codepoints: number;
|
|
121
|
+
uncovered: Array<[number, number]>;
|
|
122
|
+
missing: number[];
|
|
123
|
+
};
|
|
124
|
+
expect(result.uncovered).toEqual([]);
|
|
125
|
+
expect(result.missing).toEqual([]);
|
|
126
|
+
expect(result.codepoints).toBeGreaterThan(10_000);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('the face file is present and non-trivial', async () => {
|
|
130
|
+
const file = Bun.file(WOFF2);
|
|
131
|
+
expect(await file.exists()).toBe(true);
|
|
132
|
+
expect(file.size).toBeGreaterThan(100_000);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
Binary file
|
|
@@ -4,7 +4,7 @@ import { diffSettings } from '@/shared/lib/settings/diff';
|
|
|
4
4
|
|
|
5
5
|
describe('diffSettings', () => {
|
|
6
6
|
test('an object patch persists only the keys it actually changed', () => {
|
|
7
|
-
const prev = { theme: 'paper', soundAlerts: true,
|
|
7
|
+
const prev = { theme: 'paper', soundAlerts: true, editorFont: 'Fira Code' };
|
|
8
8
|
const next = { ...prev, theme: 'nord-dark' };
|
|
9
9
|
expect(diffSettings(prev, next, { theme: 'nord-dark' })).toEqual({ theme: 'nord-dark' });
|
|
10
10
|
});
|