[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:
199
components/scripts/ScriptCodeEditor.tsx
Normal file
199
components/scripts/ScriptCodeEditor.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import Editor, { loader, type Monaco, type OnMount, useMonaco } from '@monaco-editor/react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useImperativeHandle, useRef } from 'react';
|
||||
import { useClipboardBackend } from '@/application/state/useClipboardBackend';
|
||||
import {
|
||||
buildMonacoPasteEdits,
|
||||
pasteForMonacoEditorCommand,
|
||||
readClipboardTextWithFallbacks,
|
||||
} from '@/infrastructure/monaco/monacoClipboardPaste';
|
||||
import { useNetcattyMonacoTheme } from '@/infrastructure/monaco/useNetcattyMonacoTheme';
|
||||
import { registerNctMonacoCompletionProvider } from '@/infrastructure/scripts/nctMonacoCompletion.ts';
|
||||
|
||||
const viteEnv = import.meta.env ?? { BASE_URL: '/' };
|
||||
const monacoBasePath = viteEnv.DEV
|
||||
? './node_modules/monaco-editor/min/vs'
|
||||
: `${viteEnv.BASE_URL}monaco/vs`;
|
||||
loader.config({ paths: { vs: monacoBasePath } });
|
||||
|
||||
export interface ScriptCodeEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
language: 'javascript' | 'python' | 'shell';
|
||||
/** Fill parent flex container (modal). Parent must have explicit height. */
|
||||
fill?: boolean;
|
||||
/** Fixed pixel height (sidebar). Ignored when fill is true. */
|
||||
height?: number;
|
||||
minimap?: boolean;
|
||||
/** Re-layout when container becomes visible (e.g. dialog open). */
|
||||
active?: boolean;
|
||||
/** Move keyboard focus into the editor after it mounts. */
|
||||
autoFocus?: boolean;
|
||||
/** Accessible name announced by screen readers. */
|
||||
ariaLabel?: string;
|
||||
/** Hint shown while the editor is empty. */
|
||||
placeholder?: string;
|
||||
/** Let Tab move to the next control instead of inserting indentation. */
|
||||
tabFocusMode?: boolean;
|
||||
/** Run the surrounding form's submit action for Cmd/Ctrl+Enter. */
|
||||
onSubmitShortcut?: () => void;
|
||||
}
|
||||
|
||||
export interface ScriptCodeEditorHandle {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
export const ScriptCodeEditor = React.forwardRef<ScriptCodeEditorHandle, ScriptCodeEditorProps>(({
|
||||
value,
|
||||
onChange,
|
||||
language,
|
||||
fill = false,
|
||||
height = 240,
|
||||
minimap = false,
|
||||
active = true,
|
||||
autoFocus = false,
|
||||
ariaLabel,
|
||||
placeholder,
|
||||
tabFocusMode = false,
|
||||
onSubmitShortcut,
|
||||
}, forwardedRef) => {
|
||||
const monaco = useMonaco();
|
||||
const themeName = useNetcattyMonacoTheme(monaco ?? undefined);
|
||||
const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend();
|
||||
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
const completionDisposableRef = useRef<{ dispose: () => void } | null>(null);
|
||||
const onSubmitShortcutRef = useRef(onSubmitShortcut);
|
||||
onSubmitShortcutRef.current = onSubmitShortcut;
|
||||
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const readClipboardTextRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
focus: () => editorRef.current?.focus(),
|
||||
}), []);
|
||||
|
||||
useEffect(() => () => {
|
||||
completionDisposableRef.current?.dispose();
|
||||
completionDisposableRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !editorRef.current) return;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
editorRef.current?.layout();
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [active, fill, height]);
|
||||
|
||||
const readClipboardText = useCallback(async (): Promise<string | null> => (
|
||||
readClipboardTextWithFallbacks({
|
||||
readNavigator: navigator.clipboard?.readText
|
||||
? () => navigator.clipboard.readText()
|
||||
: undefined,
|
||||
readBridge: readClipboardTextFromBridge,
|
||||
})
|
||||
), [readClipboardTextFromBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
readClipboardTextRef.current = readClipboardText;
|
||||
}, [readClipboardText]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const text = await readClipboardText();
|
||||
|
||||
if (text === null) {
|
||||
// Clipboard read unavailable; fall back to Monaco's native paste.
|
||||
editor.trigger('keyboard', 'editor.action.clipboardPasteAction', null);
|
||||
return;
|
||||
}
|
||||
if (!text) return;
|
||||
|
||||
const selections = editor.getSelections();
|
||||
if (!selections || selections.length === 0) return;
|
||||
|
||||
editor.executeEdits('netcatty-paste', buildMonacoPasteEdits(text, selections));
|
||||
editor.focus();
|
||||
}, [readClipboardText]);
|
||||
|
||||
useEffect(() => {
|
||||
handlePasteRef.current = handlePaste;
|
||||
}, [handlePaste]);
|
||||
|
||||
const handleMount: OnMount = useCallback((editor, monacoInstance) => {
|
||||
editorRef.current = editor;
|
||||
completionDisposableRef.current?.dispose();
|
||||
completionDisposableRef.current = language === 'javascript'
|
||||
? registerNctMonacoCompletionProvider(monacoInstance)
|
||||
: null;
|
||||
if (onSubmitShortcut) {
|
||||
editor.addCommand(
|
||||
monacoInstance.KeyMod.CtrlCmd | monacoInstance.KeyCode.Enter,
|
||||
() => onSubmitShortcutRef.current?.(),
|
||||
);
|
||||
}
|
||||
// Fallback paste path for Electron where Monaco clipboardPasteAction can fail.
|
||||
// When focus is in the find/replace widget, paste into that input instead of the body.
|
||||
editor.addCommand(
|
||||
monacoInstance.KeyMod.CtrlCmd | monacoInstance.KeyCode.KeyV,
|
||||
() => {
|
||||
void pasteForMonacoEditorCommand({
|
||||
activeElement: document.activeElement,
|
||||
readClipboardText: () => readClipboardTextRef.current(),
|
||||
pasteIntoEditor: () => handlePasteRef.current(),
|
||||
});
|
||||
},
|
||||
);
|
||||
requestAnimationFrame(() => editor.layout());
|
||||
if (autoFocus) editor.focus();
|
||||
}, [autoFocus, language, onSubmitShortcut]);
|
||||
|
||||
const editorHeight = fill ? '100%' : `${height}px`;
|
||||
|
||||
return (
|
||||
<div className={fill ? 'h-full min-h-0 relative' : 'relative'} style={fill ? undefined : { height }}>
|
||||
<Editor
|
||||
height={editorHeight}
|
||||
language={language}
|
||||
value={value}
|
||||
onChange={(next) => onChange(next ?? '')}
|
||||
onMount={handleMount}
|
||||
theme={themeName}
|
||||
loading={(
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
options={{
|
||||
// Prefer native context menu in Electron so right-click Paste uses OS clipboard path.
|
||||
contextmenu: false,
|
||||
minimap: { enabled: minimap },
|
||||
fontSize: 13,
|
||||
lineNumbers: 'on',
|
||||
wordWrap: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
folding: true,
|
||||
renderLineHighlight: 'line',
|
||||
padding: { top: 8, bottom: 8 },
|
||||
bracketPairColorization: { enabled: true },
|
||||
ariaLabel,
|
||||
tabFocusMode,
|
||||
}}
|
||||
/>
|
||||
{placeholder && !value ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute left-[52px] top-2 z-10 font-mono text-[13px] text-muted-foreground"
|
||||
>
|
||||
{placeholder}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ScriptCodeEditor.displayName = 'ScriptCodeEditor';
|
||||
Reference in New Issue
Block a user