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
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { Check, ClipboardCopy, ClipboardPaste } from 'lucide-preact';
|
|
2
|
+
import { useState } from 'preact/hooks';
|
|
3
|
+
import {
|
|
4
|
+
TERMINAL_KEYS,
|
|
5
|
+
TERMINAL_SYMBOLS,
|
|
6
|
+
type TerminalKeyId,
|
|
7
|
+
type TerminalModifier,
|
|
8
|
+
type TerminalModifiers,
|
|
9
|
+
} from '@/shared/lib/workspace/terminal/keys';
|
|
10
|
+
|
|
11
|
+
interface TerminalKeyBarProps {
|
|
12
|
+
/** Latched modifiers — a tap arms one, and the next key consumes it. */
|
|
13
|
+
modifiers: TerminalModifiers;
|
|
14
|
+
onToggleModifier: (modifier: TerminalModifier) => void;
|
|
15
|
+
onKey: (id: TerminalKeyId) => void;
|
|
16
|
+
/** Cmd+C in a terminal: copy the selection. Resolves false when there was none. */
|
|
17
|
+
onCopy: () => Promise<boolean>;
|
|
18
|
+
/** Cmd+V in a terminal: paste into the shell. Resolves false on a denied clipboard. */
|
|
19
|
+
onPaste: () => Promise<boolean>;
|
|
20
|
+
/** The shell is not running; a key would be sent into nothing. */
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MODIFIERS: readonly { modifier: TerminalModifier; label: string; name: string }[] = [
|
|
25
|
+
{ modifier: 'ctrl', label: 'Ctrl', name: 'Ctrl — applies to the next key' },
|
|
26
|
+
{ modifier: 'alt', label: 'Alt', name: 'Alt / meta — applies to the next key' },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const KEY_CLASS =
|
|
30
|
+
'flex items-center justify-center h-8 min-w-8 px-1.5 rounded border font-mono transition-colors ' +
|
|
31
|
+
'cursor-pointer select-none disabled:opacity-40 disabled:cursor-default ' +
|
|
32
|
+
'text-[11px] @[420px]:h-9 @[420px]:min-w-10 @[420px]:text-xs';
|
|
33
|
+
|
|
34
|
+
const IDLE_CLASS = 'bg-canvas text-ink/80 border-ink/15 hover:text-ink hover:bg-ink/5 active:bg-ink/10';
|
|
35
|
+
const ARMED_CLASS = 'bg-ink text-canvas border-ink';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The keys a phone's soft keyboard does not have.
|
|
39
|
+
*
|
|
40
|
+
* Every button sends raw bytes into the live PTY, so what the shell sees is
|
|
41
|
+
* identical to a physical keypress (the encoding, and the three rules it obeys,
|
|
42
|
+
* are in `keys.ts`). Ctrl and Alt are LATCHES rather than keys: tapping one arms
|
|
43
|
+
* it, and the next thing you send — a button here or a letter typed on the soft
|
|
44
|
+
* keyboard — carries it, which is the only way a phone reaches Ctrl+C, Ctrl+R
|
|
45
|
+
* or Alt+B.
|
|
46
|
+
*
|
|
47
|
+
* **Copy and Paste stand in for Cmd+C / Cmd+V**, which is what those chords mean
|
|
48
|
+
* in a terminal and the one thing a phone genuinely cannot do: it has no
|
|
49
|
+
* selection to right-click and no paste without a long-press the terminal canvas
|
|
50
|
+
* does not offer. There is no raw Cmd button because xterm discards `metaKey`
|
|
51
|
+
* on every key but Cmd+A (`if (ev.metaKey) break;`, which routes Cmd+A to
|
|
52
|
+
* select-all) — a Cmd button would send nothing, while the Cmd chords the user
|
|
53
|
+
* does have keep working untouched. Copy reports when there was no selection
|
|
54
|
+
* rather than flashing a check for a clipboard it did not fill.
|
|
55
|
+
*
|
|
56
|
+
* `pointerdown` is swallowed so tapping a key never moves focus out of xterm's
|
|
57
|
+
* hidden textarea: blurring it closes the OS keyboard, and a key bar that makes
|
|
58
|
+
* the keyboard disappear is worse than no key bar.
|
|
59
|
+
*
|
|
60
|
+
* Wrapping, not horizontal scrolling. The diff toolbar taught that a
|
|
61
|
+
* `no-scrollbar` overflow hides actions with nothing on screen to say so
|
|
62
|
+
* (measured: 361px of content in a 296px box, the last action entirely
|
|
63
|
+
* outside); a narrow panel here gets three rows of keys instead, and the
|
|
64
|
+
* container query grows them back on a wide one. The container is this bar's
|
|
65
|
+
* own root, so the breakpoints follow the PANEL — 320px on a desktop is the
|
|
66
|
+
* same problem as 320px on a phone.
|
|
67
|
+
*/
|
|
68
|
+
export function TerminalKeyBar({
|
|
69
|
+
modifiers,
|
|
70
|
+
onToggleModifier,
|
|
71
|
+
onKey,
|
|
72
|
+
onCopy,
|
|
73
|
+
onPaste,
|
|
74
|
+
disabled,
|
|
75
|
+
}: TerminalKeyBarProps) {
|
|
76
|
+
const [symbols, setSymbols] = useState(false);
|
|
77
|
+
const [feedback, setFeedback] = useState<'copied' | 'empty' | 'pasteFailed' | null>(null);
|
|
78
|
+
const latched = modifiers.ctrl || modifiers.alt;
|
|
79
|
+
|
|
80
|
+
const flash = (next: typeof feedback) => {
|
|
81
|
+
setFeedback(next);
|
|
82
|
+
setTimeout(() => setFeedback((current) => (current === next ? null : current)), 1500);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const handleCopy = async () => {
|
|
86
|
+
flash((await onCopy()) ? 'copied' : 'empty');
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const handlePaste = async () => {
|
|
90
|
+
if (!(await onPaste())) flash('pasteFailed');
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div
|
|
95
|
+
className="@container flex flex-col gap-1 px-2 py-1.5 border-t border-ink/10 bg-paper flex-shrink-0"
|
|
96
|
+
onPointerDown={(event) => event.preventDefault()}
|
|
97
|
+
>
|
|
98
|
+
{/* Action row: the two latches, the Cmd pair, and the layer switch. */}
|
|
99
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
100
|
+
{MODIFIERS.map(({ modifier, label, name }) => (
|
|
101
|
+
<button
|
|
102
|
+
key={modifier}
|
|
103
|
+
type="button"
|
|
104
|
+
onClick={() => onToggleModifier(modifier)}
|
|
105
|
+
aria-pressed={modifiers[modifier]}
|
|
106
|
+
title={name}
|
|
107
|
+
aria-label={name}
|
|
108
|
+
className={`${KEY_CLASS} ${modifiers[modifier] ? ARMED_CLASS : IDLE_CLASS}`}
|
|
109
|
+
>
|
|
110
|
+
{label}
|
|
111
|
+
</button>
|
|
112
|
+
))}
|
|
113
|
+
|
|
114
|
+
{/* A latch is armed, so say so: an invisible Ctrl turns the next tap into
|
|
115
|
+
a signal the user never asked for. */}
|
|
116
|
+
{latched && (
|
|
117
|
+
<span className="text-[10px] font-mono text-ink/50 px-0.5 whitespace-nowrap" aria-hidden>
|
|
118
|
+
next
|
|
119
|
+
</span>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
<span className="w-px h-5 bg-ink/15 flex-shrink-0" aria-hidden />
|
|
123
|
+
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
onClick={handleCopy}
|
|
127
|
+
disabled={disabled}
|
|
128
|
+
title={feedback === 'empty' ? 'Nothing selected' : 'Copy selection'}
|
|
129
|
+
aria-label="Copy selection"
|
|
130
|
+
className={`${KEY_CLASS} ${
|
|
131
|
+
feedback === 'empty'
|
|
132
|
+
? 'bg-warning/10 text-warning border-warning/30'
|
|
133
|
+
: IDLE_CLASS
|
|
134
|
+
}`}
|
|
135
|
+
>
|
|
136
|
+
{feedback === 'copied' ? <Check size={13} /> : <ClipboardCopy size={13} />}
|
|
137
|
+
{/* Labelled, not icon-only: `ClipboardCopy` and `ClipboardPaste` are
|
|
138
|
+
the same 13px clipboard silhouette at a glance, so on a phone the
|
|
139
|
+
pair read as two identical buttons — the one thing a key bar must
|
|
140
|
+
never be. The label is what makes them tell themselves apart. */}
|
|
141
|
+
<span className="pl-1">Copy</span>
|
|
142
|
+
</button>
|
|
143
|
+
|
|
144
|
+
<button
|
|
145
|
+
type="button"
|
|
146
|
+
onClick={handlePaste}
|
|
147
|
+
disabled={disabled}
|
|
148
|
+
title={feedback === 'pasteFailed' ? 'Clipboard unavailable' : 'Paste into the shell'}
|
|
149
|
+
aria-label="Paste into the shell"
|
|
150
|
+
className={`${KEY_CLASS} ${
|
|
151
|
+
feedback === 'pasteFailed'
|
|
152
|
+
? 'bg-error/10 text-error border-error/30'
|
|
153
|
+
: IDLE_CLASS
|
|
154
|
+
}`}
|
|
155
|
+
>
|
|
156
|
+
<ClipboardPaste size={13} />
|
|
157
|
+
<span className="pl-1">Paste</span>
|
|
158
|
+
</button>
|
|
159
|
+
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
onClick={() => setSymbols((previous) => !previous)}
|
|
163
|
+
aria-pressed={symbols}
|
|
164
|
+
title={symbols ? 'Show control keys' : 'Show symbols'}
|
|
165
|
+
aria-label={symbols ? 'Show control keys' : 'Show symbols'}
|
|
166
|
+
className={`${KEY_CLASS} ml-auto ${symbols ? ARMED_CLASS : IDLE_CLASS}`}
|
|
167
|
+
>
|
|
168
|
+
{/* `123` / `ABC`, the labels every phone keyboard already uses for its
|
|
169
|
+
own symbol layer. An icon here was a metaphor the user had to
|
|
170
|
+
decode; the convention needs no decoding. */}
|
|
171
|
+
{symbols ? 'ABC' : '123'}
|
|
172
|
+
</button>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
{/* Key row: the same set swaps in place, the way a phone keyboard's own
|
|
176
|
+
layer key works — two sets stacked would cost the canvas twice. */}
|
|
177
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
178
|
+
{(symbols ? TERMINAL_SYMBOLS : TERMINAL_KEYS).map((def) => (
|
|
179
|
+
<button
|
|
180
|
+
key={def.id}
|
|
181
|
+
type="button"
|
|
182
|
+
disabled={disabled}
|
|
183
|
+
onClick={() => onKey(def.id)}
|
|
184
|
+
title={def.name}
|
|
185
|
+
aria-label={def.name}
|
|
186
|
+
className={`${KEY_CLASS} ${IDLE_CLASS}`}
|
|
187
|
+
>
|
|
188
|
+
{def.label}
|
|
189
|
+
</button>
|
|
190
|
+
))}
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -17,6 +17,21 @@ export interface RealtimeXtermHandle {
|
|
|
17
17
|
/** Drop the buffer and reset the cursor — used when the shell is replaced. */
|
|
18
18
|
reset: () => void;
|
|
19
19
|
focus: () => void;
|
|
20
|
+
/**
|
|
21
|
+
* The shell's DECCKM state, read at the moment a key bar button is pressed.
|
|
22
|
+
* A full-screen app (vim, less) sets it and then expects the SS3 spelling of
|
|
23
|
+
* the arrows, so the encoding cannot be decided once at mount.
|
|
24
|
+
*/
|
|
25
|
+
applicationCursorKeys: () => boolean;
|
|
26
|
+
/** The current selection, empty when nothing is selected. */
|
|
27
|
+
getSelection: () => string;
|
|
28
|
+
/**
|
|
29
|
+
* Feed text in as a paste. Goes through xterm's own `paste`, which is what
|
|
30
|
+
* wraps it in the bracketed-paste markers when the shell asked for them —
|
|
31
|
+
* writing the bytes directly would make a multi-line paste execute line by
|
|
32
|
+
* line in an editor that expects one block.
|
|
33
|
+
*/
|
|
34
|
+
paste: (text: string) => void;
|
|
20
35
|
}
|
|
21
36
|
|
|
22
37
|
interface RealtimeXtermViewProps {
|
|
@@ -76,6 +91,15 @@ export const RealtimeXtermView = forwardRef<RealtimeXtermHandle, RealtimeXtermVi
|
|
|
76
91
|
focus() {
|
|
77
92
|
mountRef.current?.term.focus();
|
|
78
93
|
},
|
|
94
|
+
applicationCursorKeys() {
|
|
95
|
+
return mountRef.current?.term.modes.applicationCursorKeysMode ?? false;
|
|
96
|
+
},
|
|
97
|
+
getSelection() {
|
|
98
|
+
return mountRef.current?.term.getSelection() ?? '';
|
|
99
|
+
},
|
|
100
|
+
paste(text: string) {
|
|
101
|
+
mountRef.current?.term.paste(text);
|
|
102
|
+
},
|
|
79
103
|
}), []);
|
|
80
104
|
|
|
81
105
|
const scrollToBottom = useCallback(() => {
|
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
import { useCallback, useRef } from 'preact/hooks';
|
|
1
|
+
import { useCallback, useRef, useState } from 'preact/hooks';
|
|
2
2
|
import { useTerminal } from '@/client/hooks/workspace/terminal';
|
|
3
3
|
import { useRepoList, useRepoScope } from '@/client/hooks/workspace/repo-scope';
|
|
4
4
|
import { useTheme } from '@/client/hooks/ui/theme';
|
|
5
|
+
import { useTouchDevice } from '@/client/hooks/ui/touch-device';
|
|
6
|
+
import { useKeyboardInset } from '@/client/hooks/ui/keyboard-inset';
|
|
7
|
+
import { copyToClipboard, readClipboardText } from '@/client/hooks/ui/clipboard';
|
|
5
8
|
import { TerminalHeader } from '@/client/components/workspace/terminal-panel/Header';
|
|
6
9
|
import { TerminalQuickActions } from '@/client/components/workspace/terminal-panel/QuickActions';
|
|
10
|
+
import { TerminalKeyBar } from '@/client/components/workspace/terminal-panel/KeyBar';
|
|
7
11
|
import { RealtimeXtermView, type RealtimeXtermHandle } from '@/client/components/workspace/terminal-panel/RealtimeXtermView';
|
|
12
|
+
import {
|
|
13
|
+
NO_MODIFIERS,
|
|
14
|
+
applyModifiers,
|
|
15
|
+
resolveKeySequence,
|
|
16
|
+
type TerminalKeyId,
|
|
17
|
+
type TerminalModifier,
|
|
18
|
+
type TerminalModifiers,
|
|
19
|
+
} from '@/shared/lib/workspace/terminal/keys';
|
|
8
20
|
|
|
9
21
|
interface TerminalPanelProps {
|
|
10
22
|
className?: string;
|
|
@@ -18,9 +30,22 @@ const encoder = new TextEncoder();
|
|
|
18
30
|
export function TerminalPanel({ className = '', enabled = true, rootPath, showHeader = true }: TerminalPanelProps) {
|
|
19
31
|
const xtermRef = useRef<RealtimeXtermHandle>(null);
|
|
20
32
|
const { isDark } = useTheme();
|
|
33
|
+
// Only a device without a physical keyboard gets the key bar: on a desktop
|
|
34
|
+
// every one of those keys is already under the user's fingers, and a strip of
|
|
35
|
+
// them would cost the canvas height for nothing.
|
|
36
|
+
const isTouchDevice = useTouchDevice();
|
|
37
|
+
// The inset is measured from the panel's OWN bottom edge, so it can never
|
|
38
|
+
// double-count a keyboard the container already shrank for.
|
|
39
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
40
|
+
const keyboardInset = useKeyboardInset(rootRef);
|
|
41
|
+
const [modifiers, setModifiers] = useState<TerminalModifiers>(NO_MODIFIERS);
|
|
21
42
|
const { activeRepo, setActiveRepo } = useRepoScope(rootPath, 'terminal.activeRepo');
|
|
22
43
|
const { repos, scanning: reposScanning, rescan: rescanRepos } = useRepoList(rootPath, enabled);
|
|
23
44
|
const gridRef = useRef<{ cols: number; rows: number } | null>(null);
|
|
45
|
+
// The latch is read inside `handleInput`, which xterm calls outside React's
|
|
46
|
+
// render — a ref is what makes it the value at the keystroke, not at mount.
|
|
47
|
+
const modifiersRef = useRef(modifiers);
|
|
48
|
+
modifiersRef.current = modifiers;
|
|
24
49
|
|
|
25
50
|
const handleOutput = useCallback((bytes: Uint8Array) => {
|
|
26
51
|
xtermRef.current?.write(bytes);
|
|
@@ -70,10 +95,59 @@ export function TerminalPanel({ className = '', enabled = true, rootPath, showHe
|
|
|
70
95
|
|
|
71
96
|
// Keystrokes arrive as text; `onBinary` payloads are latin-1 bytes and must
|
|
72
97
|
// not be UTF-8 encoded on the way out.
|
|
98
|
+
//
|
|
99
|
+
// A latched modifier is folded in here, on the way out, because that is the
|
|
100
|
+
// only place a soft keyboard's letters pass through — Ctrl+C on a phone is a
|
|
101
|
+
// tap on `Ctrl` and then the `c` the OS keyboard produces.
|
|
73
102
|
const handleInput = useCallback((data: string) => {
|
|
74
|
-
|
|
103
|
+
const latched = modifiersRef.current;
|
|
104
|
+
const armed = latched.ctrl || latched.alt;
|
|
105
|
+
const encoded = applyModifiers(data, latched);
|
|
106
|
+
// Only a single typed character consumes the latch. A paste, a mouse report
|
|
107
|
+
// or a focus event arrives on this same event and must leave the armed
|
|
108
|
+
// modifier alone, or the tap that armed it is silently spent on a no-op.
|
|
109
|
+
if (armed && data.length === 1) setModifiers(NO_MODIFIERS);
|
|
110
|
+
input(encoder.encode(encoded));
|
|
75
111
|
}, [input]);
|
|
76
112
|
|
|
113
|
+
const handleToggleModifier = useCallback((modifier: TerminalModifier) => {
|
|
114
|
+
setModifiers((previous) => ({ ...previous, [modifier]: !previous[modifier] }));
|
|
115
|
+
xtermRef.current?.focus();
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
const handleKey = useCallback((id: TerminalKeyId) => {
|
|
119
|
+
const sequence = resolveKeySequence(id, {
|
|
120
|
+
applicationCursorKeys: xtermRef.current?.applicationCursorKeys() ?? false,
|
|
121
|
+
modifiers: modifiersRef.current,
|
|
122
|
+
});
|
|
123
|
+
if (!sequence) return;
|
|
124
|
+
input(encoder.encode(sequence));
|
|
125
|
+
setModifiers(NO_MODIFIERS);
|
|
126
|
+
// Keep the OS keyboard where it was; a key bar tap must not dismiss it.
|
|
127
|
+
xtermRef.current?.focus();
|
|
128
|
+
}, [input]);
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Cmd+C / Cmd+V, the two chords a terminal actually has — a phone can neither
|
|
132
|
+
* select on the canvas nor reach its own clipboard from there. Copy reports
|
|
133
|
+
* whether it had anything to copy so the bar can say "nothing selected"
|
|
134
|
+
* instead of flashing success over an untouched clipboard.
|
|
135
|
+
*/
|
|
136
|
+
const handleCopy = useCallback(async () => {
|
|
137
|
+
const selection = xtermRef.current?.getSelection() ?? '';
|
|
138
|
+
if (!selection) return false;
|
|
139
|
+
return copyToClipboard(selection);
|
|
140
|
+
}, []);
|
|
141
|
+
|
|
142
|
+
const handlePaste = useCallback(async () => {
|
|
143
|
+
const text = await readClipboardText();
|
|
144
|
+
if (text === null || text.length === 0) return false;
|
|
145
|
+
// Through xterm's own paste, so a bracketed-paste shell gets its markers.
|
|
146
|
+
xtermRef.current?.paste(text);
|
|
147
|
+
xtermRef.current?.focus();
|
|
148
|
+
return true;
|
|
149
|
+
}, []);
|
|
150
|
+
|
|
77
151
|
const handleBinaryInput = useCallback((data: string) => {
|
|
78
152
|
const bytes = new Uint8Array(data.length);
|
|
79
153
|
for (let i = 0; i < data.length; i += 1) bytes[i] = data.charCodeAt(i) & 0xff;
|
|
@@ -98,7 +172,17 @@ export function TerminalPanel({ className = '', enabled = true, rootPath, showHe
|
|
|
98
172
|
}
|
|
99
173
|
|
|
100
174
|
return (
|
|
101
|
-
<div
|
|
175
|
+
<div
|
|
176
|
+
ref={rootRef}
|
|
177
|
+
className={`flex flex-col h-full w-full bg-paper text-ink overflow-hidden ${className}`}
|
|
178
|
+
// The soft keyboard covers the bottom of the visual viewport without
|
|
179
|
+
// shrinking the layout one, so the key bar would sit behind the keys the
|
|
180
|
+
// user is typing on. Lifting by the panel's actual overlap with the
|
|
181
|
+
// visible area keeps the bar — and the shell's own grid, which the fit
|
|
182
|
+
// observer re-measures — above the keyboard, and lifts nothing at all
|
|
183
|
+
// when the container already ends above it.
|
|
184
|
+
style={keyboardInset > 0 ? { paddingBottom: keyboardInset } : undefined}
|
|
185
|
+
>
|
|
102
186
|
{showHeader && (
|
|
103
187
|
<TerminalHeader
|
|
104
188
|
status={status}
|
|
@@ -135,6 +219,17 @@ export function TerminalPanel({ className = '', enabled = true, rootPath, showHe
|
|
|
135
219
|
onGridChange={handleGridChange}
|
|
136
220
|
/>
|
|
137
221
|
</div>
|
|
222
|
+
|
|
223
|
+
{isTouchDevice && (
|
|
224
|
+
<TerminalKeyBar
|
|
225
|
+
modifiers={modifiers}
|
|
226
|
+
onToggleModifier={handleToggleModifier}
|
|
227
|
+
onKey={handleKey}
|
|
228
|
+
onCopy={handleCopy}
|
|
229
|
+
onPaste={handlePaste}
|
|
230
|
+
disabled={status !== 'running'}
|
|
231
|
+
/>
|
|
232
|
+
)}
|
|
138
233
|
</div>
|
|
139
234
|
);
|
|
140
235
|
}
|
|
@@ -34,6 +34,7 @@ import type { FitAddon } from '@xterm/addon-fit';
|
|
|
34
34
|
import type { WebglAddon } from '@xterm/addon-webgl';
|
|
35
35
|
import {
|
|
36
36
|
getXtermTheme,
|
|
37
|
+
NERD_SYMBOLS_FAMILY,
|
|
37
38
|
XTERM_FONT_FAMILY,
|
|
38
39
|
safePatchFitAddon,
|
|
39
40
|
safePatchRenderService,
|
|
@@ -127,6 +128,11 @@ export async function mountXterm(
|
|
|
127
128
|
const fitAddon = new FitAddonClass() as FitAddon;
|
|
128
129
|
term.loadAddon(fitAddon);
|
|
129
130
|
|
|
131
|
+
// The bundled Nerd Font symbols face may still be in flight (a phone has no
|
|
132
|
+
// Nerd Font of its own). See the helper for why the atlas has to be dropped
|
|
133
|
+
// once it lands.
|
|
134
|
+
const stopNerdFaceWatch = refreshAtlasWhenNerdFaceLoads(term);
|
|
135
|
+
|
|
130
136
|
term.attachCustomKeyEventHandler((event: globalThis.KeyboardEvent) => handleKeyNavigation(term, event));
|
|
131
137
|
|
|
132
138
|
const disposables = [
|
|
@@ -228,6 +234,7 @@ export async function mountXterm(
|
|
|
228
234
|
dispose() {
|
|
229
235
|
if (frame !== null) cancelAnimationFrame(frame);
|
|
230
236
|
clearTimeout(fitTimer ?? undefined);
|
|
237
|
+
stopNerdFaceWatch();
|
|
231
238
|
touch();
|
|
232
239
|
for (const disposable of disposables) disposable.dispose();
|
|
233
240
|
resizeObserver.disconnect();
|
|
@@ -241,6 +248,46 @@ export async function mountXterm(
|
|
|
241
248
|
};
|
|
242
249
|
}
|
|
243
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Re-rasterize the terminal once the bundled symbols face has loaded.
|
|
253
|
+
*
|
|
254
|
+
* xterm has no `document.fonts` listener: it rasterizes a glyph the first time
|
|
255
|
+
* it draws it and keeps the result in a texture atlas, so a PUA glyph written
|
|
256
|
+
* before the face arrived stays whatever the fallback painted — a tofu box —
|
|
257
|
+
* for the life of the buffer. The browser does re-lay-out DOM text when a face
|
|
258
|
+
* lands late (verified on the DOM renderer), but xterm's canvas does not get
|
|
259
|
+
* that for free.
|
|
260
|
+
*
|
|
261
|
+
* `loadingdone` naming our own family is exactly the signal that this machine
|
|
262
|
+
* had no Nerd Font of its own: the face is declared last in the stack and its
|
|
263
|
+
* `unicode-range` covers only PUA, so a device with an installed Nerd Font
|
|
264
|
+
* never fetches it (verified: zero requests with a local family present). That
|
|
265
|
+
* makes this a no-op on the developer's laptop and the fix on a phone.
|
|
266
|
+
*
|
|
267
|
+
* `clearTextureAtlas()` drops the atlas and forces a full refresh, which is
|
|
268
|
+
* what re-rasterizes the glyphs. It is a public API on `Terminal`, and the
|
|
269
|
+
* atlas is small enough that a full refresh is cheap — this runs at most once
|
|
270
|
+
* per page load.
|
|
271
|
+
*
|
|
272
|
+
* Returns a disposer.
|
|
273
|
+
*/
|
|
274
|
+
function refreshAtlasWhenNerdFaceLoads(term: Terminal): () => void {
|
|
275
|
+
if (typeof document === 'undefined' || !document.fonts) return () => {};
|
|
276
|
+
|
|
277
|
+
const onLoadingDone = (event: FontFaceSetLoadEvent) => {
|
|
278
|
+
const loaded = [...event.fontfaces].some((face) => face.family.replace(/['"]/g, '') === NERD_SYMBOLS_FAMILY);
|
|
279
|
+
if (!loaded) return;
|
|
280
|
+
try {
|
|
281
|
+
term.clearTextureAtlas();
|
|
282
|
+
} catch {
|
|
283
|
+
// A disposed terminal has no render service; nothing to refresh.
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
document.fonts.addEventListener('loadingdone', onLoadingDone);
|
|
288
|
+
return () => document.fonts.removeEventListener('loadingdone', onLoadingDone);
|
|
289
|
+
}
|
|
290
|
+
|
|
244
291
|
/** Shift+PageUp/Home/End scroll the viewport instead of reaching the shell. */
|
|
245
292
|
function handleKeyNavigation(term: Terminal, event: globalThis.KeyboardEvent): boolean {
|
|
246
293
|
if (event.type !== 'keydown' || !event.shiftKey) return true;
|
|
@@ -60,14 +60,21 @@ export function getXtermTheme(themeId: string) {
|
|
|
60
60
|
* Material Design icons) that **no** coding webfont contains — `@fontsource/
|
|
61
61
|
* fira-code`'s latin subset maps 226 codepoints and not one of them is above
|
|
62
62
|
* U+FFFF — so without a face that supplies them the browser paints a hollow
|
|
63
|
-
* tofu box for every prompt segment.
|
|
64
|
-
* families a user is most likely to have installed (plus the *fontconfig*
|
|
65
|
-
* symbol faces on Linux); a face that is absent is simply skipped by the
|
|
66
|
-
* normal fallback chain, so listing many costs nothing.
|
|
63
|
+
* tofu box for every prompt segment.
|
|
67
64
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
65
|
+
* The entries before the last one name the Nerd Font families a user is most
|
|
66
|
+
* likely to have installed (plus the *fontconfig* symbol faces on Linux); a
|
|
67
|
+
* face that is absent is simply skipped by the normal fallback chain, so
|
|
68
|
+
* listing many costs nothing.
|
|
69
|
+
*
|
|
70
|
+
* `'OMPChamber Nerd Symbols'` is the one the chamber bundles itself
|
|
71
|
+
* (`src/shared/lib/fonts/nerd-symbols.css`), and it is deliberately LAST: a
|
|
72
|
+
* machine that already has a Nerd Font renders from it and never fetches these
|
|
73
|
+
* bytes, while a phone — which has none — falls through to it instead of
|
|
74
|
+
* painting tofu. Verified: with the bundled face last and a local Nerd Font
|
|
75
|
+
* ahead of it, laying out a PUA glyph issued zero font requests; with no local
|
|
76
|
+
* family present, the face is fetched and all of the prompt's codepoints
|
|
77
|
+
* render.
|
|
71
78
|
*
|
|
72
79
|
* Only glyphs the text face lacks reach these entries, so Latin metrics are
|
|
73
80
|
* untouched — verified: `A`/`m`/`W` keep identical advance and ink.
|
|
@@ -78,7 +85,17 @@ export const XTERM_GLYPH_FALLBACKS =
|
|
|
78
85
|
"'MesloLGM Nerd Font', 'CaskaydiaCove Nerd Font', 'SauceCodePro Nerd Font', " +
|
|
79
86
|
"'Symbols Nerd Font Mono', 'Symbols Nerd Font', 'PowerlineSymbols'";
|
|
80
87
|
|
|
81
|
-
|
|
88
|
+
/**
|
|
89
|
+
* The bundled symbols face, kept separate so `mount.ts` can await it by name.
|
|
90
|
+
*
|
|
91
|
+
* It must stay last in the stack (see above) and its family name must match the
|
|
92
|
+
* `@font-face` in `nerd-symbols.css` — the two are one contract, which is why
|
|
93
|
+
* the name is a constant rather than a literal in each file.
|
|
94
|
+
*/
|
|
95
|
+
export const NERD_SYMBOLS_FAMILY = 'OMPChamber Nerd Symbols';
|
|
96
|
+
|
|
97
|
+
export const XTERM_FONT_FAMILY =
|
|
98
|
+
`'Fira Code', ${XTERM_GLYPH_FALLBACKS}, '${NERD_SYMBOLS_FAMILY}', Menlo, Monaco, 'Courier New', monospace`;
|
|
82
99
|
|
|
83
100
|
/** xterm's private render service, read only to size-check before fitting. */
|
|
84
101
|
export interface XtermCore {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The editor font setting, and how a live change reaches an open editor.
|
|
8
|
+
*
|
|
9
|
+
* The setting is visual, so it has to apply to the editor the user is looking
|
|
10
|
+
* at — not on the next reload. That rules out reading it once from the
|
|
11
|
+
* bootstrap payload the way `keybindingSend` is read: `appSettings` is a prop
|
|
12
|
+
* captured at boot, so a settings change would not reach an already-mounted
|
|
13
|
+
* editor. The theme solved the same problem with a window event, and this
|
|
14
|
+
* follows it: the picker writes the setting AND announces it, and the editor
|
|
15
|
+
* subscribes.
|
|
16
|
+
*
|
|
17
|
+
* The stored value is the family name. `editorFontStack` puts it in front of
|
|
18
|
+
* the shared stack, so a face the device lacks falls through to the bundled
|
|
19
|
+
* Fira Code instead of replacing it — which is why the picker can offer faces
|
|
20
|
+
* the app does not ship.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
24
|
+
import { readChamberSetting } from '@/shared/lib/settings/client';
|
|
25
|
+
import {
|
|
26
|
+
EDITOR_DEFAULT_FONT_FAMILY,
|
|
27
|
+
editorFontStack,
|
|
28
|
+
} from '@/shared/lib/code/editor/typography';
|
|
29
|
+
|
|
30
|
+
/** Broadcast on every editor-font write. Detail is the new family name. */
|
|
31
|
+
export const EDITOR_FONT_CHANGED_EVENT = 'omp:editor-font-changed';
|
|
32
|
+
|
|
33
|
+
/** The family currently stored, falling back to the shipped default. */
|
|
34
|
+
export function currentEditorFont(): string {
|
|
35
|
+
if (typeof window === 'undefined') return EDITOR_DEFAULT_FONT_FAMILY;
|
|
36
|
+
return readChamberSetting<string>('editorFont') ?? EDITOR_DEFAULT_FONT_FAMILY;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The stored family and the stack it resolves to, kept in step with writes. */
|
|
40
|
+
export function useEditorFontStack(): string {
|
|
41
|
+
const [family, setFamily] = useState<string>(currentEditorFont);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const onChange = (event: Event) => {
|
|
45
|
+
const detail = (event as CustomEvent<string>).detail;
|
|
46
|
+
setFamily(detail || currentEditorFont());
|
|
47
|
+
};
|
|
48
|
+
window.addEventListener(EDITOR_FONT_CHANGED_EVENT, onChange);
|
|
49
|
+
return () => window.removeEventListener(EDITOR_FONT_CHANGED_EVENT, onChange);
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
return editorFontStack(family);
|
|
53
|
+
}
|
|
@@ -31,3 +31,20 @@ export async function copyToClipboard(text: string): Promise<boolean> {
|
|
|
31
31
|
return false;
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read text from the clipboard, or null when the browser refused.
|
|
37
|
+
*
|
|
38
|
+
* There is no fallback, and there cannot be one: `execCommand('paste')` is
|
|
39
|
+
* blocked by every browser and `navigator.clipboard.readText` needs a secure
|
|
40
|
+
* context plus a permission the user grants once — so a denial is reported
|
|
41
|
+
* rather than papered over with an empty paste.
|
|
42
|
+
*/
|
|
43
|
+
export async function readClipboardText(): Promise<string | null> {
|
|
44
|
+
try {
|
|
45
|
+
if (!navigator.clipboard || !window.isSecureContext) return null;
|
|
46
|
+
return await navigator.clipboard.readText();
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The keyboard-inset arithmetic.
|
|
8
|
+
*
|
|
9
|
+
* The failure this file exists for is an OVER-lift: computing the keyboard's
|
|
10
|
+
* height from `innerHeight - visualHeight` and applying it to a panel that does
|
|
11
|
+
* not reach the screen's bottom edge (a drawer with safe-area padding, a
|
|
12
|
+
* container a browser already shrank) lifts by the keyboard's height PLUS the
|
|
13
|
+
* gap the panel never had. Deriving the lift from the panel's own overlap makes
|
|
14
|
+
* every one of those cases zero.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test } from 'bun:test';
|
|
18
|
+
|
|
19
|
+
import { resolveKeyboardInset } from '@/client/hooks/ui/keyboard-inset';
|
|
20
|
+
|
|
21
|
+
describe('resolveKeyboardInset', () => {
|
|
22
|
+
test('a panel that clears the visible bottom is not lifted', () => {
|
|
23
|
+
// Keyboard open, panel already above it: nothing to do.
|
|
24
|
+
expect(resolveKeyboardInset(500, 508)).toBe(0);
|
|
25
|
+
expect(resolveKeyboardInset(508, 508)).toBe(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('a collapsed URL bar is not a keyboard', () => {
|
|
29
|
+
expect(resolveKeyboardInset(844, 780)).toBe(0);
|
|
30
|
+
expect(resolveKeyboardInset(844, 765)).toBe(0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('the lift is the overlap, not the keyboard height', () => {
|
|
34
|
+
// Panel bottom at 844, visible bottom at 508 -> 336px covered.
|
|
35
|
+
expect(resolveKeyboardInset(844, 508)).toBe(336);
|
|
36
|
+
// The same keyboard over a panel that ends 20px higher lifts 20px less,
|
|
37
|
+
// which is exactly the over-lift the height-difference form produced.
|
|
38
|
+
expect(resolveKeyboardInset(824, 508)).toBe(316);
|
|
39
|
+
expect(resolveKeyboardInset(508, 508)).toBe(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a nonsense visible bottom lifts nothing', () => {
|
|
43
|
+
expect(resolveKeyboardInset(844, 0)).toBe(0);
|
|
44
|
+
expect(resolveKeyboardInset(844, -100)).toBe(0);
|
|
45
|
+
});
|
|
46
|
+
});
|