[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
207
infrastructure/monaco/monacoClipboardPaste.test.ts
Normal file
207
infrastructure/monaco/monacoClipboardPaste.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildMonacoPasteEdits,
|
||||
isMonacoFindWidgetFocused,
|
||||
isStillFocusedFindPasteTarget,
|
||||
pasteForMonacoEditorCommand,
|
||||
pasteTextIntoFocusedInput,
|
||||
readClipboardTextWithFallbacks,
|
||||
type FocusedTextInput,
|
||||
} from './monacoClipboardPaste.ts';
|
||||
|
||||
function createMockTextInput(initialValue = ''): FocusedTextInput & {
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
} {
|
||||
let selectionStart = initialValue.length;
|
||||
let selectionEnd = initialValue.length;
|
||||
return {
|
||||
value: initialValue,
|
||||
get selectionStart() {
|
||||
return selectionStart;
|
||||
},
|
||||
set selectionStart(value: number) {
|
||||
selectionStart = value;
|
||||
},
|
||||
get selectionEnd() {
|
||||
return selectionEnd;
|
||||
},
|
||||
set selectionEnd(value: number) {
|
||||
selectionEnd = value;
|
||||
},
|
||||
focus() {},
|
||||
setSelectionRange(start: number, end: number) {
|
||||
selectionStart = start;
|
||||
selectionEnd = end;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('buildMonacoPasteEdits pastes full text at a single cursor', () => {
|
||||
const edits = buildMonacoPasteEdits('hello\nworld', [
|
||||
{ startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 },
|
||||
]);
|
||||
assert.deepEqual(edits, [
|
||||
{
|
||||
range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 },
|
||||
text: 'hello\nworld',
|
||||
forceMoveMarkers: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildMonacoPasteEdits spreads one line per cursor when counts match', () => {
|
||||
const edits = buildMonacoPasteEdits('one\ntwo', [
|
||||
{ startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 },
|
||||
{ startLineNumber: 2, startColumn: 1, endLineNumber: 2, endColumn: 1 },
|
||||
]);
|
||||
assert.equal(edits.length, 2);
|
||||
assert.equal(edits[0]?.text, 'one');
|
||||
assert.equal(edits[1]?.text, 'two');
|
||||
});
|
||||
|
||||
test('buildMonacoPasteEdits does not spread when line and cursor counts differ', () => {
|
||||
const edits = buildMonacoPasteEdits('only-one-line', [
|
||||
{ startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 },
|
||||
{ startLineNumber: 2, startColumn: 1, endLineNumber: 2, endColumn: 1 },
|
||||
]);
|
||||
assert.equal(edits[0]?.text, 'only-one-line');
|
||||
assert.equal(edits[1]?.text, 'only-one-line');
|
||||
});
|
||||
|
||||
test('buildMonacoPasteEdits returns empty when there are no selections', () => {
|
||||
assert.deepEqual(buildMonacoPasteEdits('text', []), []);
|
||||
});
|
||||
|
||||
test('readClipboardTextWithFallbacks prefers navigator clipboard', async () => {
|
||||
const text = await readClipboardTextWithFallbacks({
|
||||
readNavigator: async () => 'from-navigator',
|
||||
readBridge: async () => {
|
||||
throw new Error('bridge should not run');
|
||||
},
|
||||
});
|
||||
assert.equal(text, 'from-navigator');
|
||||
});
|
||||
|
||||
test('readClipboardTextWithFallbacks uses bridge when navigator fails', async () => {
|
||||
const text = await readClipboardTextWithFallbacks({
|
||||
readNavigator: async () => {
|
||||
throw new Error('denied');
|
||||
},
|
||||
readBridge: async () => 'from-bridge',
|
||||
});
|
||||
assert.equal(text, 'from-bridge');
|
||||
});
|
||||
|
||||
test('readClipboardTextWithFallbacks returns null when both paths fail', async () => {
|
||||
const text = await readClipboardTextWithFallbacks({
|
||||
readNavigator: async () => {
|
||||
throw new Error('denied');
|
||||
},
|
||||
readBridge: async () => {
|
||||
throw new Error('unavailable');
|
||||
},
|
||||
});
|
||||
assert.equal(text, null);
|
||||
});
|
||||
|
||||
test('isMonacoFindWidgetFocused detects elements inside .find-widget', () => {
|
||||
const findInput = {
|
||||
closest: (selector: string) => (selector === '.find-widget' ? {} : null),
|
||||
};
|
||||
const otherInput = {
|
||||
closest: () => null,
|
||||
};
|
||||
assert.equal(isMonacoFindWidgetFocused(findInput), true);
|
||||
assert.equal(isMonacoFindWidgetFocused(otherInput), false);
|
||||
assert.equal(isMonacoFindWidgetFocused(null), false);
|
||||
});
|
||||
|
||||
test('pasteTextIntoFocusedInput replaces the current selection', () => {
|
||||
const input = createMockTextInput('hello');
|
||||
input.setSelectionRange(0, 5);
|
||||
assert.equal(pasteTextIntoFocusedInput(input, 'world'), true);
|
||||
assert.equal(input.value, 'world');
|
||||
assert.equal(input.selectionStart, 5);
|
||||
assert.equal(input.selectionEnd, 5);
|
||||
});
|
||||
|
||||
test('pasteTextIntoFocusedInput rejects non-input targets', () => {
|
||||
assert.equal(pasteTextIntoFocusedInput({ closest: () => ({}) }, 'x'), false);
|
||||
});
|
||||
|
||||
test('pasteForMonacoEditorCommand pastes into find widget and skips editor body', async () => {
|
||||
const input = Object.assign(createMockTextInput(''), {
|
||||
closest: (selector: string) => (selector === '.find-widget' ? {} : null),
|
||||
});
|
||||
let bodyPasteCount = 0;
|
||||
await pasteForMonacoEditorCommand({
|
||||
activeElement: input,
|
||||
readClipboardText: async () => 'search-me',
|
||||
pasteIntoEditor: () => {
|
||||
bodyPasteCount += 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(input.value, 'search-me');
|
||||
assert.equal(bodyPasteCount, 0);
|
||||
});
|
||||
|
||||
test('pasteForMonacoEditorCommand falls through to editor body outside find widget', async () => {
|
||||
let bodyPasteCount = 0;
|
||||
await pasteForMonacoEditorCommand({
|
||||
activeElement: null,
|
||||
readClipboardText: async () => {
|
||||
throw new Error('should not read clipboard for body path');
|
||||
},
|
||||
pasteIntoEditor: () => {
|
||||
bodyPasteCount += 1;
|
||||
},
|
||||
});
|
||||
assert.equal(bodyPasteCount, 1);
|
||||
});
|
||||
|
||||
test('isStillFocusedFindPasteTarget rejects targets no longer inside the find widget', () => {
|
||||
const leftFind = {
|
||||
closest: () => null,
|
||||
};
|
||||
assert.equal(isStillFocusedFindPasteTarget(leftFind), false);
|
||||
assert.equal(isStillFocusedFindPasteTarget(null), false);
|
||||
});
|
||||
|
||||
test('pasteForMonacoEditorCommand aborts if focus leaves the find field mid clipboard read', async () => {
|
||||
const input = Object.assign(createMockTextInput('keep'), {
|
||||
closest: (selector: string) => (selector === '.find-widget' ? {} : null),
|
||||
});
|
||||
// Simulate a browser document where focus moved away during the await.
|
||||
const previousDocument = (globalThis as { document?: Document }).document;
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: { activeElement: { id: 'editor-body' } },
|
||||
});
|
||||
|
||||
let bodyPasteCount = 0;
|
||||
try {
|
||||
await pasteForMonacoEditorCommand({
|
||||
activeElement: input,
|
||||
readClipboardText: async () => 'should-not-apply',
|
||||
pasteIntoEditor: () => {
|
||||
bodyPasteCount += 1;
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (previousDocument === undefined) {
|
||||
delete (globalThis as { document?: Document }).document;
|
||||
} else {
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: previousDocument,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(input.value, 'keep');
|
||||
assert.equal(bodyPasteCount, 0);
|
||||
});
|
||||
184
infrastructure/monaco/monacoClipboardPaste.ts
Normal file
184
infrastructure/monaco/monacoClipboardPaste.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Shared Monaco paste helpers for Electron, where Monaco's built-in
|
||||
* clipboardPasteAction often cannot read the OS clipboard.
|
||||
*/
|
||||
|
||||
export type MonacoPasteRange = {
|
||||
startLineNumber: number;
|
||||
startColumn: number;
|
||||
endLineNumber: number;
|
||||
endColumn: number;
|
||||
};
|
||||
|
||||
export type MonacoPasteEdit = {
|
||||
range: MonacoPasteRange;
|
||||
text: string;
|
||||
forceMoveMarkers: true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build executeEdits payloads matching Monaco multicursorPaste:'spread':
|
||||
* when cursor count equals clipboard line count, distribute one line per cursor.
|
||||
*/
|
||||
export function buildMonacoPasteEdits(
|
||||
text: string,
|
||||
selections: readonly MonacoPasteRange[],
|
||||
): MonacoPasteEdit[] {
|
||||
if (selections.length === 0) return [];
|
||||
|
||||
const lines = text.split(/\r\n|\n/);
|
||||
const distribute = selections.length > 1 && lines.length === selections.length;
|
||||
|
||||
return selections.map((selection, i) => ({
|
||||
range: selection,
|
||||
text: distribute ? lines[i]! : text,
|
||||
forceMoveMarkers: true as const,
|
||||
}));
|
||||
}
|
||||
|
||||
export type ClipboardTextReaders = {
|
||||
readNavigator?: () => Promise<string>;
|
||||
readBridge: () => Promise<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prefer navigator.clipboard, then Electron bridge.
|
||||
* Returns null when both paths fail so callers can fall back to Monaco native paste.
|
||||
*/
|
||||
export async function readClipboardTextWithFallbacks(
|
||||
readers: ClipboardTextReaders,
|
||||
): Promise<string | null> {
|
||||
if (readers.readNavigator) {
|
||||
try {
|
||||
return await readers.readNavigator();
|
||||
} catch {
|
||||
// Fall through to Electron bridge
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await readers.readBridge();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type ClosableElement = {
|
||||
closest?: (selector: string) => unknown;
|
||||
};
|
||||
|
||||
/** Editable text field shape used by Monaco find/replace inputs. */
|
||||
export type FocusedTextInput = {
|
||||
value: string;
|
||||
selectionStart: number | null;
|
||||
selectionEnd: number | null;
|
||||
focus: () => void;
|
||||
setSelectionRange: (start: number, end: number) => void;
|
||||
dispatchEvent?: (event: Event) => boolean;
|
||||
};
|
||||
|
||||
/** Monaco's find/replace overlay uses the `.find-widget` class. */
|
||||
export function isMonacoFindWidgetFocused(
|
||||
active: ClosableElement | null | undefined,
|
||||
): boolean {
|
||||
return Boolean(active?.closest?.('.find-widget'));
|
||||
}
|
||||
|
||||
export function isFocusedTextInput(target: unknown): target is FocusedTextInput {
|
||||
if (typeof HTMLTextAreaElement !== 'undefined' && target instanceof HTMLTextAreaElement) {
|
||||
return true;
|
||||
}
|
||||
if (typeof HTMLInputElement !== 'undefined' && target instanceof HTMLInputElement) {
|
||||
return true;
|
||||
}
|
||||
if (!target || typeof target !== 'object') return false;
|
||||
const candidate = target as Partial<FocusedTextInput>;
|
||||
return typeof candidate.value === 'string'
|
||||
&& typeof candidate.focus === 'function'
|
||||
&& typeof candidate.setSelectionRange === 'function';
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert clipboard text into a focused find/replace input.
|
||||
* Custom Monaco Ctrl/Cmd+V commands steal the event, so the browser cannot
|
||||
* paste into the widget; we write the field ourselves instead of the body.
|
||||
*/
|
||||
export function pasteTextIntoFocusedInput(
|
||||
target: unknown,
|
||||
text: string,
|
||||
): boolean {
|
||||
if (!isFocusedTextInput(target)) return false;
|
||||
|
||||
const start = target.selectionStart ?? target.value.length;
|
||||
const end = target.selectionEnd ?? target.value.length;
|
||||
target.focus();
|
||||
target.setSelectionRange(start, end);
|
||||
|
||||
// insertText keeps undo/input events in supporting browsers.
|
||||
if (typeof document !== 'undefined' && typeof document.execCommand === 'function') {
|
||||
if (document.execCommand('insertText', false, text)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const nextValue = `${target.value.slice(0, start)}${text}${target.value.slice(end)}`;
|
||||
const nextCaret = start + text.length;
|
||||
target.value = nextValue;
|
||||
target.setSelectionRange(nextCaret, nextCaret);
|
||||
if (typeof target.dispatchEvent === 'function' && typeof Event !== 'undefined') {
|
||||
target.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* After an async clipboard read, confirm the find input is still the live
|
||||
* focus target so we do not steal focus back into a closed/hidden widget or
|
||||
* paste via execCommand into a different field.
|
||||
*/
|
||||
export function isStillFocusedFindPasteTarget(
|
||||
active: ClosableElement | null | undefined,
|
||||
): boolean {
|
||||
if (!active || !isMonacoFindWidgetFocused(active)) return false;
|
||||
|
||||
if (typeof document !== 'undefined' && document.activeElement !== active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof Element !== 'undefined'
|
||||
&& active instanceof Element
|
||||
&& 'isConnected' in active
|
||||
&& !(active as Element).isConnected
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* When focus is in Monaco's find widget, paste there; otherwise call body paste.
|
||||
* Used by Electron Ctrl/Cmd+V command handlers that override native paste.
|
||||
*/
|
||||
export async function pasteForMonacoEditorCommand(options: {
|
||||
activeElement: ClosableElement | null | undefined;
|
||||
readClipboardText: () => Promise<string | null>;
|
||||
pasteIntoEditor: () => void | Promise<void>;
|
||||
}): Promise<void> {
|
||||
if (isMonacoFindWidgetFocused(options.activeElement)) {
|
||||
const active = options.activeElement;
|
||||
if (!active) return;
|
||||
try {
|
||||
const text = await options.readClipboardText();
|
||||
if (!text) return;
|
||||
// Clipboard I/O is async; abort if the user left the find field meanwhile.
|
||||
if (!isStillFocusedFindPasteTarget(active)) return;
|
||||
pasteTextIntoFocusedInput(active, text);
|
||||
} catch {
|
||||
// Clipboard or insert failed; leave the find field unchanged.
|
||||
}
|
||||
return;
|
||||
}
|
||||
await options.pasteIntoEditor();
|
||||
}
|
||||
54
infrastructure/monaco/netcattyMonacoTheme.test.ts
Normal file
54
infrastructure/monaco/netcattyMonacoTheme.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
buildNetcattyMonacoThemeColors,
|
||||
type NetcattyEditorColors,
|
||||
} from './netcattyMonacoTheme.ts';
|
||||
|
||||
const sampleColors: NetcattyEditorColors = {
|
||||
bg: '#ffffff',
|
||||
fg: '#1e1e1e',
|
||||
primary: '#0078d4',
|
||||
card: '#f3f3f3',
|
||||
mutedFg: '#858585',
|
||||
border: '#d4d4d4',
|
||||
};
|
||||
|
||||
const isSemiTransparentHex = (value: string): boolean =>
|
||||
/^#[0-9a-fA-F]{8}$/.test(value) && !value.toLowerCase().endsWith('ff');
|
||||
|
||||
test('buildNetcattyMonacoThemeColors uses translucent find-match highlights', () => {
|
||||
const colors = buildNetcattyMonacoThemeColors(sampleColors);
|
||||
|
||||
assert.equal(isSemiTransparentHex(colors['editor.findMatchBackground']), true);
|
||||
assert.equal(isSemiTransparentHex(colors['editor.findMatchHighlightBackground']), true);
|
||||
assert.equal(isSemiTransparentHex(colors['editor.findRangeHighlightBackground']), true);
|
||||
assert.equal(typeof colors['editorOverviewRuler.findMatchForeground'], 'string');
|
||||
});
|
||||
|
||||
test('buildNetcattyMonacoThemeColors keeps current find match more visible than others', () => {
|
||||
const colors = buildNetcattyMonacoThemeColors(sampleColors);
|
||||
const currentAlpha = parseInt(colors['editor.findMatchBackground'].slice(-2), 16);
|
||||
const otherAlpha = parseInt(colors['editor.findMatchHighlightBackground'].slice(-2), 16);
|
||||
const rangeAlpha = parseInt(colors['editor.findRangeHighlightBackground'].slice(-2), 16);
|
||||
|
||||
assert.ok(currentAlpha > otherAlpha);
|
||||
assert.ok(otherAlpha > rangeAlpha);
|
||||
});
|
||||
|
||||
test('buildNetcattyMonacoThemeColors still maps core editor chrome from app colors', () => {
|
||||
const colors = buildNetcattyMonacoThemeColors(sampleColors);
|
||||
|
||||
assert.equal(colors['editor.background'], sampleColors.bg);
|
||||
assert.equal(colors['editor.foreground'], sampleColors.fg);
|
||||
assert.equal(colors['editorCursor.foreground'], sampleColors.primary);
|
||||
assert.equal(colors['editor.selectionBackground'], `${sampleColors.primary}40`);
|
||||
});
|
||||
|
||||
test('buildNetcattyMonacoThemeColors softens matching bracket highlight', () => {
|
||||
const colors = buildNetcattyMonacoThemeColors(sampleColors);
|
||||
|
||||
assert.equal(colors['editorBracketMatch.background'], `${sampleColors.primary}14`);
|
||||
assert.equal(colors['editorBracketMatch.border'], `${sampleColors.primary}40`);
|
||||
assert.equal(colors['editor.selectionBackground'], `${sampleColors.primary}40`);
|
||||
});
|
||||
119
infrastructure/monaco/netcattyMonacoTheme.ts
Normal file
119
infrastructure/monaco/netcattyMonacoTheme.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/** Shared Monaco theme colors derived from app CSS variables. */
|
||||
|
||||
export interface NetcattyEditorColors {
|
||||
bg: string;
|
||||
fg: string;
|
||||
primary: string;
|
||||
card: string;
|
||||
mutedFg: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
const hslToHex = (hslString: string): string => {
|
||||
const parts = hslString.trim().split(/\s+/);
|
||||
if (parts.length < 3) return '#1e1e1e';
|
||||
const h = parseFloat(parts[0]) / 360;
|
||||
const s = parseFloat(parts[1].replace('%', '')) / 100;
|
||||
const l = parseFloat(parts[2].replace('%', '')) / 100;
|
||||
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
let r: number;
|
||||
let g: number;
|
||||
let b: number;
|
||||
if (s === 0) {
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
}
|
||||
|
||||
const toHex = (x: number) => {
|
||||
const hex = Math.round(x * 255).toString(16);
|
||||
return hex.length === 1 ? `0${hex}` : hex;
|
||||
};
|
||||
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
};
|
||||
|
||||
const getCssColor = (varName: string, fallback: string): string => {
|
||||
if (typeof document === 'undefined' || typeof getComputedStyle === 'undefined') {
|
||||
return fallback;
|
||||
}
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
.trim();
|
||||
return value ? hslToHex(value) : fallback;
|
||||
};
|
||||
|
||||
export const getNetcattyEditorColors = (isDark: boolean): NetcattyEditorColors => ({
|
||||
bg: getCssColor('--background', isDark ? '#1e1e1e' : '#ffffff'),
|
||||
fg: getCssColor('--foreground', isDark ? '#d4d4d4' : '#1e1e1e'),
|
||||
primary: getCssColor('--primary', isDark ? '#569cd6' : '#0078d4'),
|
||||
card: getCssColor('--card', isDark ? '#252526' : '#f3f3f3'),
|
||||
mutedFg: getCssColor('--muted-foreground', '#858585'),
|
||||
border: getCssColor('--border', isDark ? '#3c3c3c' : '#d4d4d4'),
|
||||
});
|
||||
|
||||
export const getNetcattyThemeSignal = (): string => {
|
||||
if (typeof document === 'undefined' || typeof getComputedStyle === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
const root = document.documentElement;
|
||||
return root.dataset.activeChromeTheme
|
||||
?? getComputedStyle(root).getPropertyValue('--background').trim();
|
||||
};
|
||||
|
||||
export const NETCATTY_MONACO_THEME_DARK = 'netcatty-dark';
|
||||
export const NETCATTY_MONACO_THEME_LIGHT = 'netcatty-light';
|
||||
|
||||
export const getNetcattyMonacoThemeName = (isDark: boolean): string => (
|
||||
isDark ? NETCATTY_MONACO_THEME_DARK : NETCATTY_MONACO_THEME_LIGHT
|
||||
);
|
||||
|
||||
/**
|
||||
* Soft amber find highlights (semi-transparent).
|
||||
* Monaco's built-in light `editor.findMatchBackground` (#A8AC94) is fully
|
||||
* opaque and washes out text on light editor backgrounds; dark's #515C6A is
|
||||
* similarly heavy. Keep alphas below 1 so syntax colors remain readable.
|
||||
*/
|
||||
const FIND_MATCH_CURRENT = '#E8C54766';
|
||||
const FIND_MATCH_OTHER = '#E8C5473A';
|
||||
const FIND_MATCH_RANGE = '#E8C54722';
|
||||
const FIND_MATCH_OVERVIEW = '#E8C54799';
|
||||
|
||||
export const buildNetcattyMonacoThemeColors = (
|
||||
colors: NetcattyEditorColors,
|
||||
): Record<string, string> => ({
|
||||
'editor.background': colors.bg,
|
||||
'editor.foreground': colors.fg,
|
||||
'editorCursor.foreground': colors.primary,
|
||||
'editor.selectionBackground': `${colors.primary}40`,
|
||||
'editor.inactiveSelectionBackground': `${colors.primary}25`,
|
||||
'editorLineNumber.foreground': colors.mutedFg,
|
||||
'editorLineNumber.activeForeground': colors.fg,
|
||||
'editor.lineHighlightBackground': `${colors.fg}08`,
|
||||
// Soft matching-bracket highlight; inherited Monaco #888 border reads too heavy.
|
||||
'editorBracketMatch.background': `${colors.primary}14`,
|
||||
'editorBracketMatch.border': `${colors.primary}40`,
|
||||
'editorWidget.background': colors.card,
|
||||
'editorWidget.foreground': colors.fg,
|
||||
'editorWidget.border': colors.border,
|
||||
'input.background': colors.card,
|
||||
'input.foreground': colors.fg,
|
||||
'input.border': colors.border,
|
||||
'editor.findMatchBackground': FIND_MATCH_CURRENT,
|
||||
'editor.findMatchHighlightBackground': FIND_MATCH_OTHER,
|
||||
'editor.findRangeHighlightBackground': FIND_MATCH_RANGE,
|
||||
'editorOverviewRuler.findMatchForeground': FIND_MATCH_OVERVIEW,
|
||||
});
|
||||
60
infrastructure/monaco/useNetcattyMonacoTheme.ts
Normal file
60
infrastructure/monaco/useNetcattyMonacoTheme.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Monaco } from '@monaco-editor/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
buildNetcattyMonacoThemeColors,
|
||||
getNetcattyEditorColors,
|
||||
getNetcattyMonacoThemeName,
|
||||
getNetcattyThemeSignal,
|
||||
NETCATTY_MONACO_THEME_DARK,
|
||||
NETCATTY_MONACO_THEME_LIGHT,
|
||||
} from './netcattyMonacoTheme';
|
||||
|
||||
export const useNetcattyMonacoTheme = (
|
||||
monaco: Monaco | null | undefined,
|
||||
): string => {
|
||||
const [isDarkTheme, setIsDarkTheme] = useState(() =>
|
||||
typeof document !== 'undefined' && document.documentElement.classList.contains('dark'),
|
||||
);
|
||||
const [themeSignal, setThemeSignal] = useState(() => getNetcattyThemeSignal());
|
||||
const themeName = getNetcattyMonacoThemeName(isDarkTheme);
|
||||
|
||||
useEffect(() => {
|
||||
if (!monaco) return;
|
||||
|
||||
const colors = getNetcattyEditorColors(isDarkTheme);
|
||||
const themeColors = buildNetcattyMonacoThemeColors(colors);
|
||||
|
||||
monaco.editor.defineTheme(NETCATTY_MONACO_THEME_DARK, {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: themeColors,
|
||||
});
|
||||
|
||||
monaco.editor.defineTheme(NETCATTY_MONACO_THEME_LIGHT, {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: themeColors,
|
||||
});
|
||||
|
||||
monaco.editor.setTheme(themeName);
|
||||
}, [monaco, isDarkTheme, themeSignal, themeName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return;
|
||||
const root = document.documentElement;
|
||||
const updateTheme = () => {
|
||||
setIsDarkTheme(root.classList.contains('dark'));
|
||||
setThemeSignal(getNetcattyThemeSignal());
|
||||
};
|
||||
const observer = new MutationObserver(updateTheme);
|
||||
observer.observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style', 'data-active-chrome-theme'],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return themeName;
|
||||
};
|
||||
Reference in New Issue
Block a user