ompchamber 3.2.0 → 3.3.1
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-1q6165r3.js → index-4qx6m2p7.js} +640 -640
- package/dist/client/static/js/index-bsxxt89j.css +1 -0
- 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 +148 -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
|
@@ -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
|
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The bytes the terminal key bar sends.
|
|
8
|
+
*
|
|
9
|
+
* Every expectation here is copied from xterm's own `evaluateKeyboardEvent`
|
|
10
|
+
* (`@xterm/xterm/src/common/input/Keyboard.ts`), because the shell cannot tell a
|
|
11
|
+
* bar button from a physical key: a sequence that merely looks plausible works
|
|
12
|
+
* in bash and breaks in vim. Three of those rules are the ones this file exists
|
|
13
|
+
* to pin — a modifier is a CSI PARAMETER and not an ESC prefix, application
|
|
14
|
+
* cursor mode swaps in the SS3 spelling, and Ctrl on a literal key is a latch
|
|
15
|
+
* for typed text rather than a byte to fold.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, expect, test } from 'bun:test';
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
NO_MODIFIERS,
|
|
22
|
+
TERMINAL_KEYS,
|
|
23
|
+
TERMINAL_SYMBOLS,
|
|
24
|
+
applyModifiers,
|
|
25
|
+
ctrlCharacter,
|
|
26
|
+
resolveKeySequence,
|
|
27
|
+
type TerminalModifiers,
|
|
28
|
+
} from '@/shared/lib/workspace/terminal/keys';
|
|
29
|
+
|
|
30
|
+
const CTRL: TerminalModifiers = { ctrl: true, alt: false };
|
|
31
|
+
const ALT: TerminalModifiers = { ctrl: false, alt: true };
|
|
32
|
+
|
|
33
|
+
describe('resolveKeySequence — cursor keys', () => {
|
|
34
|
+
test('unmodified arrows use CSI', () => {
|
|
35
|
+
expect(resolveKeySequence('up')).toBe('\x1b[A');
|
|
36
|
+
expect(resolveKeySequence('down')).toBe('\x1b[B');
|
|
37
|
+
expect(resolveKeySequence('right')).toBe('\x1b[C');
|
|
38
|
+
expect(resolveKeySequence('left')).toBe('\x1b[D');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('application cursor mode swaps in the SS3 spelling', () => {
|
|
42
|
+
const options = { applicationCursorKeys: true };
|
|
43
|
+
expect(resolveKeySequence('up', options)).toBe('\x1bOA');
|
|
44
|
+
expect(resolveKeySequence('left', options)).toBe('\x1bOD');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('Home and End always use the terminfo spelling, DECCKM or not', () => {
|
|
48
|
+
// `TERM=xterm-256color` declares khome/kend as SS3, so the CSI form is a
|
|
49
|
+
// sequence the shell does not bind: zsh rings the bell and ignores it.
|
|
50
|
+
expect(resolveKeySequence('home')).toBe('\x1bOH');
|
|
51
|
+
expect(resolveKeySequence('end')).toBe('\x1bOF');
|
|
52
|
+
expect(resolveKeySequence('home', { applicationCursorKeys: true })).toBe('\x1bOH');
|
|
53
|
+
expect(resolveKeySequence('end', { applicationCursorKeys: true })).toBe('\x1bOF');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a modifier beats application cursor mode, as xterm does', () => {
|
|
57
|
+
const options = { applicationCursorKeys: true, modifiers: CTRL };
|
|
58
|
+
expect(resolveKeySequence('up', options)).toBe('\x1b[1;5A');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a modifier is a CSI parameter, never an ESC prefix', () => {
|
|
62
|
+
expect(resolveKeySequence('up', { modifiers: CTRL })).toBe('\x1b[1;5A');
|
|
63
|
+
expect(resolveKeySequence('up', { modifiers: ALT })).toBe('\x1b[1;3A');
|
|
64
|
+
expect(resolveKeySequence('up', { modifiers: { ctrl: true, alt: true } })).toBe('\x1b[1;7A');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('resolveKeySequence — keys with a fixed spelling', () => {
|
|
69
|
+
test('an unmodified page/delete key drops its leading 1', () => {
|
|
70
|
+
expect(resolveKeySequence('delete')).toBe('\x1b[3~');
|
|
71
|
+
expect(resolveKeySequence('pageUp')).toBe('\x1b[5~');
|
|
72
|
+
expect(resolveKeySequence('pageDown')).toBe('\x1b[6~');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('page keys carry their prefix once modified', () => {
|
|
76
|
+
expect(resolveKeySequence('pageUp', { modifiers: CTRL })).toBe('\x1b[5;5~');
|
|
77
|
+
expect(resolveKeySequence('delete', { modifiers: ALT })).toBe('\x1b[3;3~');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a page key has no SS3 form, so DECCKM does not touch it', () => {
|
|
81
|
+
expect(resolveKeySequence('pageUp', { applicationCursorKeys: true })).toBe('\x1b[5~');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('escape, tab and shift-tab are literal', () => {
|
|
85
|
+
expect(resolveKeySequence('escape')).toBe('\x1b');
|
|
86
|
+
expect(resolveKeySequence('tab')).toBe('\t');
|
|
87
|
+
expect(resolveKeySequence('shiftTab')).toBe('\x1b[Z');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('resolveKeySequence — modifiers on a literal key', () => {
|
|
92
|
+
test('Ctrl does NOT fold a literal key into a control byte', () => {
|
|
93
|
+
// The `^C` button is the only Ctrl+C. A tapped `|` stays a pipe: folding it
|
|
94
|
+
// would send FS (0x1c), which is not a pipe and not a signal.
|
|
95
|
+
expect(resolveKeySequence('pipe', { modifiers: CTRL })).toBe('|');
|
|
96
|
+
expect(resolveKeySequence('underscore', { modifiers: CTRL })).toBe('_');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('Alt on a literal key is the ESC prefix', () => {
|
|
100
|
+
expect(resolveKeySequence('pipe', { modifiers: ALT })).toBe('\x1b|');
|
|
101
|
+
expect(resolveKeySequence('escape', { modifiers: ALT })).toBe('\x1b\x1b');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('the dedicated control keys are already the folded byte', () => {
|
|
105
|
+
expect(resolveKeySequence('interrupt')).toBe('\x03');
|
|
106
|
+
expect(resolveKeySequence('eof')).toBe('\x04');
|
|
107
|
+
expect(resolveKeySequence('clear')).toBe('\x0c');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('resolveKeySequence — defaults and coverage', () => {
|
|
112
|
+
test('no options means no modifier and no application mode', () => {
|
|
113
|
+
// `pageUp` is the plain CSI default; Home/End carry a terminfo spelling.
|
|
114
|
+
expect(resolveKeySequence('pageUp')).toBe('\x1b[5~');
|
|
115
|
+
expect(NO_MODIFIERS).toEqual({ ctrl: false, alt: false });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('every control key resolves to bytes', () => {
|
|
119
|
+
for (const def of TERMINAL_KEYS) {
|
|
120
|
+
expect(resolveKeySequence(def.id).length, def.id).toBeGreaterThan(0);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('every symbol resolves to its own literal', () => {
|
|
125
|
+
for (const def of TERMINAL_SYMBOLS) {
|
|
126
|
+
expect(resolveKeySequence(def.id), def.id).toBe(def.sequence);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('the symbol layer covers the characters a phone buries', () => {
|
|
131
|
+
const labels = new Set(TERMINAL_SYMBOLS.map((def) => def.label));
|
|
132
|
+
for (const required of ['|', '~', '\\', '$', '*', '?', '&', '<', '>', '`', '{', '}', '[', ']', '"', "'"]) {
|
|
133
|
+
expect(labels.has(required), required).toBe(true);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a symbol under a latched Alt is an ESC prefix, like any literal', () => {
|
|
138
|
+
expect(resolveKeySequence('dollar', { modifiers: ALT })).toBe('\x1b$');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('an unknown id sends nothing rather than a guess', () => {
|
|
142
|
+
expect(resolveKeySequence('nope' as never)).toBe('');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('ctrlCharacter', () => {
|
|
147
|
+
test('letters fold case-insensitively onto C0', () => {
|
|
148
|
+
expect(ctrlCharacter('c')).toBe('\x03');
|
|
149
|
+
expect(ctrlCharacter('C')).toBe('\x03');
|
|
150
|
+
expect(ctrlCharacter('a')).toBe('\x01');
|
|
151
|
+
expect(ctrlCharacter('z')).toBe('\x1a');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('the symbol set matches the terminal, not the ASCII table', () => {
|
|
155
|
+
expect(ctrlCharacter('@')).toBe('\x00');
|
|
156
|
+
expect(ctrlCharacter(' ')).toBe('\x00');
|
|
157
|
+
expect(ctrlCharacter('[')).toBe('\x1b');
|
|
158
|
+
expect(ctrlCharacter('_')).toBe('\x1f');
|
|
159
|
+
expect(ctrlCharacter('?')).toBe('\x7f');
|
|
160
|
+
expect(ctrlCharacter('!')).toBeNull();
|
|
161
|
+
expect(ctrlCharacter('ab')).toBeNull();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('applyModifiers', () => {
|
|
166
|
+
test('a latched Ctrl folds a typed letter', () => {
|
|
167
|
+
expect(applyModifiers('c', CTRL)).toBe('\x03');
|
|
168
|
+
expect(applyModifiers('r', CTRL)).toBe('\x12');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('a latched Alt prefixes ESC', () => {
|
|
172
|
+
expect(applyModifiers('b', ALT)).toBe('\x1bb');
|
|
173
|
+
expect(applyModifiers('f', { ctrl: true, alt: true })).toBe('\x1b\x06');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('no modifier is the identity', () => {
|
|
177
|
+
expect(applyModifiers('c', NO_MODIFIERS)).toBe('c');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('a paste is left intact — the latch does not corrupt it', () => {
|
|
181
|
+
const pasted = 'bun run build\n';
|
|
182
|
+
expect(applyModifiers(pasted, CTRL)).toBe(pasted);
|
|
183
|
+
expect(applyModifiers(pasted, ALT)).toBe(pasted);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('a character with no control code passes through', () => {
|
|
187
|
+
expect(applyModifiers('!', CTRL)).toBe('!');
|
|
188
|
+
});
|
|
189
|
+
});
|