mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-02 03:42:34 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae005b2956 | ||
|
|
f4ae05404c | ||
|
|
b7dcd4f740 | ||
|
|
dcec631871 | ||
|
|
64ce900fa8 | ||
|
|
b62cb9744c | ||
|
|
c6afda755c | ||
|
|
1628c52389 |
No files matched your search
@@ -76,6 +76,7 @@ on:
|
||||
- "scripts/mosh-extra-resources.cjs"
|
||||
- "scripts/nodePtyConptyPatch.cjs"
|
||||
- "scripts/patch-xterm-macos-column-selection.cjs"
|
||||
- "scripts/patch-xterm-foreground-intense.cjs"
|
||||
- "scripts/patch-xterm-webgl-atlas.cjs"
|
||||
- "scripts/rebuildPatchedNodePty.cjs"
|
||||
- "scripts/resolve-et-bin-release.cjs"
|
||||
|
||||
@@ -251,6 +251,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.customTheme.group.bright': 'Bright Colors',
|
||||
'terminal.customTheme.color.background': 'Background',
|
||||
'terminal.customTheme.color.foreground': 'Foreground',
|
||||
'terminal.customTheme.color.foregroundIntense': 'Intense foreground (bold)',
|
||||
'terminal.customTheme.color.cursor': 'Cursor',
|
||||
'terminal.customTheme.color.selection': 'Selection',
|
||||
'terminal.customTheme.color.black': 'Black',
|
||||
|
||||
@@ -251,6 +251,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.customTheme.group.bright': 'Colores brillantes',
|
||||
'terminal.customTheme.color.background': 'Fondo',
|
||||
'terminal.customTheme.color.foreground': 'Primer plano',
|
||||
'terminal.customTheme.color.foregroundIntense': 'Primer plano intenso (negrita)',
|
||||
'terminal.customTheme.color.cursor': 'Cursor',
|
||||
'terminal.customTheme.color.selection': 'Selección',
|
||||
'terminal.customTheme.color.black': 'Negro',
|
||||
|
||||
@@ -269,6 +269,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.customTheme.group.bright': 'Яркие цвета',
|
||||
'terminal.customTheme.color.background': 'Фон',
|
||||
'terminal.customTheme.color.foreground': 'Текст',
|
||||
'terminal.customTheme.color.foregroundIntense': 'Яркий текст (жирный)',
|
||||
'terminal.customTheme.color.cursor': 'Курсор',
|
||||
'terminal.customTheme.color.selection': 'Выделение',
|
||||
'terminal.customTheme.color.black': 'Чёрный',
|
||||
|
||||
@@ -644,6 +644,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.customTheme.group.bright': '高亮色',
|
||||
'terminal.customTheme.color.background': '背景',
|
||||
'terminal.customTheme.color.foreground': '前景',
|
||||
'terminal.customTheme.color.foregroundIntense': '高亮前景(粗体)',
|
||||
'terminal.customTheme.color.cursor': '光标',
|
||||
'terminal.customTheme.color.selection': '选区',
|
||||
'terminal.customTheme.color.black': '黑色',
|
||||
|
||||
@@ -644,6 +644,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.customTheme.group.bright': '標示色',
|
||||
'terminal.customTheme.color.background': '背景',
|
||||
'terminal.customTheme.color.foreground': '前景',
|
||||
'terminal.customTheme.color.foregroundIntense': '高亮前景(粗體)',
|
||||
'terminal.customTheme.color.cursor': '游標',
|
||||
'terminal.customTheme.color.selection': '選區',
|
||||
'terminal.customTheme.color.black': '黑色',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isPluginTerminalProviderRefreshCurrent,
|
||||
mergePluginTerminalThemeColors,
|
||||
resolvePluginTerminalTheme,
|
||||
toPluginTerminalTheme,
|
||||
waitForProviderResponse,
|
||||
} from './usePluginTerminalProviders.ts';
|
||||
|
||||
@@ -24,6 +25,15 @@ const baseTheme = {
|
||||
},
|
||||
} as const satisfies TerminalTheme;
|
||||
|
||||
test('theme provider requests keep the existing palette contract with intense color enabled or disabled', () => {
|
||||
for (const foregroundIntense of ['#ff8800', undefined]) {
|
||||
const theme = { ...baseTheme, colors: { ...baseTheme.colors, foregroundIntense } };
|
||||
assert.deepEqual(toPluginTerminalTheme(theme), { type: baseTheme.type, colors: baseTheme.colors });
|
||||
assert.equal(theme.colors.foregroundIntense, foregroundIntense);
|
||||
assert.equal(resolvePluginTerminalTheme(theme, { background: '#112233' }).colors.foregroundIntense, foregroundIntense);
|
||||
}
|
||||
});
|
||||
|
||||
test('terminal theme Providers merge deterministically over the host theme', () => {
|
||||
const merged = mergePluginTerminalThemeColors([
|
||||
{ background: '#102030', cursor: '#abcdef' },
|
||||
|
||||
@@ -51,6 +51,13 @@ interface UsePluginTerminalProvidersOptions extends PluginTerminalProviderRefres
|
||||
getSnapshotState(): Partial<NetcattyTerminalSessionSnapshot>;
|
||||
}
|
||||
|
||||
export function toPluginTerminalTheme(theme: TerminalTheme) {
|
||||
// The preview plugin contract exposes the existing palette only. Keep the
|
||||
// host-only optional color out of its strictly validated request payload.
|
||||
const { foregroundIntense: _foregroundIntense, ...colors } = theme.colors;
|
||||
return { type: theme.type, colors };
|
||||
}
|
||||
|
||||
export function mergePluginTerminalThemeColors(
|
||||
results: readonly PluginTerminalThemeColors[],
|
||||
): PluginTerminalThemeColors {
|
||||
@@ -211,10 +218,7 @@ export function usePluginTerminalProviders(options: UsePluginTerminalProvidersOp
|
||||
if (hasProvider('terminal.theme')) {
|
||||
tasks.push(applyCurrentProviderResponse(waitForProviderResponse(request('terminal.theme', 'provideTheme', {
|
||||
reason,
|
||||
currentTheme: {
|
||||
type: metadata.baseTheme.type,
|
||||
colors: metadata.baseTheme.colors,
|
||||
},
|
||||
currentTheme: toPluginTerminalTheme(metadata.baseTheme),
|
||||
}, PROVIDER_DEADLINE_MS, undefined, controller.signal), controller), isCurrent,
|
||||
(response) => {
|
||||
setThemeColors(mergePluginTerminalThemeColors(response.results.map((result) => result.status === 'ok'
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { CustomThemeEditor } from './CustomThemeEditor';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import type { TerminalTheme } from '../../domain/models';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
test('intense foreground can be enabled, edited, disabled and saved without changing the palette', async () => {
|
||||
let theme: TerminalTheme = structuredClone(TERMINAL_THEMES[0]);
|
||||
delete theme.colors.foregroundIntense;
|
||||
const palette = { ...theme.colors };
|
||||
let renderer: ReturnType<typeof create>;
|
||||
function Editor() {
|
||||
const [current, setCurrent] = React.useState(theme);
|
||||
return <CustomThemeEditor theme={current} onChange={(next) => { theme = next; setCurrent(next); }} />;
|
||||
}
|
||||
await act(async () => { renderer = create(<Editor />); });
|
||||
const toggle = () => renderer.root.findByProps({ role: 'switch' });
|
||||
const color = () => renderer.root.findAllByType('input').find((input) =>
|
||||
input.props.type === 'color' && input.props['aria-label'] === 'terminal.customTheme.color.foregroundIntense')!;
|
||||
assert.equal(toggle().props['aria-checked'], false);
|
||||
assert.equal(color().props.disabled, true);
|
||||
await act(async () => toggle().props.onClick());
|
||||
assert.equal(theme.colors.foregroundIntense, theme.colors.foreground);
|
||||
await act(async () => color().props.onChange({ target: { value: '#ff8800' } }));
|
||||
assert.equal(theme.colors.foregroundIntense, '#ff8800');
|
||||
assert.equal(JSON.parse(JSON.stringify(theme)).colors.foregroundIntense, '#ff8800');
|
||||
await act(async () => toggle().props.onClick());
|
||||
assert.equal(theme.colors.foregroundIntense, undefined);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(theme.colors)), palette);
|
||||
await act(async () => renderer.unmount());
|
||||
});
|
||||
|
||||
test('iTerm import preserves Bold Color and leaves older themes disabled', async () => {
|
||||
const { JSDOM } = await import('jsdom');
|
||||
const { parseItermcolors } = await import('../../infrastructure/parsers/itermcolorsParser');
|
||||
const dom = new JSDOM();
|
||||
const original = globalThis.DOMParser;
|
||||
globalThis.DOMParser = dom.window.DOMParser;
|
||||
const color = (key: string, r: number, g: number, b: number) => `<key>${key}</key><dict><key>Red Component</key><real>${r}</real><key>Green Component</key><real>${g}</real><key>Blue Component</key><real>${b}</real></dict>`;
|
||||
const essential = color('Background Color', 0, 0, 0) + color('Foreground Color', 1, 1, 1);
|
||||
try {
|
||||
assert.equal(parseItermcolors(`<plist><dict>${essential}${color('Bold Color', 1, 0.5, 0)}</dict></plist>`, 'imported')?.colors.foregroundIntense, '#ff8000');
|
||||
assert.equal(parseItermcolors(`<plist><dict>${essential}</dict></plist>`, 'older')?.colors.foregroundIntense, undefined);
|
||||
} finally {
|
||||
if (original === undefined) delete (globalThis as { DOMParser?: typeof DOMParser }).DOMParser;
|
||||
else globalThis.DOMParser = original;
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
@@ -7,16 +7,24 @@
|
||||
import React, { useCallback, memo } from 'react';
|
||||
import { TerminalTheme } from '../../domain/models';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Switch } from '../ui/switch';
|
||||
|
||||
|
||||
interface ColorFieldDef {
|
||||
key: keyof TerminalTheme['colors'];
|
||||
labelKey: string;
|
||||
/** Fallback shown when the (optional) field has no value yet. */
|
||||
fallbackKey?: keyof TerminalTheme['colors'];
|
||||
}
|
||||
|
||||
const GENERAL_COLORS: ColorFieldDef[] = [
|
||||
{ key: 'background', labelKey: 'terminal.customTheme.color.background' },
|
||||
{ key: 'foreground', labelKey: 'terminal.customTheme.color.foreground' },
|
||||
{
|
||||
key: 'foregroundIntense',
|
||||
labelKey: 'terminal.customTheme.color.foregroundIntense',
|
||||
fallbackKey: 'foreground',
|
||||
},
|
||||
{ key: 'cursor', labelKey: 'terminal.customTheme.color.cursor' },
|
||||
{ key: 'selection', labelKey: 'terminal.customTheme.color.selection' },
|
||||
];
|
||||
@@ -47,10 +55,14 @@ const ColorInput = memo(({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
enabled,
|
||||
onEnabledChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
enabled?: boolean;
|
||||
onEnabledChange?: (enabled: boolean) => void;
|
||||
}) => {
|
||||
// Local state for text input — allows partial hex while typing
|
||||
const [textValue, setTextValue] = React.useState(value);
|
||||
@@ -76,9 +88,14 @@ const ColorInput = memo(({
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{onEnabledChange && (
|
||||
<Switch checked={enabled} onCheckedChange={onEnabledChange} aria-label={label} />
|
||||
)}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="color"
|
||||
aria-label={label}
|
||||
disabled={enabled === false}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-6 h-6 rounded cursor-pointer border border-border/50 p-0"
|
||||
@@ -88,6 +105,8 @@ const ColorInput = memo(({
|
||||
<span className="text-[10px] text-muted-foreground flex-1 truncate">{label}</span>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={label}
|
||||
disabled={enabled === false}
|
||||
value={textValue}
|
||||
onChange={handleTextChange}
|
||||
onBlur={handleBlur}
|
||||
@@ -114,7 +133,7 @@ export const CustomThemeEditor: React.FC<CustomThemeEditorProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const updateColor = useCallback((key: keyof TerminalTheme['colors'], value: string) => {
|
||||
const updateColor = useCallback((key: keyof TerminalTheme['colors'], value: string | undefined) => {
|
||||
onChange({
|
||||
...theme,
|
||||
colors: { ...theme.colors, [key]: value },
|
||||
@@ -135,12 +154,16 @@ export const CustomThemeEditor: React.FC<CustomThemeEditorProps> = ({
|
||||
{title}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{fields.map(({ key, labelKey }) => (
|
||||
{fields.map(({ key, labelKey, fallbackKey }) => (
|
||||
<ColorInput
|
||||
key={key}
|
||||
label={t(labelKey)}
|
||||
value={theme.colors[key]}
|
||||
value={theme.colors[key] ?? (fallbackKey ? theme.colors[fallbackKey] : '') ?? ''}
|
||||
onChange={(v) => updateColor(key, v)}
|
||||
enabled={key === 'foregroundIntense' ? theme.colors.foregroundIntense !== undefined : undefined}
|
||||
onEnabledChange={key === 'foregroundIntense'
|
||||
? (enabled) => updateColor(key, enabled ? theme.colors.foreground : undefined)
|
||||
: undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -56,3 +56,17 @@ test("terminal memo refreshes when terminal context reader callback changes", ()
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('terminal memo refreshes for intense foreground edits and toggles', async () => {
|
||||
const { TERMINAL_THEMES } = await import('../../infrastructure/config/terminalThemes');
|
||||
const theme = TERMINAL_THEMES[0];
|
||||
for (const field of ['terminalTheme', 'appearanceTheme'] as const) {
|
||||
const props = { ...baseProps, [field]: theme };
|
||||
const enabled = { ...props, [field]: { ...theme, colors: { ...theme.colors, foregroundIntense: '#ff8800' } } };
|
||||
const recolored = { ...props, [field]: { ...theme, colors: { ...theme.colors, foregroundIntense: '#00ff00' } } };
|
||||
assert.equal(terminalPropsAreEqual(props, enabled), false);
|
||||
assert.equal(terminalPropsAreEqual(enabled, recolored), false);
|
||||
assert.equal(terminalPropsAreEqual(enabled, props), false);
|
||||
assert.equal(terminalPropsAreEqual(enabled, { ...enabled }), true);
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TerminalProps } from './terminalHelpers';
|
||||
|
||||
const themeFingerprint = (theme: TerminalProps['terminalTheme'] | undefined): string => (
|
||||
theme?.colors ? `${theme.id}:${theme.colors.background}:${theme.colors.foreground}:${theme.colors.cursor}` : ''
|
||||
theme?.colors ? `${theme.id}:${theme.colors.background}:${theme.colors.foreground}:${theme.colors.foregroundIntense ?? ''}:${theme.colors.cursor}` : ''
|
||||
);
|
||||
|
||||
export const terminalPropsAreEqual = (
|
||||
|
||||
@@ -1047,7 +1047,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
}, [status]);
|
||||
|
||||
|
||||
const effectiveThemeKey = `${effectiveTheme.id}:${effectiveTheme.colors.background}:${effectiveTheme.colors.foreground}:${effectiveTheme.colors.cursor}`;
|
||||
const effectiveThemeKey = `${effectiveTheme.id}:${effectiveTheme.colors.background}:${effectiveTheme.colors.foreground}:${effectiveTheme.colors.foregroundIntense ?? ""}:${effectiveTheme.colors.cursor}`;
|
||||
|
||||
// Sync xterm theme before browser paint; apply synchronously on visible panes.
|
||||
useLayoutEffect(() => {
|
||||
|
||||
@@ -569,6 +569,13 @@ export interface TerminalTheme {
|
||||
colors: {
|
||||
background: string;
|
||||
foreground: string;
|
||||
/**
|
||||
* Optional intense variant for bold text drawn with the default foreground
|
||||
* (SGR 1 / SGR 39;1). When set and different from `foreground`, bold text
|
||||
* without an explicit ANSI color renders with this color instead (#3352).
|
||||
* Explicit ANSI colors keep the existing normal/bright palette behavior.
|
||||
*/
|
||||
foregroundIntense?: string;
|
||||
cursor: string;
|
||||
selection: string;
|
||||
black: string;
|
||||
|
||||
@@ -28,6 +28,7 @@ const COLOR_KEY_MAP: Record<string, keyof TerminalTheme['colors']> = {
|
||||
'Ansi 15 Color': 'brightWhite',
|
||||
'Background Color': 'background',
|
||||
'Foreground Color': 'foreground',
|
||||
'Bold Color': 'foregroundIntense',
|
||||
'Cursor Color': 'cursor',
|
||||
'Selection Color': 'selection',
|
||||
};
|
||||
@@ -138,6 +139,7 @@ export function parseItermcolors(xml: string, name: string): TerminalTheme | nul
|
||||
const defaults: TerminalTheme['colors'] = {
|
||||
background: colors.background,
|
||||
foreground: colors.foreground,
|
||||
foregroundIntense: colors.foregroundIntense,
|
||||
cursor: colors.cursor || colors.foreground,
|
||||
selection: colors.selection || (isDarkBackground(colors.background) ? '#264f78' : '#add6ff'),
|
||||
black: colors.black || '#000000',
|
||||
|
||||
+2
-1
@@ -42,7 +42,7 @@
|
||||
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never",
|
||||
"pack:linux-x64": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never",
|
||||
"pack:linux-arm64": "npm run build && cross-env npm_config_arch=arm64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never",
|
||||
"postinstall": "patch-package && node scripts/patch-xterm-macos-column-selection.cjs && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs",
|
||||
"postinstall": "patch-package && node scripts/patch-xterm-macos-column-selection.cjs && node scripts/patch-xterm-foreground-intense.cjs && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs",
|
||||
"rebuild": "electron-builder install-app-deps",
|
||||
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
|
||||
"generate:capability-tools": "node scripts/generate-capability-tools.cjs",
|
||||
@@ -65,6 +65,7 @@
|
||||
"test:ssh-mfa-models": "node --test electron/bridges/sshMfaModels.live.test.cjs",
|
||||
"test:ssh-mfa-models:live": "SSH_MFA_LIVE=1 node --test electron/bridges/sshMfaModels.live.test.cjs",
|
||||
"test:tray-panel-layout": "electron scripts/tray-panel-layout.live.test.cjs",
|
||||
"test:xterm-foreground-intense": "electron scripts/xterm-foreground-intense.live.test.cjs",
|
||||
"test:terminal-background-rendering": "electron scripts/terminal-background-rendering.live.test.cjs",
|
||||
"test:xterm-webgl-overflow": "electron scripts/xterm-webgl-atlas-overflow.live.test.cjs",
|
||||
"test:xterm-macos-selection": "electron scripts/xterm-macos-column-selection.live.test.cjs",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env node
|
||||
/* global process, console */
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const EXPECTED_VERSIONS = {
|
||||
xterm: "6.1.0-beta.292",
|
||||
"addon-webgl": "0.20.0-beta.291",
|
||||
};
|
||||
|
||||
// Resolve the optional color only when rendering bold default-foreground cells.
|
||||
// Buffer attributes and serialized terminal output stay unchanged. Converting
|
||||
// renderer-local colors before inverse/decoration/selection processing lets the
|
||||
// existing renderer handle their precedence, contrast and dimming normally.
|
||||
const PATCHES = [
|
||||
{
|
||||
file: "xterm/lib/xterm.js",
|
||||
original: "t.foreground=m(e.foreground,d),t.background=",
|
||||
replacement: "t.foreground=m(e.foreground,d),t.foregroundIntense=m(e.foregroundIntense,void 0),t.background=",
|
||||
},
|
||||
{
|
||||
file: "xterm/lib/xterm.mjs",
|
||||
original: "t.foreground=M(e.foreground,qe),t.background=",
|
||||
replacement: "t.foreground=M(e.foreground,qe),t.foregroundIntense=M(e.foregroundIntense,void 0),t.background=",
|
||||
},
|
||||
{
|
||||
file: "xterm/lib/xterm.js",
|
||||
original: "let V=W.getFgColor(),q=W.getFgColorMode(),X=W.getBgColor(),Y=W.getBgColorMode();",
|
||||
replacement: "let V=W.getFgColor(),q=W.getFgColorMode(),X=W.getBgColor(),Y=W.getBgColorMode();if(q===0&&W.isBold()&&b.foregroundIntense){V=b.foregroundIntense.rgba>>>8;q=50331648;}",
|
||||
},
|
||||
{
|
||||
file: "xterm/lib/xterm.mjs",
|
||||
original: "let de=x.getFgColor(),Ft=x.getFgColorMode(),Se=x.getBgColor(),Ht=x.getBgColorMode(),Br=!!x.isInverse();",
|
||||
replacement: "let de=x.getFgColor(),Ft=x.getFgColorMode(),Se=x.getBgColor(),Ht=x.getBgColorMode(),Br=!!x.isInverse();if(Ft===0&&x.isBold()&&f.foregroundIntense){de=f.foregroundIntense.rgba>>>8;Ft=50331648;}",
|
||||
},
|
||||
{
|
||||
file: "addon-webgl/lib/addon-webgl.js",
|
||||
original: "this.result.fg=t.fg,this.result.ext=",
|
||||
replacement: "this.result.fg=t.isBold()&&t.getFgColorMode()===0&&this._themeService.colors.foregroundIntense?(t.fg&~67108863)|50331648|(this._themeService.colors.foregroundIntense.rgba>>>8):t.fg,this.result.ext=",
|
||||
},
|
||||
{
|
||||
file: "addon-webgl/lib/addon-webgl.mjs",
|
||||
original: "this.result.fg=e.fg,this.result.ext=",
|
||||
replacement: "this.result.fg=e.isBold()&&e.getFgColorMode()===0&&this._themeService.colors.foregroundIntense?(e.fg&~67108863)|50331648|(this._themeService.colors.foregroundIntense.rgba>>>8):e.fg,this.result.ext=",
|
||||
},
|
||||
];
|
||||
|
||||
function patchXtermSource(source, patch) {
|
||||
// Some replacements contain the original. Check the fully patched shape
|
||||
// first, then reject any additional original occurrences in the remainder.
|
||||
const patchedCount = source.split(patch.replacement).length - 1;
|
||||
if (patchedCount === 1 && !source.replace(patch.replacement, "").includes(patch.original)) {
|
||||
return { source, changed: false };
|
||||
}
|
||||
const originalCount = source.split(patch.original).length - 1;
|
||||
if (patchedCount === 0 && originalCount === 1) {
|
||||
return { source: source.replace(patch.original, patch.replacement), changed: true };
|
||||
}
|
||||
throw new Error(`${patch.file}: unexpected foreground-intense patch shape (original=${originalCount}, patched=${patchedCount})`);
|
||||
}
|
||||
|
||||
function patchInstalledXterm(root = process.cwd()) {
|
||||
const base = path.resolve(root, "node_modules/@xterm");
|
||||
for (const [name, expected] of Object.entries(EXPECTED_VERSIONS)) {
|
||||
const actual = JSON.parse(fs.readFileSync(path.join(base, name, "package.json"), "utf8")).version;
|
||||
if (actual !== expected) throw new Error(`unsupported @xterm/${name} version ${actual}; expected ${expected}`);
|
||||
}
|
||||
// Validate every bundle before writing any, so a changed dependency cannot
|
||||
// leave an installation with only one renderer patched.
|
||||
const files = new Map();
|
||||
let changed = 0;
|
||||
for (const patch of PATCHES) {
|
||||
const source = files.get(patch.file) ?? fs.readFileSync(path.join(base, patch.file), "utf8");
|
||||
const result = patchXtermSource(source, patch);
|
||||
files.set(patch.file, result.source);
|
||||
if (result.changed) changed++;
|
||||
}
|
||||
if (changed) {
|
||||
for (const [file, source] of files) fs.writeFileSync(path.join(base, file), source);
|
||||
fs.rmSync(path.resolve(root, "node_modules/.vite"), { recursive: true, force: true });
|
||||
}
|
||||
return { changed, checked: PATCHES.length };
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
const result = patchInstalledXterm();
|
||||
console.log(`[patch-xterm-foreground-intense] changed=${result.changed} checked=${result.checked}`);
|
||||
} catch (error) {
|
||||
console.error(`[patch-xterm-foreground-intense] ERROR: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { EXPECTED_VERSIONS, PATCHES, patchXtermSource, patchInstalledXterm };
|
||||
@@ -0,0 +1,38 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const vm = require("node:vm");
|
||||
const { PATCHES, patchXtermSource } = require("./patch-xterm-foreground-intense.cjs");
|
||||
|
||||
for (const patch of PATCHES) {
|
||||
test(`${patch.file}: ${patch.original.slice(0, 24)} patches once and rejects drift`, () => {
|
||||
const first = patchXtermSource(`before;${patch.original}after`, patch);
|
||||
assert.equal(first.changed, true);
|
||||
assert.equal(first.source, `before;${patch.replacement}after`);
|
||||
assert.deepEqual(patchXtermSource(first.source, patch), { source: first.source, changed: false });
|
||||
assert.throws(() => patchXtermSource("upstream changed", patch));
|
||||
assert.throws(() => patchXtermSource(patch.original.repeat(2), patch));
|
||||
assert.throws(() => patchXtermSource(patch.replacement.repeat(2), patch));
|
||||
assert.throws(() => patchXtermSource(patch.replacement + patch.original, patch));
|
||||
});
|
||||
}
|
||||
|
||||
for (const patch of PATCHES.filter(p => p.file.startsWith("addon-webgl"))) {
|
||||
test(`${patch.file}: renderer color resolution preserves cell flags and buffer`, () => {
|
||||
const expression = patch.replacement.slice("this.result.fg=".length, -",this.result.ext=".length);
|
||||
const resolve = (fg, intense) => {
|
||||
const cell = { fg, isBold: () => fg & 0x08000000, getFgColorMode: () => fg & 0x03000000 };
|
||||
const context = { t: cell, e: cell, _themeService: { colors: { foregroundIntense: intense } } };
|
||||
const result = vm.runInNewContext(expression, context);
|
||||
assert.equal(cell.fg, fg, "must not alter the stored cell");
|
||||
return result;
|
||||
};
|
||||
const color = { rgba: 0xff6000ff };
|
||||
assert.equal(resolve(0, color), 0);
|
||||
assert.equal(resolve(0x08000000, undefined), 0x08000000);
|
||||
assert.equal(resolve(0x08000000, color), 0x0bff6000);
|
||||
assert.equal(resolve(0x0c000000, color), 0x0fff6000, "inverse flag remains set");
|
||||
assert.equal(resolve(0x09000002, color), 0x09000002, "ANSI color preserved");
|
||||
assert.equal(resolve(0x0b123456, color), 0x0b123456, "truecolor preserved");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use strict";
|
||||
/* global process, __dirname, console, document, getComputedStyle */
|
||||
if (!process.versions.electron) {
|
||||
require("node:test")("foreground intense renders without changing terminal data", {
|
||||
skip: "run with Electron to exercise DOM and WebGL rendering",
|
||||
}, () => {});
|
||||
} else {
|
||||
const { app, BrowserWindow } = require("electron");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const esbuild = require("esbuild");
|
||||
const temp = require("../electron/bridges/tempDirBridge.cjs");
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const userData = fs.mkdtempSync(`${temp.getTempFilePath("foreground-intense")}-`);
|
||||
app.setPath("userData", userData);
|
||||
app.on("window-all-closed", () => {});
|
||||
let win;
|
||||
const cleanup = code => {
|
||||
win?.destroy();
|
||||
fs.rmSync(userData, { recursive: true, force: true });
|
||||
app.exit(code);
|
||||
};
|
||||
const bundle = esbuild.buildSync({
|
||||
stdin: {
|
||||
contents: 'export { Terminal } from "@xterm/xterm"; export { WebglAddon } from "@xterm/addon-webgl"; export { SerializeAddon } from "@xterm/addon-serialize";',
|
||||
resolveDir: root,
|
||||
},
|
||||
bundle: true, format: "cjs", platform: "browser", write: false,
|
||||
}).outputFiles[0].text;
|
||||
async function exercise({ Terminal, WebglAddon, SerializeAddon }) {
|
||||
const assert = require("node:assert/strict");
|
||||
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
const results = [];
|
||||
for (const renderer of ["dom", "webgl"]) {
|
||||
const host = document.createElement("div");
|
||||
host.style.cssText = "width:800px;height:250px";
|
||||
document.body.appendChild(host);
|
||||
const theme = { foreground: "#aaaaaa", background: "#000000", foregroundIntense: "#ff0000", cyan: "#00aaaa", brightCyan: "#00ffff" };
|
||||
const term = new Terminal({ cols: 40, rows: 8, fontFamily: "monospace", fontSize: 20, cursorBlink: false, allowProposedApi: true, theme, drawBoldTextInBrightColors: true, minimumContrastRatio: 1 });
|
||||
term.open(host);
|
||||
let addon;
|
||||
if (renderer === "webgl") {
|
||||
addon = new WebglAddon({ preserveDrawingBuffer: true });
|
||||
term.loadAddon(addon);
|
||||
assert.ok(host.querySelector("canvas"), "WebGL must actually initialize");
|
||||
}
|
||||
const serial = new SerializeAddon();
|
||||
term.loadAddon(serial);
|
||||
const write = data => new Promise(resolve => term.write(data, resolve));
|
||||
const render = async () => { term.refresh(0, term.rows - 1); await wait(100); };
|
||||
const color = (x, background = false) => {
|
||||
if (renderer === "dom") {
|
||||
const spans = host.querySelector(".xterm-rows").firstElementChild.children;
|
||||
let offset = 0;
|
||||
for (const span of spans) {
|
||||
if (x < offset + span.textContent.length) return background ? getComputedStyle(span).backgroundColor : getComputedStyle(span).color;
|
||||
offset += span.textContent.length;
|
||||
}
|
||||
throw new Error("DOM cell not found: " + x);
|
||||
}
|
||||
const canvas = [...host.querySelectorAll(".xterm-screen canvas")].find(c => c.getContext("webgl2"));
|
||||
const gl = canvas?.getContext("webgl2");
|
||||
assert.ok(gl, "read actual WebGL output");
|
||||
const cell = term._core._renderService.dimensions.device.cell;
|
||||
const pixels = new Uint8Array(4 * Math.floor(cell.width) * Math.floor(cell.height));
|
||||
gl.readPixels(Math.floor(x * cell.width), canvas.height - Math.floor(cell.height), Math.floor(cell.width), Math.floor(cell.height), gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
const counts = new Map();
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
if (!(pixels[i] || pixels[i + 1] || pixels[i + 2])) continue;
|
||||
const key = `rgb(${pixels[i]}, ${pixels[i + 1]}, ${pixels[i + 2]})`;
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
}
|
||||
return [...counts].sort((a, b) => b[1] - a[1])[0]?.[0] || "rgb(0, 0, 0)";
|
||||
};
|
||||
// Blocks have a solid interior, allowing exact pixel checks despite font antialiasing.
|
||||
await write("\x1b[?25l\x1b[0m█\x1b[1m█\x1b[0;36m█\x1b[1m█\x1b[39;1m█\x1b[0m");
|
||||
await render();
|
||||
assert.deepEqual([0, 1, 2, 3, 4].map(x => color(x)), ["rgb(170, 170, 170)", "rgb(255, 0, 0)", "rgb(0, 170, 170)", "rgb(0, 255, 255)", "rgb(255, 0, 0)"], renderer + " issue examples");
|
||||
assert.equal(term.buffer.active.getLine(0).getCell(1).isFgDefault(), true, "rendering must preserve default color provenance");
|
||||
const snapshot = serial.serialize();
|
||||
assert.ok(!snapshot.includes("38;2;255;0;0"), "snapshot must not bake theme colors into data");
|
||||
term.options.theme = { ...theme, foregroundIntense: "#00ff00" };
|
||||
await render();
|
||||
assert.equal(color(1), "rgb(0, 255, 0)", renderer + " recolors existing text");
|
||||
term.options.theme = { ...theme, foregroundIntense: undefined };
|
||||
await render();
|
||||
assert.equal(color(1), "rgb(170, 170, 170)", renderer + " disabling restores existing text");
|
||||
term.options.theme = { ...theme, foregroundIntense: "#00ff00" };
|
||||
term.reset();
|
||||
await write(snapshot);
|
||||
await render();
|
||||
assert.equal(color(1), "rgb(0, 255, 0)", renderer + " snapshot uses current theme");
|
||||
await write("\x1b[?1049h\x1b[2J\x1b[H\x1b[1m█\x1b[0m");
|
||||
await render();
|
||||
assert.equal(color(0), "rgb(0, 255, 0)", renderer + " alternate screen");
|
||||
await write("\x1b[?1049l");
|
||||
await render();
|
||||
assert.equal(color(1), "rgb(0, 255, 0)", renderer + " restores normal screen");
|
||||
term.reset();
|
||||
await write("\x1b[?25l█\x1b[1m█\x1b[0m");
|
||||
await render();
|
||||
assert.deepEqual([0, 1].map(x => color(x)), ["rgb(170, 170, 170)", "rgb(0, 255, 0)"], renderer + " full reset");
|
||||
term.options.theme = { ...theme, foregroundIntense: "#00ff00", selectionForeground: "#ff00ff", selectionBackground: "#000000", selectionInactiveBackground: "#000000" };
|
||||
term.reset();
|
||||
await write("\x1b[?25lM\x1b[1mM\x1b[0m");
|
||||
term.select(1, 0, 1);
|
||||
await render();
|
||||
assert.equal(color(1), "rgb(255, 0, 255)", renderer + " selection overrides intense");
|
||||
const marker = term.registerMarker(0);
|
||||
const decoration = term.registerDecoration({ marker, x: 1, width: 1, foregroundColor: "#ffff00", layer: "top" });
|
||||
await render();
|
||||
assert.equal(color(1), "rgb(255, 255, 0)", renderer + " top keyword decoration overrides selection/intense");
|
||||
decoration.dispose();
|
||||
marker.dispose();
|
||||
term.clearSelection();
|
||||
term.reset();
|
||||
await write("\x1b[?25l\x1b[1;7m█ \x1b[0m");
|
||||
await render();
|
||||
assert.equal(color(0), "rgb(0, 0, 0)", renderer + " inverse foreground is default background");
|
||||
assert.equal(color(1, true), "rgb(0, 255, 0)", renderer + " inverse background uses intense");
|
||||
results.push({ renderer, issueExamples: true, themeSwitch: true, disable: true, snapshot: true, alternateScreen: true, reset: true, selection: true, decoration: true, inverse: true });
|
||||
addon?.dispose();
|
||||
term.dispose();
|
||||
host.remove();
|
||||
}
|
||||
return results;
|
||||
}
|
||||
void app.whenReady().then(async () => {
|
||||
win = new BrowserWindow({ show: true, width: 900, height: 400, webPreferences: { nodeIntegration: true, contextIsolation: false, sandbox: false, backgroundThrottling: false } });
|
||||
await win.loadURL("data:text/html,<body style='margin:0;background:black'>");
|
||||
await win.webContents.insertCSS(fs.readFileSync(require.resolve("@xterm/xterm/css/xterm.css"), "utf8"));
|
||||
const result = await win.webContents.executeJavaScript(`(async () => { const loaded = {exports:{}}; ((module,exports)=>{${bundle}})(loaded,loaded.exports); return (${exercise.toString()})(loaded.exports); })()`);
|
||||
console.log("FOREGROUND_INTENSE_RENDERING_OK", JSON.stringify(result));
|
||||
cleanup(0);
|
||||
}).catch(error => { console.error(error); cleanup(1); });
|
||||
}
|
||||
Reference in new issue
Block a user