mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 17:10:08 +00:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3a7dd4882 | ||
|
|
afd382473a | ||
|
|
9992217832 | ||
|
|
bb47a32375 | ||
|
|
06caab8d28 | ||
|
|
070a9f0daa | ||
|
|
6db5f823ba | ||
|
|
180470a03b | ||
|
|
a3380d26e8 | ||
|
|
5dbede1368 | ||
|
|
715f71f18c | ||
|
|
d809b6c3fa | ||
|
|
65d334ff8d | ||
|
|
a09a0cb4ec | ||
|
|
101b686dbd | ||
|
|
5f67c8b720 | ||
|
|
eb615b9da5 | ||
|
|
4f3ca3ec9d | ||
|
|
f556c9688f | ||
|
|
00d1c819cf | ||
|
|
8bae9ea584 | ||
|
|
5ad4eaa309 | ||
|
|
981807e6ca | ||
|
|
2f020e6513 | ||
|
|
4733cb77fb | ||
|
|
cab224e642 | ||
|
|
08044ee987 | ||
|
|
b27aef1cd8 | ||
|
|
340f53722c | ||
|
|
cbd25c3fef | ||
|
|
b21e2a7e4b | ||
|
|
132aef813a | ||
|
|
2ad03f5ad2 | ||
|
|
2967a5a189 |
No files matched your search
@@ -29,6 +29,8 @@ export const enVaultMessages: Messages = {
|
||||
'notes.mode.preview': 'Preview',
|
||||
'notes.title.placeholder': 'Note title',
|
||||
'notes.editor.placeholder': 'Write Markdown notes here...',
|
||||
'notes.editor.unrenderableMarkdown': 'This note contains Markdown the rich editor cannot display (for example unbalanced <tag> text). Showing the raw Markdown source instead — the content is intact.',
|
||||
'notes.editor.retryRichView': 'Retry rich view',
|
||||
'notes.preview.empty': 'Nothing to preview yet.',
|
||||
'notes.empty.title': 'No notes yet',
|
||||
'notes.empty.desc': 'Create Markdown notes for runbooks, reminders, and server handoff details.',
|
||||
|
||||
@@ -29,6 +29,8 @@ export const esVaultMessages: Messages = {
|
||||
'notes.mode.preview': 'Vista previa',
|
||||
'notes.title.placeholder': 'Título de la nota',
|
||||
'notes.editor.placeholder': 'Escribe notas Markdown aquí...',
|
||||
'notes.editor.unrenderableMarkdown': 'Esta nota contiene Markdown que el editor enriquecido no puede mostrar (por ejemplo, etiquetas sin cerrar como <tag>). Se muestra el origen Markdown sin cambios: el contenido está intacto.',
|
||||
'notes.editor.retryRichView': 'Reintentar vista enriquecida',
|
||||
'notes.preview.empty': 'Aún no hay nada para previsualizar.',
|
||||
'notes.empty.title': 'Aún no hay notas',
|
||||
'notes.empty.desc': 'Crea notas Markdown para runbooks, recordatorios y detalles de traspaso de servidores.',
|
||||
|
||||
@@ -29,6 +29,8 @@ export const ruVaultMessages: Messages = {
|
||||
'notes.mode.preview': 'Просмотр',
|
||||
'notes.title.placeholder': 'Заголовок заметки',
|
||||
'notes.editor.placeholder': 'Пишите Markdown-заметки здесь...',
|
||||
'notes.editor.unrenderableMarkdown': 'Эта заметка содержит Markdown, который не может отобразить визуальный редактор (например, незакрытые теги вида <tag>). Показан исходный Markdown — содержимое не потеряно.',
|
||||
'notes.editor.retryRichView': 'Повторить в визуальном режиме',
|
||||
'notes.preview.empty': 'Пока нечего просматривать.',
|
||||
'notes.empty.title': 'Заметок пока нет',
|
||||
'notes.empty.desc': 'Создавайте Markdown-заметки для инструкций, напоминаний и передачи серверов.',
|
||||
|
||||
@@ -29,6 +29,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'notes.mode.preview': '预览',
|
||||
'notes.title.placeholder': '笔记标题',
|
||||
'notes.editor.placeholder': '在这里写 Markdown 笔记...',
|
||||
'notes.editor.unrenderableMarkdown': '这篇笔记包含富文本编辑器无法显示的 Markdown(例如未闭合的 <tag> 文本),已改为显示原始 Markdown 源码,内容并没有丢失。',
|
||||
'notes.editor.retryRichView': '重试富文本视图',
|
||||
'notes.preview.empty': '暂无内容可预览。',
|
||||
'notes.empty.title': '还没有笔记',
|
||||
'notes.empty.desc': '可以记录运维手册、临时备忘、服务器交接信息。',
|
||||
|
||||
@@ -1032,8 +1032,8 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.goHome': '返回主目錄',
|
||||
'sftp.folderName': '資料夾名稱',
|
||||
'sftp.folderName.placeholder': '輸入資料夾名稱',
|
||||
'sftp.fileName': '檔名稱',
|
||||
'sftp.fileName.placeholder': '輸入檔名稱',
|
||||
'sftp.fileName': '檔案名稱',
|
||||
'sftp.fileName.placeholder': '輸入檔案名稱',
|
||||
'sftp.prompt.newFolderName': '新增資料夾名稱?',
|
||||
'sftp.rename.title': '重新命名',
|
||||
'sftp.rename.newName': '新名稱',
|
||||
|
||||
@@ -29,6 +29,8 @@ export const zhTWVaultMessages: Messages = {
|
||||
'notes.mode.preview': '預覽',
|
||||
'notes.title.placeholder': '筆記標題',
|
||||
'notes.editor.placeholder': '在這裡寫 Markdown 筆記...',
|
||||
'notes.editor.unrenderableMarkdown': '這篇筆記包含富文本編輯器無法顯示的 Markdown(例如未閉合的 <tag> 文字),已改為顯示原始 Markdown 原始碼,內容並沒有遺失。',
|
||||
'notes.editor.retryRichView': '重試富文字檢視',
|
||||
'notes.preview.empty': '暫無內容可預覽。',
|
||||
'notes.empty.title': '還沒有筆記',
|
||||
'notes.empty.desc': '可以記錄運維手冊、臨時備忘、伺服器交接資訊。',
|
||||
|
||||
@@ -110,6 +110,9 @@ test("associates the transfer-center folder warning with every destructive Repla
|
||||
const describedBy = button.getAttribute("aria-describedby");
|
||||
assert.ok(describedBy, `${button.textContent} should expose the folder deletion warning`);
|
||||
const warning = env.document.getElementById(describedBy);
|
||||
assert.match(warning?.textContent ?? "", /Replace deletes all destination files and sub-folders/);
|
||||
assert.match(
|
||||
warning?.textContent ?? "",
|
||||
/Replace: deletes destination-only content and cannot be undone/,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -137,6 +137,7 @@ import { createProgrammaticCommandLogRewriter, type ProgrammaticCommandLogRewrit
|
||||
import { getSessionLogInitialLine } from "./terminal/sessionLogInitialLine";
|
||||
import { getTerminalSelectionForClipboard } from "./terminal/normalizeTerminalSelection";
|
||||
import { getHistoryPreviewSelectionFromRoot } from "./terminal/runtime/terminalHistoryScrollOverride";
|
||||
import { createTerminalOutputHistoryPreview } from "./terminal/runtime/terminalOutputHistory";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import {
|
||||
createTerminalSessionStarters,
|
||||
@@ -557,6 +558,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const manualReconnectRequestRef = useRef<() => void>(() => {});
|
||||
const terminalDataCapturedRef = useRef(false);
|
||||
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
|
||||
const terminalOutputHistoryRef = useRef(createTerminalOutputHistoryPreview());
|
||||
const terminalLogSanitizerRef = useRef(createReplaySafeTerminalLogSanitizer());
|
||||
const commandLogRewriterRef = useRef(createProgrammaticCommandLogRewriter());
|
||||
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
|
||||
@@ -629,6 +631,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const captureTerminalLogData = useCallback((data: string) => {
|
||||
const readableCommandData = commandLogRewriterRef.current.append(data);
|
||||
// The alternate-screen preview reads the raw display stream (before the
|
||||
// replay sanitizer, which drops alternate-screen output on purpose) but
|
||||
// after the command rewriter, so masked commands stay masked (#2516).
|
||||
terminalOutputHistoryRef.current.append(readableCommandData);
|
||||
const replaySafeData = terminalLogSanitizerRef.current.append(readableCommandData);
|
||||
if (!replaySafeData) return;
|
||||
connectionLogBufferRef.current.append(replaySafeData);
|
||||
@@ -3992,6 +3998,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
serialLineBufferRef,
|
||||
telnetLocalEchoRef,
|
||||
onTerminalLogData: captureTerminalLogData,
|
||||
terminalOutputHistory: terminalOutputHistoryRef.current,
|
||||
onCwdChange: (cwd: string) => {
|
||||
pluginAwareOnRuntimeCwdChange(cwd, { source: 'osc7' });
|
||||
},
|
||||
@@ -4269,7 +4276,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onWake: wakeFromHibernateRuntime,
|
||||
});
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "@mdxeditor/editor";
|
||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import { tags } from "@lezer/highlight";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { AlertTriangle, ExternalLink } from "lucide-react";
|
||||
import {
|
||||
$createRangeSelection,
|
||||
$getNearestNodeFromDOMNode,
|
||||
@@ -149,6 +149,14 @@ export interface InlineMarkdownEditorProps {
|
||||
|
||||
export const NOTE_EDIT_DECORATION_DEBOUNCE_MS = 160;
|
||||
|
||||
export const shouldApplyMdxParseFailure = (input: {
|
||||
currentNoteId?: string;
|
||||
failedNoteId?: string;
|
||||
currentMarkdown: string;
|
||||
failedMarkdown: string;
|
||||
}): boolean => input.currentNoteId === input.failedNoteId
|
||||
&& input.currentMarkdown.trim() === input.failedMarkdown.trim();
|
||||
|
||||
export const getNoteDecorationMutationDelay = (editorMode: NoteEditorMode): number =>
|
||||
editorMode === "edit" || editorMode === "live" ? NOTE_EDIT_DECORATION_DEBOUNCE_MS : 0;
|
||||
|
||||
@@ -978,6 +986,12 @@ export const InlineMarkdownEditor = React.memo(
|
||||
return;
|
||||
}
|
||||
|
||||
// Unrenderable-markdown fallback: the raw source editor is mounted.
|
||||
if (mdxFallbackActiveRef.current) {
|
||||
fallbackSourceEditorRef.current?.insertAction(action);
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
const editable = container?.querySelector("[contenteditable]");
|
||||
const lexicalEditor = editable ? getNearestEditorFromDOMNode(editable) : null;
|
||||
@@ -1056,12 +1070,19 @@ export const InlineMarkdownEditor = React.memo(
|
||||
}
|
||||
},
|
||||
focus: () => {
|
||||
if (mdxFallbackActiveRef.current) {
|
||||
fallbackSourceEditorRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
editorRef.current?.focus();
|
||||
},
|
||||
scrollToHeading: (heading: NoteHeadingItem, headingIndex: number) => {
|
||||
if (controlledEditorMode === "source") {
|
||||
return sourceEditorRef?.current?.scrollToLine(heading.line) ?? false;
|
||||
}
|
||||
if (mdxFallbackActiveRef.current) {
|
||||
return fallbackSourceEditorRef.current?.scrollToLine(heading.line) ?? false;
|
||||
}
|
||||
const occurrence = extractNoteHeadings(value)
|
||||
.slice(0, headingIndex)
|
||||
.filter((item) => item.level === heading.level && item.text === heading.text)
|
||||
@@ -1083,10 +1104,20 @@ export const InlineMarkdownEditor = React.memo(
|
||||
const [linkAction, setLinkAction] = useState<LinkActionState | null>(null);
|
||||
const [acceptedSourceMarkdown, setAcceptedSourceMarkdown] = useState(value);
|
||||
const [isContentSwapping, setIsContentSwapping] = useState(false);
|
||||
// MDX (the rich editor's parser) rejects some CommonMark — e.g. unbalanced
|
||||
// angle tags like `<host>` in prose. MDXEditor then renders an EMPTY document
|
||||
// and only reports the failure through onError, which made such notes look
|
||||
// like they had lost their content. Track the failure and fall back to the
|
||||
// raw markdown source view so the content always stays visible.
|
||||
const [mdxParseFailure, setMdxParseFailure] = useState<string | null>(null);
|
||||
const linkActionRef = useRef<LinkActionState | null>(null);
|
||||
linkActionRef.current = linkAction;
|
||||
const mouseMoveFrameRef = useRef(0);
|
||||
const editorMode = controlledEditorMode ?? "edit";
|
||||
const mdxFallbackActive = mdxParseFailure !== null && editorMode !== "source";
|
||||
const mdxFallbackActiveRef = useRef(false);
|
||||
mdxFallbackActiveRef.current = mdxFallbackActive;
|
||||
const fallbackSourceEditorRef = useRef<NoteSourceEditorHandle | null>(null);
|
||||
const hostPickerRangeRef = useRef<Range | null>(null);
|
||||
const hostPickerListRef = useRef<FixedSizeVirtualListHandle>(null);
|
||||
const hostsRef = useRef(hosts);
|
||||
@@ -1097,7 +1128,7 @@ export const InlineMarkdownEditor = React.memo(
|
||||
// buttons. Listens to Lexical selection/update changes in edit/live modes.
|
||||
useEffect(() => {
|
||||
if (!onActiveFormatsChange) return;
|
||||
if (editorMode !== "edit" && editorMode !== "live") {
|
||||
if (mdxFallbackActive || (editorMode !== "edit" && editorMode !== "live")) {
|
||||
onActiveFormatsChange(EMPTY_ACTIVE_FORMATS);
|
||||
return;
|
||||
}
|
||||
@@ -1141,7 +1172,7 @@ export const InlineMarkdownEditor = React.memo(
|
||||
unregisterUpdate();
|
||||
unregisterSelection();
|
||||
};
|
||||
}, [editorMode, onActiveFormatsChange]);
|
||||
}, [editorMode, mdxFallbackActive, onActiveFormatsChange]);
|
||||
|
||||
const setLinkActionIfChanged = useCallback((next: LinkActionState | null) => {
|
||||
if (linkActionStatesEqual(linkActionRef.current, next)) return;
|
||||
@@ -1226,6 +1257,10 @@ export const InlineMarkdownEditor = React.memo(
|
||||
const scheduledNoteId = noteId;
|
||||
noteIdRef.current = noteId;
|
||||
pasteRecoveryGenerationRef.current += 1;
|
||||
// The new note's markdown gets a fresh MDX import; clear a previous
|
||||
// note's unrenderable-markdown fallback.
|
||||
setMdxParseFailure(null);
|
||||
setMdxRetry(null);
|
||||
// Keep both refs in displayMarkdown space so public-path normalization
|
||||
// does not look like a divergent local draft.
|
||||
latestMarkdownRef.current = markdown;
|
||||
@@ -1322,6 +1357,15 @@ export const InlineMarkdownEditor = React.memo(
|
||||
latestSourceMarkdownRef.current = value;
|
||||
syncedSourceMarkdownRef.current = value;
|
||||
setAcceptedSourceMarkdown(value);
|
||||
if (displayChanged && mdxFallbackActiveRef.current) {
|
||||
// A sync/publish update replaced this note's unrenderable markdown while
|
||||
// the source fallback was active. editorRef.setMarkdown below would hit
|
||||
// the unmounted MDXEditor, so clear the failure instead: the rich editor
|
||||
// remounts with the new markdown. If it is still unrenderable, onError
|
||||
// re-arms the fallback.
|
||||
setMdxParseFailure(null);
|
||||
setMdxRetry(null);
|
||||
}
|
||||
if (!displayChanged) return;
|
||||
try {
|
||||
editorRef.current?.setMarkdown(markdown);
|
||||
@@ -1371,6 +1415,10 @@ export const InlineMarkdownEditor = React.memo(
|
||||
|
||||
useEffect(() => {
|
||||
setLinkAction(null);
|
||||
// Mode changes remount the rich editor (key={editorMode}); give its MDX
|
||||
// import a fresh chance after an unrenderable-markdown fallback.
|
||||
setMdxParseFailure(null);
|
||||
setMdxRetry(null);
|
||||
}, [editorMode]);
|
||||
|
||||
const getHostPickerContext = useCallback(() => {
|
||||
@@ -1546,6 +1594,39 @@ export const InlineMarkdownEditor = React.memo(
|
||||
onChange(markdown);
|
||||
}, [onChange]);
|
||||
|
||||
// MDXEditor reports markdown it cannot import through onError (the lexical
|
||||
// document is left empty). Switch to the raw source view so the note content
|
||||
// is never silently hidden; the user can retry or edit the raw markdown.
|
||||
// The failure is reported while MDXEditor's realm is still rendering, so a
|
||||
// synchronous setState here would be dropped — defer it to a timer.
|
||||
const handleMdxParseError = useCallback((payload: { error: string; source: string }) => {
|
||||
const failedNoteId = noteIdRef.current;
|
||||
const failedMarkdown = normalizeNotePublicAssetPaths(payload.source);
|
||||
window.setTimeout(() => {
|
||||
if (!shouldApplyMdxParseFailure({
|
||||
currentNoteId: noteIdRef.current,
|
||||
failedNoteId,
|
||||
currentMarkdown: latestMarkdownRef.current,
|
||||
failedMarkdown,
|
||||
})) return;
|
||||
// The same note can receive a newer external value before the deferred
|
||||
// callback runs. Do not show the old import failure over that content.
|
||||
setMdxParseFailure((current) => current ?? payload.error);
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
// One-shot markdown for the retry after a fallback: NotesManager debounces
|
||||
// source drafts, so the `value` prop can still hold the pre-fix body when
|
||||
// the user retries right after editing in the fallback. Seed that retry
|
||||
// import from the editor's own latest accepted source instead, but only while
|
||||
// the parent prop is still the stale body that originally failed.
|
||||
const [mdxRetry, setMdxRetry] = useState<{ markdown: string; staleValue: string } | null>(null);
|
||||
|
||||
const retryRichNoteEditor = useCallback(() => {
|
||||
setMdxRetry({ markdown: latestSourceMarkdownRef.current, staleValue: value });
|
||||
setMdxParseFailure(null);
|
||||
}, [value]);
|
||||
|
||||
const insertHostLink = useCallback((host: Host) => {
|
||||
const link = `[${getHostLinkLabel(host)}](${formatSshDeepLinkForHost(host)})`;
|
||||
const editor = editorRef.current;
|
||||
@@ -2093,6 +2174,37 @@ export const InlineMarkdownEditor = React.memo(
|
||||
noteFontFamily={noteFontFamily}
|
||||
noteFontSize={noteCodeFontSize || noteFontSize}
|
||||
/>
|
||||
) : mdxFallbackActive ? (
|
||||
<div
|
||||
data-note-markdown-source-fallback="true"
|
||||
className="flex min-h-[calc(100vh-15rem)] min-w-0 flex-1 flex-col"
|
||||
>
|
||||
<div
|
||||
data-note-markdown-source-notice="true"
|
||||
className="flex items-start gap-2 border-b border-border/60 bg-muted/40 px-4 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1">{t("notes.editor.unrenderableMarkdown")}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-note-markdown-source-retry="true"
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-xs text-foreground underline decoration-dotted hover:bg-secondary"
|
||||
onClick={retryRichNoteEditor}
|
||||
>
|
||||
{t("notes.editor.retryRichView")}
|
||||
</button>
|
||||
</div>
|
||||
<NoteSourceEditor
|
||||
ref={fallbackSourceEditorRef}
|
||||
noteId={noteId}
|
||||
value={noteId !== undefined && noteId !== noteIdRef.current ? value : acceptedSourceMarkdown}
|
||||
placeholder={placeholder}
|
||||
onChange={commitSourceMarkdown}
|
||||
readOnly={editorMode === "preview"}
|
||||
noteFontFamily={noteFontFamily}
|
||||
noteFontSize={noteCodeFontSize || noteFontSize}
|
||||
/>
|
||||
</div>
|
||||
) : editorMode === "preview" && !displayMarkdown.trim() ? (
|
||||
<div className="netcatty-note-preview-empty">
|
||||
{previewEmptyLabel ?? placeholder}
|
||||
@@ -2101,7 +2213,11 @@ export const InlineMarkdownEditor = React.memo(
|
||||
<MDXEditor
|
||||
key={editorMode}
|
||||
ref={editorRef}
|
||||
markdown={displayMarkdown}
|
||||
markdown={
|
||||
mdxRetry !== null && value === mdxRetry.staleValue
|
||||
? normalizeNotePublicAssetPaths(mdxRetry.markdown)
|
||||
: displayMarkdown
|
||||
}
|
||||
placeholder={placeholder}
|
||||
plugins={plugins}
|
||||
readOnly={editorMode === "preview"}
|
||||
@@ -2111,6 +2227,7 @@ export const InlineMarkdownEditor = React.memo(
|
||||
)}
|
||||
contentEditableClassName="netcatty-mdx-content"
|
||||
onChange={commitMarkdown}
|
||||
onError={handleMdxParseError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import { runWithAct } from "../test-support/renderReactDom.tsx";
|
||||
|
||||
/**
|
||||
* Regression tests for the notes rich editor: markdown that MDX cannot parse
|
||||
* (e.g. unbalanced angle tags such as `<host>` in prose) used to render as an
|
||||
* empty editor, so an AI-written or imported note looked like it had lost its
|
||||
* content. The editor must fall back to the raw markdown source view instead.
|
||||
*/
|
||||
|
||||
const NOTE_MARKDOWN_MDX_CANNOT_PARSE = [
|
||||
"# SlurmDB Agent Skill",
|
||||
"",
|
||||
"## Overview",
|
||||
"",
|
||||
"Run the exporter with mysql -h <host> -u <user> -P 3306.",
|
||||
"",
|
||||
"The content after the angle tags must stay visible.",
|
||||
].join("\n");
|
||||
|
||||
const PLAIN_NOTE_MARKDOWN = ["# Steps", "", "Promote the replica, then restart the agent."].join("\n");
|
||||
|
||||
const setupDom = () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost",
|
||||
});
|
||||
const window = dom.window;
|
||||
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
|
||||
const installGlobal = (key: string, value: unknown) => {
|
||||
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
};
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries({
|
||||
window,
|
||||
document: window.document,
|
||||
navigator: window.navigator,
|
||||
HTMLElement: window.HTMLElement,
|
||||
HTMLInputElement: window.HTMLInputElement,
|
||||
HTMLTextAreaElement: window.HTMLTextAreaElement,
|
||||
HTMLSelectElement: window.HTMLSelectElement,
|
||||
Element: window.Element,
|
||||
SVGElement: window.SVGElement,
|
||||
Node: window.Node,
|
||||
NodeFilter: window.NodeFilter,
|
||||
MutationObserver: window.MutationObserver,
|
||||
CustomEvent: window.CustomEvent,
|
||||
DOMRect: window.DOMRect,
|
||||
Event: window.Event,
|
||||
KeyboardEvent: window.KeyboardEvent,
|
||||
MouseEvent: window.MouseEvent,
|
||||
getComputedStyle: window.getComputedStyle.bind(window),
|
||||
requestAnimationFrame: window.requestAnimationFrame.bind(window),
|
||||
cancelAnimationFrame: window.cancelAnimationFrame.bind(window),
|
||||
ResizeObserver: ResizeObserverStub,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
})) {
|
||||
installGlobal(key, value);
|
||||
}
|
||||
|
||||
return {
|
||||
window,
|
||||
cleanup() {
|
||||
for (const [key, descriptor] of previousGlobals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else delete (globalThis as Record<string, unknown>)[key];
|
||||
}
|
||||
dom.window.close();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type DomHarness = ReturnType<typeof setupDom>;
|
||||
|
||||
type ActiveFormats = {
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
underline: boolean;
|
||||
strikethrough: boolean;
|
||||
code: boolean;
|
||||
};
|
||||
|
||||
type RenderEditorProps = {
|
||||
value: string;
|
||||
editorMode?: "edit" | "preview" | "source";
|
||||
onActiveFormatsChange?: (formats: ActiveFormats) => void;
|
||||
};
|
||||
|
||||
const renderEditor = async (
|
||||
window: DomHarness["window"],
|
||||
props: RenderEditorProps,
|
||||
) => {
|
||||
const { act } = await import("react");
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
const { I18nProvider } = await import("../../application/i18n/I18nProvider.tsx");
|
||||
const { InlineMarkdownEditor } = await import("./InlineMarkdownEditor.tsx");
|
||||
|
||||
const rootNode = window.document.getElementById("root");
|
||||
assert.ok(rootNode);
|
||||
const root = createRoot(rootNode);
|
||||
const changes: string[] = [];
|
||||
const render = async (nextProps: RenderEditorProps) => act(async () => {
|
||||
root.render(
|
||||
<I18nProvider locale="en">
|
||||
<InlineMarkdownEditor
|
||||
noteId="note-1"
|
||||
value={nextProps.value}
|
||||
placeholder="Write Markdown notes here..."
|
||||
editorMode={nextProps.editorMode ?? "edit"}
|
||||
onChange={(next) => changes.push(next)}
|
||||
onActiveFormatsChange={nextProps.onActiveFormatsChange}
|
||||
hosts={[]}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
await render(props);
|
||||
// Let the deferred MDX import (and its parse-error reporting) settle.
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
return {
|
||||
rootNode,
|
||||
changes,
|
||||
rerender: render,
|
||||
async unmount() {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const querySourceFallback = (rootNode: HTMLElement) =>
|
||||
rootNode.querySelector<HTMLTextAreaElement>("[data-note-markdown-source-fallback] textarea");
|
||||
|
||||
const setTextareaValue = (window: DomHarness["window"], textarea: HTMLTextAreaElement, nextValue: string) => {
|
||||
const setValue = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
|
||||
assert.ok(setValue);
|
||||
setValue.call(textarea, nextValue);
|
||||
textarea.dispatchEvent(new window.Event("input", { bubbles: true }));
|
||||
};
|
||||
|
||||
test("unrenderable markdown stays visible via the raw source fallback", async () => {
|
||||
const { window, cleanup } = setupDom();
|
||||
try {
|
||||
const { rootNode, unmount } = await renderEditor(window, { value: NOTE_MARKDOWN_MDX_CANNOT_PARSE });
|
||||
|
||||
const fallback = querySourceFallback(rootNode);
|
||||
assert.ok(fallback, "expected the raw markdown fallback to be rendered");
|
||||
assert.equal(fallback.value, NOTE_MARKDOWN_MDX_CANNOT_PARSE);
|
||||
|
||||
const notice = rootNode.querySelector("[data-note-markdown-source-notice]");
|
||||
assert.ok(notice, "expected an explanatory notice next to the fallback");
|
||||
|
||||
// The rich editor must not sit next to the fallback showing a blank page.
|
||||
assert.equal(rootNode.querySelectorAll("[contenteditable]").length, 0);
|
||||
await unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("plain markdown still renders in the rich editor", async () => {
|
||||
const { window, cleanup } = setupDom();
|
||||
try {
|
||||
const { rootNode, unmount } = await renderEditor(window, { value: PLAIN_NOTE_MARKDOWN });
|
||||
|
||||
assert.equal(querySourceFallback(rootNode), null);
|
||||
const editable = rootNode.querySelector<HTMLElement>("[contenteditable]");
|
||||
assert.ok(editable, "expected the rich editor to stay mounted");
|
||||
assert.match(editable.textContent || "", /Promote the replica/);
|
||||
await unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("unrenderable markdown in preview mode falls back to a read-only source view", async () => {
|
||||
const { window, cleanup } = setupDom();
|
||||
try {
|
||||
const { rootNode, changes, unmount } = await renderEditor(window, {
|
||||
value: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
|
||||
editorMode: "preview",
|
||||
});
|
||||
|
||||
const fallback = querySourceFallback(rootNode);
|
||||
assert.ok(fallback, "expected the raw markdown fallback in preview mode");
|
||||
assert.equal(fallback.value, NOTE_MARKDOWN_MDX_CANNOT_PARSE);
|
||||
assert.equal(fallback.readOnly, true);
|
||||
|
||||
// Read-only must also block the custom Tab insertion and history handling.
|
||||
await runWithAct(async () => {
|
||||
fallback.dispatchEvent(new window.KeyboardEvent("keydown", {
|
||||
key: "Tab",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
});
|
||||
assert.deepEqual(changes, [], "read-only fallback must not mutate the note");
|
||||
await unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("retry imports the latest fallback draft, not the stale prop value", async () => {
|
||||
const { window, cleanup } = setupDom();
|
||||
try {
|
||||
// The host keeps the original (invalid) `value` prop, like the 300 ms
|
||||
// draft debounce in NotesManager does while the user edits the fallback.
|
||||
const { rootNode, changes, unmount } = await renderEditor(window, { value: NOTE_MARKDOWN_MDX_CANNOT_PARSE });
|
||||
|
||||
const fallback = querySourceFallback(rootNode);
|
||||
assert.ok(fallback);
|
||||
|
||||
const fixedMarkdown = NOTE_MARKDOWN_MDX_CANNOT_PARSE.replace("mysql -h <host> -u <user>", "mysql with host and user");
|
||||
await runWithAct(async () => {
|
||||
setTextareaValue(window, fallback, fixedMarkdown);
|
||||
});
|
||||
assert.deepEqual(changes, [fixedMarkdown]);
|
||||
|
||||
const retry = rootNode.querySelector<HTMLButtonElement>("[data-note-markdown-source-retry]");
|
||||
assert.ok(retry, "expected a retry action on the fallback notice");
|
||||
await runWithAct(async () => {
|
||||
retry.click();
|
||||
});
|
||||
await runWithAct(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
|
||||
const editable = rootNode.querySelector<HTMLElement>("[contenteditable]");
|
||||
assert.ok(editable, "expected the rich editor back after the retry");
|
||||
assert.match(editable.textContent || "", /The content after the angle tags/);
|
||||
assert.equal(querySourceFallback(rootNode), null, "the fallback must be gone after a successful retry");
|
||||
await unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("retry rebinds active-format listeners after the rich editor remounts", async () => {
|
||||
const { window, cleanup } = setupDom();
|
||||
try {
|
||||
const formatChanges: ActiveFormats[] = [];
|
||||
const onActiveFormatsChange = (formats: ActiveFormats) => {
|
||||
formatChanges.push(formats);
|
||||
};
|
||||
const { rootNode, changes, unmount } = await renderEditor(window, {
|
||||
value: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
|
||||
onActiveFormatsChange,
|
||||
});
|
||||
|
||||
assert.ok(querySourceFallback(rootNode), "expected the raw markdown fallback");
|
||||
assert.equal(rootNode.querySelectorAll("[contenteditable]").length, 0);
|
||||
const callsWhileFallback = formatChanges.length;
|
||||
|
||||
const fallback = querySourceFallback(rootNode);
|
||||
assert.ok(fallback);
|
||||
const fixedMarkdown = NOTE_MARKDOWN_MDX_CANNOT_PARSE.replace(
|
||||
"mysql -h <host> -u <user>",
|
||||
"mysql with host and user",
|
||||
);
|
||||
await runWithAct(async () => {
|
||||
setTextareaValue(window, fallback, fixedMarkdown);
|
||||
});
|
||||
assert.deepEqual(changes, [fixedMarkdown]);
|
||||
|
||||
const retry = rootNode.querySelector<HTMLButtonElement>("[data-note-markdown-source-retry]");
|
||||
assert.ok(retry, "expected a retry action on the fallback notice");
|
||||
await runWithAct(async () => {
|
||||
retry.click();
|
||||
});
|
||||
await runWithAct(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
|
||||
const editable = rootNode.querySelector<HTMLElement>("[contenteditable]");
|
||||
assert.ok(editable, "expected the rich editor back after the retry");
|
||||
assert.equal(querySourceFallback(rootNode), null, "the fallback must be gone after a successful retry");
|
||||
assert.ok(
|
||||
formatChanges.length > callsWhileFallback,
|
||||
"expected onActiveFormatsChange after the rich editor remounted",
|
||||
);
|
||||
await unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("same-note external markdown clears an active fallback", async () => {
|
||||
const { window, cleanup } = setupDom();
|
||||
try {
|
||||
const { rootNode, rerender, unmount } = await renderEditor(window, {
|
||||
value: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
|
||||
});
|
||||
assert.ok(querySourceFallback(rootNode));
|
||||
|
||||
await rerender({ value: PLAIN_NOTE_MARKDOWN });
|
||||
await runWithAct(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
|
||||
assert.equal(querySourceFallback(rootNode), null);
|
||||
assert.match(rootNode.querySelector<HTMLElement>("[contenteditable]")?.textContent || "", /Promote the replica/);
|
||||
await unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("leading and trailing whitespace does not hide an unrenderable note", async () => {
|
||||
const { window, cleanup } = setupDom();
|
||||
try {
|
||||
const { rootNode, unmount } = await renderEditor(window, {
|
||||
value: `\n${NOTE_MARKDOWN_MDX_CANNOT_PARSE}\n`,
|
||||
});
|
||||
|
||||
const fallback = querySourceFallback(rootNode);
|
||||
assert.ok(fallback, "expected padded unrenderable markdown to use the source fallback");
|
||||
assert.equal(fallback.value, `\n${NOTE_MARKDOWN_MDX_CANNOT_PARSE}\n`);
|
||||
await unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a stale parse error is ignored when the same note has newer markdown", async () => {
|
||||
const { shouldApplyMdxParseFailure } = await import("./InlineMarkdownEditor.tsx");
|
||||
|
||||
assert.equal(shouldApplyMdxParseFailure({
|
||||
currentNoteId: "note-1",
|
||||
failedNoteId: "note-1",
|
||||
currentMarkdown: PLAIN_NOTE_MARKDOWN,
|
||||
failedMarkdown: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
|
||||
}), false);
|
||||
assert.equal(shouldApplyMdxParseFailure({
|
||||
currentNoteId: "note-1",
|
||||
failedNoteId: "note-1",
|
||||
currentMarkdown: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
|
||||
failedMarkdown: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
|
||||
}), true);
|
||||
assert.equal(shouldApplyMdxParseFailure({
|
||||
currentNoteId: "note-1",
|
||||
failedNoteId: "note-1",
|
||||
currentMarkdown: `\n${NOTE_MARKDOWN_MDX_CANNOT_PARSE}\n`,
|
||||
failedMarkdown: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
|
||||
}), true);
|
||||
});
|
||||
@@ -70,10 +70,12 @@ export interface NoteSourceEditorProps {
|
||||
className?: string;
|
||||
noteFontFamily?: string;
|
||||
noteFontSize?: number;
|
||||
/** Blocks edits (used when the rich editor cannot render the markdown). */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSourceEditorProps>(
|
||||
({ noteId, value, placeholder = "", onChange, className = "", noteFontFamily, noteFontSize }, ref) => {
|
||||
({ noteId, value, placeholder = "", onChange, className = "", noteFontFamily, noteFontSize, readOnly = false }, ref) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const lineNumbersRef = useRef<HTMLDivElement>(null);
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
@@ -147,6 +149,7 @@ export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSou
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (readOnly) return;
|
||||
const nextValue = e.target.value;
|
||||
if (nextValue === localValue) return;
|
||||
const nativeInputType = (e.nativeEvent as InputEvent | undefined)?.inputType;
|
||||
@@ -227,6 +230,10 @@ export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSou
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
// Read-only fallback (preview mode): the DOM readOnly attribute blocks
|
||||
// typing but not our custom Tab/undo handling, which would still mutate
|
||||
// and persist the note.
|
||||
if (readOnly) return;
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
const commandModifier = e.metaKey || e.ctrlKey;
|
||||
@@ -269,7 +276,7 @@ export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSou
|
||||
useImperativeHandle(ref, () => ({
|
||||
insertAction: (action: MarkdownActionType) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
if (!textarea || readOnly) return;
|
||||
|
||||
if (action === "undo" || action === "redo") {
|
||||
applyHistoryAction(action);
|
||||
@@ -359,6 +366,8 @@ export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSou
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
readOnly={readOnly || undefined}
|
||||
aria-readonly={readOnly || undefined}
|
||||
style={{
|
||||
fontFamily: noteFontFamily || undefined,
|
||||
fontSize: noteFontSize ? `${noteFontSize}px` : undefined,
|
||||
|
||||
@@ -954,3 +954,64 @@ test("alternate-screen history preview stays selectable and copyable", async ()
|
||||
assert.match(source, /HISTORY_PREVIEW_HIDE_EVENT/);
|
||||
assert.match(source, /document\.addEventListener\("copy", handlePreviewNativeCopy, true\)/);
|
||||
});
|
||||
|
||||
test("alternate-screen history preview falls back to the captured session output", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
|
||||
// The fallback only applies while the app owns the alternate buffer and the
|
||||
// normal buffer has no scrollback above the viewport (#2516).
|
||||
assert.match(source, /bufferHasPreviewScrollback\(normalBuffer\)/);
|
||||
assert.match(source, /outputHistory\.getPreviewRowCount\(term\.cols\)/);
|
||||
assert.match(source, /outputHistory\.getPreviewRows\(\{/);
|
||||
assert.match(source, /nextOutputHistoryPreviewTop\(/);
|
||||
});
|
||||
|
||||
test("multi-character plain text goes out as per-character writes (#3077)", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
|
||||
// Strict bastion prompts (QAX) treat one channel write as one keystroke and
|
||||
// drop multi-character chunks, so IME commits and short raw pastes must be
|
||||
// sent one character per write instead of one write per commit.
|
||||
assert.match(source, /perCharacterWrites\?: boolean/);
|
||||
assert.match(
|
||||
source,
|
||||
/getTextInputWireChunks\(outData, options\?\.perCharacterWrites === true\)/,
|
||||
);
|
||||
// Every chunk carries the same write options as the original single write.
|
||||
const writeLoopIdx = source.indexOf("for (const chunk of getTextInputWireChunks(outData");
|
||||
assert.ok(writeLoopIdx >= 0);
|
||||
const writeLoop = source.slice(writeLoopIdx, writeLoopIdx + 320);
|
||||
assert.match(writeLoop, /for \(const chunk of/);
|
||||
assert.match(writeLoop, /writeToSession\(id, chunk, \{ sensitive \}\)/);
|
||||
|
||||
// IME commits split the committed glyph, not the Kitty CSI-u encoding.
|
||||
assert.match(
|
||||
source,
|
||||
/handleTerminalInputData\(text, \{ perCharacterWrites: shouldSplitImeTextInputForWire\(text\) \}\)/,
|
||||
);
|
||||
// Raw onData (unbracketed paste / committed composition) uses the capped
|
||||
// raw-paste rule; the composition fallback uses the uncapped IME rule.
|
||||
assert.match(
|
||||
source,
|
||||
/handleTerminalInputData\(sanitizedRawData, \{\s*perCharacterWrites: shouldSplitRawPasteInputForWire\(sanitizedRawData\),?\s*\}\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/handleTerminalInputData\(sanitizedData, \{\s*perCharacterWrites: shouldSplitImeTextInputForWire\(sanitizedData\),?\s*\}\)/,
|
||||
);
|
||||
// Negotiated Kitty paths keep their single write: CSI-u associated text and
|
||||
// forwarded key sequences must never be split across writes.
|
||||
const compositionIdx = source.indexOf(
|
||||
"handleTerminalInputData(encoded, { source: \"kitty\" })",
|
||||
);
|
||||
assert.ok(compositionIdx >= 0);
|
||||
assert.doesNotMatch(source.slice(0, compositionIdx + 60), /handleTerminalInputData\(encoded, \{ perCharacterWrites/);
|
||||
assert.doesNotMatch(source, /handleTerminalInputData\(sequence, \{ perCharacterWrites/);
|
||||
// Bookkeeping sees the original string exactly once: the command buffer and
|
||||
// broadcast keep using the unsplit payload.
|
||||
const writeSite = source.slice(writeLoopIdx - 600, writeLoopIdx + 320);
|
||||
assert.match(writeSite, /ctx\.onOutputTriggerUserInputRef\?\.current\?\.\(outData\)/);
|
||||
assert.match(source, /onBroadcastInput\?\.\(broadcastData, ctx\.sessionId\)/);
|
||||
});
|
||||
@@ -110,9 +110,19 @@ import {
|
||||
shouldBlockKeyPressForImeTextInput,
|
||||
shouldCommitDeferredImeTextInput,
|
||||
shouldDeferKeyDownForImeTextInput,
|
||||
resolveDeferredKeyupRelease,
|
||||
shouldDiscardStaleDeferredImeTextInput,
|
||||
shouldFlushDeferredImeTextInputOnKeyUp,
|
||||
shouldFlushStaleDeferredImeTextInput,
|
||||
} from "./terminalImeTextInput";
|
||||
import { formatSerialLocalEcho } from "./serialLocalEcho";
|
||||
import { mapTerminalBackspaceInput } from "./terminalBackspaceInput";
|
||||
import {
|
||||
getTextInputWireChunks,
|
||||
shouldSplitImeTextInputForWire,
|
||||
shouldSplitRawPasteInputForWire,
|
||||
} from "./terminalPerCharacterInput";
|
||||
import { sanitizeTerminalInput } from "./terminalInputSanitize";
|
||||
import { formatTelnetLocalEcho } from "./telnetLocalEcho";
|
||||
import {
|
||||
isTerminalFontSizeAction,
|
||||
@@ -125,7 +135,9 @@ import {
|
||||
HISTORY_PREVIEW_HIDE_EVENT,
|
||||
HISTORY_PREVIEW_OVERLAY_ATTR,
|
||||
HISTORY_PREVIEW_WRAP_ATTR,
|
||||
bufferHasPreviewScrollback,
|
||||
encodeHistoryPreviewWrapFlags,
|
||||
type HistoryPreviewRow,
|
||||
getHistoryPreviewRows,
|
||||
getHistoryPreviewSelectionFromRoot,
|
||||
forcedHistoryScrollLinesForWheel,
|
||||
@@ -139,6 +151,10 @@ import {
|
||||
shouldHideHistoryPreviewOnMouseDown,
|
||||
shouldKeepHistoryPreviewOnKey,
|
||||
} from "./terminalHistoryScrollOverride";
|
||||
import {
|
||||
nextOutputHistoryPreviewTop,
|
||||
type TerminalOutputHistoryPreview,
|
||||
} from "./terminalOutputHistory";
|
||||
import { shouldPassThroughCopyShortcut } from "./terminalCopyShortcut";
|
||||
import { shouldUseUrgentTerminalInterrupt } from "./terminalInterruptShortcut";
|
||||
import {
|
||||
@@ -349,6 +365,11 @@ export type CreateXTermRuntimeContext = {
|
||||
serialLineBufferRef?: RefObject<string>;
|
||||
telnetLocalEchoRef?: RefObject<boolean>;
|
||||
onTerminalLogData?: (data: string) => void;
|
||||
/**
|
||||
* Captured session output used as the history preview source while an app
|
||||
* owns the alternate buffer and the normal buffer has no scrollback (#2516).
|
||||
*/
|
||||
terminalOutputHistory?: TerminalOutputHistoryPreview;
|
||||
|
||||
// Callback when shell reports CWD change via OSC 7
|
||||
onCwdChange?: (cwd: string) => void;
|
||||
@@ -997,20 +1018,43 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const showAlternateScreenHistoryPreview = (lines: number) => {
|
||||
if (term.buffer.active.type !== "alternate") return false;
|
||||
const normalBuffer = term.buffer.normal;
|
||||
historyPreviewTop = nextHistoryPreviewTop({
|
||||
buffer: normalBuffer,
|
||||
currentTop: historyPreviewTop,
|
||||
lines,
|
||||
});
|
||||
// Inside screen/vim/codex the app owns the alternate buffer, so the normal
|
||||
// buffer has no rows above the viewport and there is nothing to preview.
|
||||
// Fall back to the captured session output (#2516). Apps that request mouse
|
||||
// reporting never get here — xterm hands those wheel events to the app.
|
||||
const outputHistory = ctx.terminalOutputHistory;
|
||||
const outputRowCount = outputHistory && !bufferHasPreviewScrollback(normalBuffer)
|
||||
? outputHistory.getPreviewRowCount(term.cols)
|
||||
: 0;
|
||||
let previewRows: HistoryPreviewRow[];
|
||||
if (outputHistory && outputRowCount > 0) {
|
||||
historyPreviewTop = nextOutputHistoryPreviewTop({
|
||||
currentTop: historyPreviewTop,
|
||||
lines,
|
||||
rows: term.rows,
|
||||
totalRows: outputRowCount,
|
||||
});
|
||||
previewRows = outputHistory.getPreviewRows({
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
top: historyPreviewTop,
|
||||
}).rows;
|
||||
} else {
|
||||
historyPreviewTop = nextHistoryPreviewTop({
|
||||
buffer: normalBuffer,
|
||||
currentTop: historyPreviewTop,
|
||||
lines,
|
||||
});
|
||||
previewRows = getHistoryPreviewRows({
|
||||
buffer: normalBuffer,
|
||||
rows: term.rows,
|
||||
top: historyPreviewTop,
|
||||
});
|
||||
}
|
||||
const overlay = ensureHistoryPreviewOverlay();
|
||||
overlay.style.fontSize = `${currentTerminalFontSize()}px`;
|
||||
overlay.style.fontFamily = String(term.options.fontFamily ?? fontFamily);
|
||||
overlay.style.lineHeight = String(term.options.lineHeight ?? lineHeight);
|
||||
const previewRows = getHistoryPreviewRows({
|
||||
buffer: normalBuffer,
|
||||
rows: term.rows,
|
||||
top: historyPreviewTop,
|
||||
});
|
||||
overlay.textContent = previewRows.map((row) => row.text).join("\n");
|
||||
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, encodeHistoryPreviewWrapFlags(previewRows));
|
||||
return true;
|
||||
@@ -1055,8 +1099,23 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
source?: "terminal" | "shift-enter" | "kitty";
|
||||
/** Skip string broadcast when peers will re-resolve from a key chord. */
|
||||
skipBroadcast?: boolean;
|
||||
/**
|
||||
* Send plain text as one write per character. Strict bastion prompts
|
||||
* (QAX) treat one channel write as a keystroke and drop multi-character
|
||||
* chunks, so IME commits and short raw pastes must go out per
|
||||
* character (#3077). Bookkeeping still sees the whole payload once.
|
||||
*/
|
||||
perCharacterWrites?: boolean;
|
||||
},
|
||||
) => {
|
||||
// Strip zero-width / invisible formatting characters that CJK IMEs
|
||||
// (notably Microsoft Pinyin / Sogou on Windows) emit when switching
|
||||
// composition modes. With the 15-graphemes Unicode version these render
|
||||
// at width 0, so they become hidden characters in the command line and
|
||||
// cause the executed command to fail (#3138).
|
||||
data = sanitizeTerminalInput(data);
|
||||
if (!data) return;
|
||||
|
||||
// Clipboard paste / typed password while assist is open must dismiss the
|
||||
// hint first. Otherwise Enter is still hijacked for confirmFill and can
|
||||
// append the host session password after the user's pasted secret (#2198).
|
||||
@@ -1170,7 +1229,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
// When backspaceBehavior is configured, remap the Backspace key output
|
||||
const outData = mapTerminalBackspaceInput(dataToWrite, ctx.host.backspaceBehavior);
|
||||
ctx.onOutputTriggerUserInputRef?.current?.(outData);
|
||||
ctx.terminalBackend.writeToSession(id, outData, { sensitive });
|
||||
for (const chunk of getTextInputWireChunks(outData, options?.perCharacterWrites === true)) {
|
||||
ctx.terminalBackend.writeToSession(id, chunk, { sensitive });
|
||||
}
|
||||
|
||||
// Local echo for serial connections only when explicitly enabled
|
||||
if (inputSource !== "kitty" && ctx.host.protocol === "serial" && ctx.serialLocalEcho) {
|
||||
@@ -1334,7 +1395,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
|
||||
suppressNextTerminalDataBroadcast = true;
|
||||
}
|
||||
handleTerminalInputData(text);
|
||||
handleTerminalInputData(text, { perCharacterWrites: shouldSplitImeTextInputForWire(text) });
|
||||
if (deferredKittyEvent) {
|
||||
const pressEvent: KittyKeyboardEvent = {
|
||||
...deferredKittyEvent,
|
||||
@@ -1376,16 +1437,22 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
|
||||
// Actual IME remap — Kitty associated-text composition when negotiated;
|
||||
// otherwise send the committed glyph (report-all alone cannot carry it).
|
||||
const encoded = encodeKittyCompositionText(kittyKeyboardMode, text);
|
||||
// Sanitize before encoding so zero-width IME artifacts do not become
|
||||
// Kitty escape sequences that bypass the handleTerminalInputData guard (#3138).
|
||||
const sanitizedText = sanitizeTerminalInput(text);
|
||||
if (!sanitizedText) return;
|
||||
const encoded = encodeKittyCompositionText(kittyKeyboardMode, sanitizedText);
|
||||
if (encoded) {
|
||||
handleTerminalInputData(encoded, { source: "kitty" });
|
||||
} else {
|
||||
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
|
||||
suppressNextTerminalDataBroadcast = true;
|
||||
}
|
||||
handleTerminalInputData(text);
|
||||
handleTerminalInputData(sanitizedText, {
|
||||
perCharacterWrites: shouldSplitImeTextInputForWire(sanitizedText),
|
||||
});
|
||||
}
|
||||
broadcastKittyInput({ kind: "text", text });
|
||||
broadcastKittyInput({ kind: "text", text: sanitizedText });
|
||||
};
|
||||
const flushImeTextInputDeferral = () => {
|
||||
const fallback = imeTextInputDeferredKey;
|
||||
@@ -1402,6 +1469,35 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
// broadcast targets can still receive paired key events.
|
||||
imeTextInputDeferredKittyEvent = toKittyKeyboardEvent(event);
|
||||
};
|
||||
/**
|
||||
* Emit the paired release for a forwarded press, locally and to broadcast
|
||||
* peers. Used by the keyup handler and by the stale-deferral recovery, where
|
||||
* the IME dropped the release and one must be synthesized from the deferred
|
||||
* physical key so the TUI does not see the key held until focus loss.
|
||||
*/
|
||||
const releaseForwardedKittyPress = (
|
||||
event: Pick<KittyKeyboardEvent, "code" | "key"> & KittyKeyboardEvent,
|
||||
): boolean => {
|
||||
const identity = event.code || event.key;
|
||||
const forwardedPress = broadcastForwardedKeys.get(identity);
|
||||
if (forwardedPress) {
|
||||
broadcastForwardedKeys.delete(identity);
|
||||
broadcastKittyInput(
|
||||
{ kind: "key", event },
|
||||
true,
|
||||
forwardedPress.targetSessionIds,
|
||||
);
|
||||
}
|
||||
if (!kittyForwardedKeys.delete(identity)) return false;
|
||||
const sequence = kittyKeyboardProtocolEnabled
|
||||
? encodeKittyKeyEvent(kittyKeyboardMode, event)
|
||||
: null;
|
||||
if (sequence) {
|
||||
handleTerminalInputData(sequence, { source: "kitty" });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
term.attachCustomKeyEventHandler((e: KeyboardEvent) => {
|
||||
// Preserve mouse selection across keystrokes when enabled. xterm.js
|
||||
@@ -1448,38 +1544,57 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
|
||||
if (e.type === "keyup") {
|
||||
// insertText for this keystroke has already run when present; flush the
|
||||
// deferred ASCII key only when the IME did not remap it.
|
||||
if (imeTextInputDeferredKey !== null && e.key === imeTextInputDeferredKey) {
|
||||
flushImeTextInputDeferral();
|
||||
}
|
||||
const identity = kittyKeyIdentity(e);
|
||||
if (broadcastLegacyDataPending === identity) clearBroadcastLegacyDataPending();
|
||||
const forwardedPress = broadcastForwardedKeys.get(identity);
|
||||
if (forwardedPress) {
|
||||
broadcastForwardedKeys.delete(identity);
|
||||
broadcastKittyInput(
|
||||
{ kind: "key", event: toKittyKeyboardEvent(e) },
|
||||
true,
|
||||
forwardedPress.targetSessionIds,
|
||||
// deferred ASCII key when the IME did not remap it. Any real key release
|
||||
// ends the deferral: Windows IMEs report Process/229 as the release of a
|
||||
// key they consumed (or drop the keyup), and an exact key match left the
|
||||
// deferral armed so every later keypress was swallowed (#3103).
|
||||
let releaseEvent: KeyboardEvent = e;
|
||||
if (
|
||||
imeTextInputDeferredKey !== null &&
|
||||
shouldFlushDeferredImeTextInputOnKeyUp(imeTextInputDeferredKey, e)
|
||||
) {
|
||||
const deferredKittyEvent = imeTextInputDeferredKittyEvent;
|
||||
const releaseMode = resolveDeferredKeyupRelease(
|
||||
imeTextInputDeferredKey,
|
||||
deferredKittyEvent?.code ?? null,
|
||||
e,
|
||||
);
|
||||
flushImeTextInputDeferral();
|
||||
if (deferredKittyEvent && releaseMode === "deferred") {
|
||||
// The release reported an IME sentinel (Process/229); pair the
|
||||
// flushed press from the deferred physical key so the kitty
|
||||
// sequence keeps its key identity.
|
||||
releaseEvent = {
|
||||
...deferredKittyEvent,
|
||||
type: "keyup",
|
||||
} as unknown as KeyboardEvent;
|
||||
} else if (deferredKittyEvent && releaseMode === "unrelated") {
|
||||
// Another held key was released first: this keyup only ends the
|
||||
// stale deferral, so the flushed press needs its own synthesized
|
||||
// release while the real keyup keeps its identity.
|
||||
releaseForwardedKittyPress({ ...deferredKittyEvent, type: "keyup" });
|
||||
}
|
||||
}
|
||||
if (!kittyForwardedKeys.delete(identity)) return true;
|
||||
const kittyEvent = toKittyKeyboardEvent(e);
|
||||
const sequence = kittyKeyboardProtocolEnabled
|
||||
? encodeKittyKeyEvent(kittyKeyboardMode, kittyEvent)
|
||||
: null;
|
||||
if (sequence) {
|
||||
const identity = kittyKeyIdentity(releaseEvent);
|
||||
if (broadcastLegacyDataPending === identity) clearBroadcastLegacyDataPending();
|
||||
if (releaseForwardedKittyPress(toKittyKeyboardEvent(releaseEvent))) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleTerminalInputData(sequence, { source: "kitty" });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Block keypress so xterm cannot re-emit the half-width ASCII char after we
|
||||
// deferred the matching keydown for IME insertText (#2833).
|
||||
if (shouldBlockKeyPressForImeTextInput(imeTextInputDeferredKey, e)) {
|
||||
// deferred the matching keydown for IME insertText (#2833). Scoped to the
|
||||
// deferred key so a stale deferral cannot swallow unrelated keystrokes.
|
||||
if (
|
||||
shouldBlockKeyPressForImeTextInput(
|
||||
imeTextInputDeferredKey,
|
||||
e,
|
||||
imeTextInputDeferredKittyEvent?.keyCode ?? null,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1489,6 +1604,29 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
|
||||
if (handlingKittyBroadcast) return true;
|
||||
|
||||
// A deferred punctuation keystroke that outlived its own release means the
|
||||
// IME swallowed the keyup (Windows reports Process/229). Flush it before
|
||||
// the next keystroke so the pending ASCII key still reaches the PTY
|
||||
// instead of wedging the deferral (#3103). A modified keydown is a command
|
||||
// rather than a continuation, so the lost keystroke is dropped there
|
||||
// instead of being injected in front of the interrupt or shortcut.
|
||||
if (imeTextInputDeferredKey !== null) {
|
||||
if (shouldFlushStaleDeferredImeTextInput(imeTextInputDeferredKey, e)) {
|
||||
const deferredKittyEvent = imeTextInputDeferredKittyEvent;
|
||||
flushImeTextInputDeferral();
|
||||
if (deferredKittyEvent) {
|
||||
// The flush emitted the deferred press, but the release it waits for
|
||||
// is the one the IME dropped — synthesize it so the TUI does not see
|
||||
// the key held until focus loss.
|
||||
releaseForwardedKittyPress({ ...deferredKittyEvent, type: "keyup" });
|
||||
}
|
||||
} else if (
|
||||
shouldDiscardStaleDeferredImeTextInput(imeTextInputDeferredKey, e)
|
||||
) {
|
||||
clearImeTextInputDeferral();
|
||||
}
|
||||
}
|
||||
|
||||
if (e.keyCode === 229) {
|
||||
markKittyCompositionPending(true);
|
||||
}
|
||||
@@ -2129,16 +2267,23 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
window.clearTimeout(kittyCompositionClearTimer);
|
||||
kittyCompositionClearTimer = undefined;
|
||||
}
|
||||
const encoded = encodeKittyCompositionText(kittyKeyboardMode, data);
|
||||
// Sanitize before encoding so zero-width IME artifacts do not become
|
||||
// Kitty escape sequences that bypass the handleTerminalInputData guard (#3138).
|
||||
const sanitizedData = sanitizeTerminalInput(data);
|
||||
if (!sanitizedData) return;
|
||||
const encoded = encodeKittyCompositionText(kittyKeyboardMode, sanitizedData);
|
||||
if (encoded) {
|
||||
handleTerminalInputData(encoded, { source: "kitty" });
|
||||
} else {
|
||||
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
|
||||
suppressNextTerminalDataBroadcast = true;
|
||||
}
|
||||
handleTerminalInputData(data);
|
||||
// Committed composition text, not a negotiated Kitty sequence.
|
||||
handleTerminalInputData(sanitizedData, {
|
||||
perCharacterWrites: shouldSplitImeTextInputForWire(sanitizedData),
|
||||
});
|
||||
}
|
||||
broadcastKittyInput({ kind: "text", text: data });
|
||||
broadcastKittyInput({ kind: "text", text: sanitizedData });
|
||||
return;
|
||||
}
|
||||
if (broadcastLegacyDataPending) {
|
||||
@@ -2154,7 +2299,14 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
});
|
||||
broadcastLegacyDataPending = null;
|
||||
}
|
||||
handleTerminalInputData(data);
|
||||
// Raw multi-character onData is an unbracketed paste (or a committed
|
||||
// composition): short plain text must reach strict bastions per character.
|
||||
// Decide on the sanitized payload so removed zero-width characters cannot
|
||||
// keep a qualifying paste above the split cap (#3138).
|
||||
const sanitizedRawData = sanitizeTerminalInput(data);
|
||||
handleTerminalInputData(sanitizedRawData, {
|
||||
perCharacterWrites: shouldSplitRawPasteInputForWire(sanitizedRawData),
|
||||
});
|
||||
});
|
||||
|
||||
const handleKittyKeyboardBroadcast = createKittyKeyboardBroadcastHandler({
|
||||
|
||||
@@ -146,7 +146,7 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/const handleTerminalInputData = \(\s+data: string,\s+options\?: \{\s+source\?: "terminal" \| "shift-enter" \| "kitty";\s+[\s\S]*?skipBroadcast\?: boolean;\s+\},\s+\) => \{/s,
|
||||
/const handleTerminalInputData = \(\s+data: string,\s+options\?: \{\s+source\?: "terminal" \| "shift-enter" \| "kitty";\s+[\s\S]*?skipBroadcast\?: boolean;\s+[\s\S]*?perCharacterWrites\?: boolean;\s+\},\s+\) => \{/s,
|
||||
);
|
||||
// Remap when Kitty encoding does not preserve Shift+Enter (not merely flags===0).
|
||||
assert.match(
|
||||
@@ -173,11 +173,11 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/term\.onData\(\(data\) => \{[\s\S]*handleTerminalInputData\(data\);\s+\}\);/,
|
||||
/term\.onData\(\(data\) => \{[\s\S]*const sanitizedRawData = sanitizeTerminalInput\(data\);[\s\S]*handleTerminalInputData\(sanitizedRawData, \{\s*perCharacterWrites: shouldSplitRawPasteInputForWire\(sanitizedRawData\),?\s*\}\);\s+\}\);/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const encoded = encodeKittyCompositionText\(kittyKeyboardMode, data\);[\s\S]*if \(encoded\) \{[\s\S]*handleTerminalInputData\(encoded, \{ source: "kitty" \}\);[\s\S]*\} else \{[\s\S]*handleTerminalInputData\(data\);[\s\S]*broadcastKittyInput\(\{ kind: "text", text: data \}\);/,
|
||||
/const sanitizedData = sanitizeTerminalInput\(data\);[\s\S]*const encoded = encodeKittyCompositionText\(kittyKeyboardMode, sanitizedData\);[\s\S]*if \(encoded\) \{[\s\S]*handleTerminalInputData\(encoded, \{ source: "kitty" \}\);[\s\S]*\} else \{[\s\S]*handleTerminalInputData\(sanitizedData, \{\s*perCharacterWrites: shouldSplitImeTextInputForWire\(sanitizedData\),?\s*\}\);[\s\S]*broadcastKittyInput\(\{ kind: "text", text: sanitizedData \}\);/,
|
||||
);
|
||||
assert.match(source, /ctx\.container\.addEventListener\("input", markKittyTextInput, true\);/);
|
||||
assert.match(
|
||||
|
||||
@@ -4,6 +4,7 @@ import test from "node:test";
|
||||
import {
|
||||
HISTORY_PREVIEW_OVERLAY_ATTR,
|
||||
HISTORY_PREVIEW_WRAP_ATTR,
|
||||
bufferHasPreviewScrollback,
|
||||
encodeHistoryPreviewWrapFlags,
|
||||
getHistoryPreviewLines,
|
||||
getHistoryPreviewRows,
|
||||
@@ -129,6 +130,12 @@ test("alternate-screen history preview reads normal-buffer history", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("only a normal buffer with rows above the viewport offers preview scrollback", () => {
|
||||
assert.equal(bufferHasPreviewScrollback({ baseY: 12 }), true);
|
||||
// Alternate-screen hosts leave the normal buffer at a single viewport.
|
||||
assert.equal(bufferHasPreviewScrollback({ baseY: 0 }), false);
|
||||
});
|
||||
|
||||
test("history preview stays up for pointer selection and copy chords", () => {
|
||||
const overlay = { contains: (node: unknown) => node === "inside" };
|
||||
|
||||
|
||||
@@ -69,6 +69,14 @@ export const clampHistoryPreviewTop = (top: number, buffer: Pick<BufferLike, "ba
|
||||
return Math.max(0, Math.min(maxTop, top));
|
||||
};
|
||||
|
||||
/**
|
||||
* True when the buffer still has rows above the viewport, i.e. something to
|
||||
* preview. In the alternate screen there is none (screen/vim/codex own that
|
||||
* buffer), so the preview falls back to the captured session output.
|
||||
*/
|
||||
export const bufferHasPreviewScrollback = (buffer: Pick<BufferLike, "baseY">): boolean =>
|
||||
buffer.baseY > 0;
|
||||
|
||||
export const nextHistoryPreviewTop = ({
|
||||
buffer,
|
||||
currentTop,
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
shouldBlockKeyPressForImeTextInput,
|
||||
shouldCommitDeferredImeTextInput,
|
||||
shouldDeferKeyDownForImeTextInput,
|
||||
resolveDeferredKeyupRelease,
|
||||
shouldDiscardStaleDeferredImeTextInput,
|
||||
shouldFlushDeferredImeTextInputOnKeyUp,
|
||||
shouldFlushStaleDeferredImeTextInput,
|
||||
} from "./terminalImeTextInput";
|
||||
|
||||
const runtimeSource = readFileSync(
|
||||
@@ -63,9 +67,305 @@ test("shouldDeferKeyDownForImeTextInput leaves composition and modified keys alo
|
||||
});
|
||||
|
||||
test("shouldBlockKeyPressForImeTextInput only while a deferral is armed", () => {
|
||||
assert.equal(shouldBlockKeyPressForImeTextInput(",", { type: "keypress" }), true);
|
||||
assert.equal(shouldBlockKeyPressForImeTextInput(null, { type: "keypress" }), false);
|
||||
assert.equal(shouldBlockKeyPressForImeTextInput(",", { type: "keydown" }), false);
|
||||
assert.equal(
|
||||
shouldBlockKeyPressForImeTextInput(",", { type: "keypress", key: "," }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBlockKeyPressForImeTextInput(null, { type: "keypress", key: "," }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBlockKeyPressForImeTextInput(",", { type: "keydown", key: "," }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldBlockKeyPressForImeTextInput still blocks the deferred keystroke itself", () => {
|
||||
assert.equal(
|
||||
shouldBlockKeyPressForImeTextInput(
|
||||
"/",
|
||||
{ type: "keypress", key: "/", keyCode: 191 },
|
||||
191,
|
||||
),
|
||||
true,
|
||||
);
|
||||
// Shift+/ reports "?" on keydown, so the deferral arms with "?".
|
||||
assert.equal(
|
||||
shouldBlockKeyPressForImeTextInput(
|
||||
"?",
|
||||
{ type: "keypress", key: "?", keyCode: 191 },
|
||||
191,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBlockKeyPressForImeTextInput(
|
||||
"/",
|
||||
{ type: "keypress", key: "/", isComposing: true },
|
||||
191,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("a stale deferral must not swallow unrelated keypresses (#3103)", () => {
|
||||
// Uppercase letters are routed through keypress by xterm, so a blanket
|
||||
// keypress block turned one stale deferral into a terminal that ignored
|
||||
// everything typed afterwards.
|
||||
assert.equal(
|
||||
shouldBlockKeyPressForImeTextInput(
|
||||
"/",
|
||||
{ type: "keypress", key: "X", keyCode: 88 },
|
||||
191,
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBlockKeyPressForImeTextInput(
|
||||
"/",
|
||||
{ type: "keypress", key: "a", keyCode: 65 },
|
||||
191,
|
||||
),
|
||||
false,
|
||||
);
|
||||
// A matching keyCode keeps the block when an IME rewrites the key label.
|
||||
assert.equal(
|
||||
shouldBlockKeyPressForImeTextInput(
|
||||
"/",
|
||||
{ type: "keypress", key: "1", keyCode: 191 },
|
||||
191,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("a Windows IME release reporting Process must flush the deferred slash (#3103)", () => {
|
||||
// Windows + CJK IME in vi: keydown still reports the physical key while the
|
||||
// IME consumes it, then reports Process/229 on the release and never sends
|
||||
// an insertText the runtime would treat as a remap.
|
||||
let deferredKey: string | null = null;
|
||||
const keydown = { type: "keydown", key: "/", keyCode: 191 };
|
||||
if (shouldDeferKeyDownForImeTextInput(keydown)) deferredKey = keydown.key;
|
||||
assert.equal(deferredKey, "/");
|
||||
|
||||
const release = { type: "keyup", key: "Process", keyCode: 229 };
|
||||
if (shouldFlushDeferredImeTextInputOnKeyUp(deferredKey, release)) deferredKey = null;
|
||||
assert.equal(deferredKey, null, "the deferral must not outlive its keystroke");
|
||||
|
||||
// Input keeps flowing afterwards.
|
||||
assert.equal(
|
||||
shouldBlockKeyPressForImeTextInput(deferredKey, { type: "keypress", key: "x", keyCode: 88 }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("a held key released while the deferral is armed keeps its own keyup identity", () => {
|
||||
// "/" is deferred (its own release was swallowed) and the user still holds
|
||||
// "a" with the other hand, so the "a" keyup lands first. The stale deferral
|
||||
// still ends, but that release is a real keyup, not an IME sentinel: it
|
||||
// keeps its own identity, and the deferred press is released separately.
|
||||
const deferredKey = "/";
|
||||
const heldKeyUp = { type: "keyup", key: "a", keyCode: 65, code: "KeyA" };
|
||||
assert.equal(
|
||||
shouldFlushDeferredImeTextInputOnKeyUp(deferredKey, heldKeyUp),
|
||||
true,
|
||||
"the stale deferral still ends on a real release",
|
||||
);
|
||||
assert.equal(
|
||||
resolveDeferredKeyupRelease(deferredKey, "Slash", heldKeyUp),
|
||||
"unrelated",
|
||||
"an unrelated release must not be rewritten to the deferred key",
|
||||
);
|
||||
});
|
||||
|
||||
test("only IME sentinel releases take over the deferred key's release identity", () => {
|
||||
// Windows IMEs report Process/229 (occasionally Unidentified) as the release
|
||||
// of a key they consumed — those are the only releases that stand in for the
|
||||
// deferred punctuation key.
|
||||
assert.equal(
|
||||
resolveDeferredKeyupRelease("/", "Slash", { type: "keyup", key: "Process", keyCode: 229 }),
|
||||
"deferred",
|
||||
);
|
||||
assert.equal(
|
||||
resolveDeferredKeyupRelease("/", "Slash", { type: "keyup", key: "Unidentified" }),
|
||||
"deferred",
|
||||
);
|
||||
assert.equal(
|
||||
resolveDeferredKeyupRelease("/", "Slash", { type: "keyup", key: "/", keyCode: 191 }),
|
||||
"own",
|
||||
"a matched release already encodes from the real event",
|
||||
);
|
||||
assert.equal(
|
||||
resolveDeferredKeyupRelease("/", "Slash", {
|
||||
type: "keyup",
|
||||
key: "1",
|
||||
keyCode: 191,
|
||||
code: "Slash",
|
||||
}),
|
||||
"own",
|
||||
"a release of the same physical key already pairs the flushed press",
|
||||
);
|
||||
assert.equal(
|
||||
resolveDeferredKeyupRelease("/", "Slash", { type: "keyup", key: "Shift", keyCode: 16 }),
|
||||
"own",
|
||||
);
|
||||
assert.equal(
|
||||
resolveDeferredKeyupRelease("/", "Slash", {
|
||||
type: "keyup",
|
||||
key: "a",
|
||||
keyCode: 65,
|
||||
code: "KeyA",
|
||||
ctrlKey: true,
|
||||
}),
|
||||
"own",
|
||||
);
|
||||
assert.equal(
|
||||
resolveDeferredKeyupRelease(null, null, { type: "keyup", key: "Process", keyCode: 229 }),
|
||||
"own",
|
||||
);
|
||||
});
|
||||
|
||||
test("a deferral left without release or insertText recovers on the next keystroke (#3103)", () => {
|
||||
let deferredKey: string | null = null;
|
||||
const keydown = { type: "keydown", key: "/", keyCode: 191 };
|
||||
if (shouldDeferKeyDownForImeTextInput(keydown)) deferredKey = keydown.key;
|
||||
assert.equal(deferredKey, "/");
|
||||
|
||||
// No insertText and no keyup at all; the user then types a plain letter.
|
||||
const nextKeyDown = { type: "keydown", key: "a", keyCode: 65 };
|
||||
if (shouldFlushStaleDeferredImeTextInput(deferredKey, nextKeyDown)) deferredKey = null;
|
||||
assert.equal(deferredKey, null, "the stale deferral must flush before the new keystroke");
|
||||
assert.equal(
|
||||
shouldBlockKeyPressForImeTextInput(deferredKey, { type: "keypress", key: "a", keyCode: 65 }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("a modified keydown discards the stale deferral instead of flushing it", () => {
|
||||
// The IME swallowed the "/" release and the user then interrupts with
|
||||
// Ctrl+C. Flushing there would inject "/" in front of the interrupt, and
|
||||
// keeping the deferral armed would inject it before the next character, so
|
||||
// the lost keystroke is dropped instead.
|
||||
assert.equal(
|
||||
shouldFlushStaleDeferredImeTextInput("/", {
|
||||
type: "keydown",
|
||||
key: "c",
|
||||
keyCode: 67,
|
||||
ctrlKey: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldDiscardStaleDeferredImeTextInput("/", {
|
||||
type: "keydown",
|
||||
key: "c",
|
||||
keyCode: 67,
|
||||
ctrlKey: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldDiscardStaleDeferredImeTextInput("/", {
|
||||
type: "keydown",
|
||||
key: "Tab",
|
||||
keyCode: 9,
|
||||
altKey: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldDiscardStaleDeferredImeTextInput("/", {
|
||||
type: "keydown",
|
||||
key: "d",
|
||||
keyCode: 229,
|
||||
ctrlKey: true,
|
||||
isComposing: true,
|
||||
}),
|
||||
false,
|
||||
"a composition still owns the keystroke and resolves via insertText",
|
||||
);
|
||||
assert.equal(
|
||||
shouldDiscardStaleDeferredImeTextInput("/", { type: "keydown", key: "a", keyCode: 65 }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldDiscardStaleDeferredImeTextInput(null, {
|
||||
type: "keydown",
|
||||
key: "c",
|
||||
keyCode: 67,
|
||||
ctrlKey: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("auto-repeat and modifier keystrokes keep the deferral armed", () => {
|
||||
// Same-key keydown is auto-repeat: re-arm instead of flushing, so a held key
|
||||
// does not emit an extra character per repeat.
|
||||
assert.equal(
|
||||
shouldFlushStaleDeferredImeTextInput("/", { type: "keydown", key: "/", keyCode: 191 }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldFlushStaleDeferredImeTextInput("/", { type: "keydown", key: "Shift", keyCode: 16 }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldFlushStaleDeferredImeTextInput("/", {
|
||||
type: "keydown",
|
||||
key: "d",
|
||||
keyCode: 229,
|
||||
isComposing: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldFlushDeferredImeTextInputOnKeyUp("/", { type: "keyup", key: "Shift", keyCode: 16 }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldFlushDeferredImeTextInputOnKeyUp("/", {
|
||||
type: "keyup",
|
||||
key: "c",
|
||||
keyCode: 67,
|
||||
ctrlKey: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("an active composition still resolves the deferred keystroke via insertText", () => {
|
||||
// The IME absorbed the punctuation into a composition: the composing release
|
||||
// must not flush the ASCII key ahead of the committed glyph.
|
||||
assert.equal(
|
||||
shouldFlushDeferredImeTextInputOnKeyUp("/", {
|
||||
type: "keyup",
|
||||
key: "/",
|
||||
keyCode: 191,
|
||||
isComposing: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCommitDeferredImeTextInput("/", { inputType: "insertText", data: "、" }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("a matched keyup still flushes the English punctuation fallback (#2833)", () => {
|
||||
assert.equal(
|
||||
shouldFlushDeferredImeTextInputOnKeyUp("/", { type: "keyup", key: "/", keyCode: 191 }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldFlushDeferredImeTextInputOnKeyUp(null, { type: "keyup", key: "/", keyCode: 191 }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldFlushDeferredImeTextInputOnKeyUp("/", { type: "keydown", key: "/", keyCode: 191 }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldCommitDeferredImeTextInput accepts insertText payloads while deferred", () => {
|
||||
@@ -100,7 +400,10 @@ test("isUnchangedDeferredImeTextInput detects English punctuation flush", () =>
|
||||
test("createXTermRuntime defers ASCII punctuation keydowns to insertText", () => {
|
||||
assert.match(runtimeSource, /shouldDeferKeyDownForImeTextInput\(e\)/);
|
||||
assert.match(runtimeSource, /armImeTextInputDeferral\(e\)/);
|
||||
assert.match(runtimeSource, /shouldBlockKeyPressForImeTextInput\(imeTextInputDeferredKey, e\)/);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/shouldBlockKeyPressForImeTextInput\(\s*imeTextInputDeferredKey,\s*e,\s*imeTextInputDeferredKittyEvent\?\.keyCode \?\? null,\s*\)/,
|
||||
);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/shouldCommitDeferredImeTextInput\(imeTextInputDeferredKey, event\)/,
|
||||
@@ -132,7 +435,7 @@ test("createXTermRuntime defers ASCII punctuation keydowns to insertText", () =>
|
||||
// Unchanged ASCII must encode via Kitty key events, not composition text.
|
||||
const unchangedIdx = runtimeSource.indexOf("isUnchangedDeferredImeTextInput(deferredKey, text)");
|
||||
const compositionIdx = runtimeSource.indexOf(
|
||||
"encodeKittyCompositionText(kittyKeyboardMode, text)",
|
||||
"encodeKittyCompositionText(kittyKeyboardMode, sanitizedText)",
|
||||
);
|
||||
assert.ok(unchangedIdx >= 0 && compositionIdx > unchangedIdx);
|
||||
// Even when the source writes the literal glyph, broadcast peers still get
|
||||
@@ -155,12 +458,69 @@ test("createXTermRuntime defers ASCII punctuation keydowns to insertText", () =>
|
||||
assert.ok(unchangedFallbackIdx > unchangedIdx);
|
||||
assert.match(
|
||||
runtimeSource.slice(unchangedFallbackIdx, unchangedFallbackIdx + 1800),
|
||||
/handleTerminalInputData\(text\);[\s\S]*shouldTrackKittyKeyRelease\(kittyKeyboardMode, pressEvent\)[\s\S]*upsertKittyKeyboardForwardedPress\(\s*kittyForwardedKeys,[\s\S]*broadcastKittyInput\(\{[\s\S]*kind: "key",[\s\S]*fallbackToLegacy: true,/,
|
||||
/handleTerminalInputData\(text, \{ perCharacterWrites: shouldSplitImeTextInputForWire\(text\) \}\);[\s\S]*shouldTrackKittyKeyRelease\(kittyKeyboardMode, pressEvent\)[\s\S]*upsertKittyKeyboardForwardedPress\(\s*kittyForwardedKeys,[\s\S]*broadcastKittyInput\(\{[\s\S]*kind: "key",[\s\S]*fallbackToLegacy: true,/,
|
||||
);
|
||||
// Remap path must fall back to literal text when composition encoding is null
|
||||
// (report-all without associated text).
|
||||
assert.match(
|
||||
runtimeSource.slice(compositionIdx, compositionIdx + 420),
|
||||
/if \(encoded\) \{[\s\S]*handleTerminalInputData\(encoded, \{ source: "kitty" \}\);[\s\S]*\} else \{[\s\S]*handleTerminalInputData\(text\);/,
|
||||
runtimeSource.slice(compositionIdx, compositionIdx + 560),
|
||||
/if \(encoded\) \{[\s\S]*handleTerminalInputData\(encoded, \{ source: "kitty" \}\);[\s\S]*\} else \{[\s\S]*handleTerminalInputData\(sanitizedText, \{\s*perCharacterWrites: shouldSplitImeTextInputForWire\(sanitizedText\),?\s*\}\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test("createXTermRuntime recovers a stuck IME punctuation deferral (#3103)", () => {
|
||||
// Any real key release ends the deferral, not just an exact key match.
|
||||
const keyupIdx = runtimeSource.indexOf('if (e.type === "keyup")');
|
||||
assert.ok(keyupIdx >= 0);
|
||||
const keyupSlice = runtimeSource.slice(keyupIdx, keyupIdx + 2000);
|
||||
assert.match(
|
||||
keyupSlice,
|
||||
/shouldFlushDeferredImeTextInputOnKeyUp\(imeTextInputDeferredKey, e\)/,
|
||||
);
|
||||
assert.match(keyupSlice, /flushImeTextInputDeferral\(\);/);
|
||||
// Only an IME sentinel release (Process/229/Unidentified) takes over the
|
||||
// deferred key's release identity; an unrelated keyup keeps its own identity
|
||||
// and the flushed press is released separately.
|
||||
assert.match(
|
||||
keyupSlice,
|
||||
/resolveDeferredKeyupRelease\(\s*imeTextInputDeferredKey,\s*deferredKittyEvent\?\.code \?\? null,\s*e,?\s*\)/,
|
||||
);
|
||||
assert.match(keyupSlice, /releaseMode === "deferred"/);
|
||||
assert.match(keyupSlice, /releaseMode === "unrelated"/);
|
||||
assert.match(
|
||||
keyupSlice,
|
||||
/\.\.\.deferredKittyEvent,\s*type: "keyup",/,
|
||||
);
|
||||
const kittyReleaseIdx = keyupSlice.indexOf("toKittyKeyboardEvent(releaseEvent)");
|
||||
assert.ok(kittyReleaseIdx > keyupSlice.indexOf("flushImeTextInputDeferral();"));
|
||||
|
||||
// The stale flush runs on keydown, after the broadcast guard and before the
|
||||
// composition handling, so a wedged deferral cannot survive a new keystroke.
|
||||
const staleIdx = runtimeSource.indexOf(
|
||||
"shouldFlushStaleDeferredImeTextInput(imeTextInputDeferredKey, e)",
|
||||
);
|
||||
assert.ok(staleIdx > keyupIdx);
|
||||
const keydownGuardIdx = runtimeSource.indexOf("if (handlingKittyBroadcast) return true;", keyupIdx);
|
||||
const keyCode229Idx = runtimeSource.indexOf("if (e.keyCode === 229) {", keyupIdx);
|
||||
assert.ok(
|
||||
keydownGuardIdx > keyupIdx &&
|
||||
staleIdx > keydownGuardIdx &&
|
||||
keyCode229Idx > staleIdx,
|
||||
);
|
||||
assert.match(
|
||||
runtimeSource.slice(staleIdx - 400, staleIdx + 200),
|
||||
/flushImeTextInputDeferral\(\);/,
|
||||
);
|
||||
// The recovery runs because the deferred key's release will never arrive, so
|
||||
// the press emitted by the flush must be paired with a synthesized release
|
||||
// instead of staying forwarded until focus loss.
|
||||
const recoverySlice = runtimeSource.slice(staleIdx, staleIdx + 500);
|
||||
assert.match(recoverySlice, /deferredKittyEvent = imeTextInputDeferredKittyEvent/);
|
||||
assert.match(recoverySlice, /releaseForwardedKittyPress\(\s*\{\s*\.\.\.deferredKittyEvent,\s*type: "keyup",?\s*\}\s*\)/);
|
||||
// A modified keydown (Ctrl+C, Alt+…) drops the lost keystroke instead of
|
||||
// injecting it in front of the interrupt or shortcut.
|
||||
assert.match(
|
||||
runtimeSource.slice(staleIdx, staleIdx + 700),
|
||||
/shouldDiscardStaleDeferredImeTextInput\(imeTextInputDeferredKey, e\)/,
|
||||
);
|
||||
});
|
||||
@@ -8,11 +8,18 @@
|
||||
* Defer those keydowns to the following insertText. If no remap arrives before
|
||||
* keyup/blur, the original ASCII key is flushed. Composition (keyCode 229 /
|
||||
* isComposing) stays on xterm's CompositionHelper path.
|
||||
*
|
||||
* The deferral must never outlive the keystroke it was armed for: Windows IMEs
|
||||
* report `Process` / keyCode 229 as the keyup of a key they consumed (or drop
|
||||
* the keyup), so a deferral flushed only on an exact key match stayed armed and
|
||||
* blocked typed input from then on (#3103). Any real key release, and any later
|
||||
* unrelated keydown, now ends the deferral.
|
||||
*/
|
||||
|
||||
export type ImeTextInputKeyEvent = {
|
||||
type?: string;
|
||||
key: string;
|
||||
code?: string;
|
||||
keyCode?: number;
|
||||
altKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
@@ -38,11 +45,124 @@ export function shouldDeferKeyDownForImeTextInput(
|
||||
return isAsciiPunctuationKey(event.key);
|
||||
}
|
||||
|
||||
/** Key releases that carry no keystroke of their own. */
|
||||
const MODIFIER_ONLY_KEY_RE =
|
||||
/^(Shift|Control|Alt|Meta|CapsLock|NumLock|ScrollLock|Hyper|Super|Fn|FnLock|Symbol|SymbolLock)$/;
|
||||
|
||||
export function isModifierOnlyKey(key: string): boolean {
|
||||
return MODIFIER_ONLY_KEY_RE.test(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* DOM keys that stand in for a key the IME consumed, mirroring the non-text
|
||||
* DOM keys the Kitty encoder already refuses to send as text.
|
||||
*/
|
||||
const IME_SENTINEL_KEYS = new Set(["Dead", "Process", "Unidentified", "Compose"]);
|
||||
|
||||
export function isImeSentinelKeyUp(event: ImeTextInputKeyEvent): boolean {
|
||||
if (event.type !== undefined && event.type !== "keyup") return false;
|
||||
return event.keyCode === 229 || IME_SENTINEL_KEYS.has(event.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* A deferred punctuation keystroke is over once any real key release arrives.
|
||||
* The IME remap (insertText) is dispatched before keyup, so a release means the
|
||||
* IME did not remap the key and the ASCII character must be flushed.
|
||||
*
|
||||
* The release key cannot be matched exactly: Windows IMEs report `Process` /
|
||||
* keyCode 229 as the release of a key they consumed, and some drop the release
|
||||
* entirely. Requiring an exact key match left the deferral armed, and the
|
||||
* armed deferral then blocked input (#3103). Composing releases are excluded —
|
||||
* an active composition still owns the keystroke and resolves it via
|
||||
* insertText.
|
||||
*/
|
||||
export function shouldFlushDeferredImeTextInputOnKeyUp(
|
||||
deferredKey: string | null | undefined,
|
||||
event: ImeTextInputKeyEvent,
|
||||
): boolean {
|
||||
if (!deferredKey) return false;
|
||||
if (event.type !== undefined && event.type !== "keyup") return false;
|
||||
if (event.isComposing === true) return false;
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return false;
|
||||
return !isModifierOnlyKey(event.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* A deferral that outlived its own keystroke is stale — the IME swallowed the
|
||||
* release. Flush it when a new, unmodified, non-composing keydown for a
|
||||
* different key arrives so the pending ASCII character still reaches the PTY.
|
||||
* A same-key keydown is auto-repeat (or a second IME attempt) and keeps
|
||||
* re-arming instead.
|
||||
*/
|
||||
export function shouldFlushStaleDeferredImeTextInput(
|
||||
deferredKey: string | null | undefined,
|
||||
event: ImeTextInputKeyEvent,
|
||||
): boolean {
|
||||
if (!deferredKey) return false;
|
||||
if (event.type !== undefined && event.type !== "keydown") return false;
|
||||
if (event.isComposing === true || event.keyCode === 229) return false;
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return false;
|
||||
if (isModifierOnlyKey(event.key)) return false;
|
||||
return event.key !== deferredKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* A modified keydown is a command (Ctrl+C, Alt+…), not the continuation of the
|
||||
* lost punctuation keystroke: flushing there would inject the ASCII character
|
||||
* in front of the interrupt or shortcut. Drop the stale deferral instead — the
|
||||
* keystroke was already lost to the IME.
|
||||
*/
|
||||
export function shouldDiscardStaleDeferredImeTextInput(
|
||||
deferredKey: string | null | undefined,
|
||||
event: ImeTextInputKeyEvent,
|
||||
): boolean {
|
||||
if (!deferredKey) return false;
|
||||
if (event.type !== undefined && event.type !== "keydown") return false;
|
||||
if (event.isComposing === true || event.keyCode === 229) return false;
|
||||
return Boolean(event.altKey || event.ctrlKey || event.metaKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* How the keyup that ends a deferral relates to the deferred key.
|
||||
* - `deferred`: an IME sentinel stood in for the deferred key, so the paired
|
||||
* release must be encoded from the deferred physical key.
|
||||
* - `own`: the release belongs to the deferred key itself (matched key or
|
||||
* physical code), so it already pairs the press the flush emitted.
|
||||
* - `unrelated`: another held key was released; it keeps its own identity and
|
||||
* the deferred press needs a separate synthesized release.
|
||||
*/
|
||||
export type DeferredKeyupReleaseMode = "deferred" | "own" | "unrelated";
|
||||
|
||||
export function resolveDeferredKeyupRelease(
|
||||
deferredKey: string | null | undefined,
|
||||
deferredCode: string | null | undefined,
|
||||
event: ImeTextInputKeyEvent,
|
||||
): DeferredKeyupReleaseMode {
|
||||
if (!deferredKey) return "own";
|
||||
if (event.type !== undefined && event.type !== "keyup") return "own";
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return "own";
|
||||
if (isModifierOnlyKey(event.key)) return "own";
|
||||
if (event.key === deferredKey) return "own";
|
||||
if (event.code && deferredCode && event.code === deferredCode) return "own";
|
||||
if (isImeSentinelKeyUp(event)) return "deferred";
|
||||
return "unrelated";
|
||||
}
|
||||
|
||||
export function shouldBlockKeyPressForImeTextInput(
|
||||
deferredKey: string | null | undefined,
|
||||
event: Pick<ImeTextInputKeyEvent, "type">,
|
||||
event: ImeTextInputKeyEvent,
|
||||
deferredKeyCode?: number | null,
|
||||
): boolean {
|
||||
return Boolean(deferredKey) && event.type === "keypress";
|
||||
if (!deferredKey || event.type !== "keypress") return false;
|
||||
// Composition keypresses carry no committable character on this path.
|
||||
if (event.isComposing === true || event.keyCode === 229) return true;
|
||||
// Only the deferred keystroke itself is suppressed. Blocking every keypress
|
||||
// while armed turned one stale deferral into a terminal that ignored all
|
||||
// typed input (#3103).
|
||||
return (
|
||||
event.key === deferredKey ||
|
||||
(deferredKeyCode != null && event.keyCode === deferredKeyCode)
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldCommitDeferredImeTextInput(
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { sanitizeTerminalInput } from "./terminalInputSanitize";
|
||||
|
||||
test("sanitizeTerminalInput strips zero-width space (U+200B)", () => {
|
||||
assert.equal(sanitizeTerminalInput("ls\u200b"), "ls");
|
||||
assert.equal(sanitizeTerminalInput("\u200bls"), "ls");
|
||||
assert.equal(sanitizeTerminalInput("l\u200bs"), "ls");
|
||||
});
|
||||
|
||||
test("sanitizeTerminalInput strips BOM / ZWNBSP (U+FEFF)", () => {
|
||||
assert.equal(sanitizeTerminalInput("\ufeffls"), "ls");
|
||||
assert.equal(sanitizeTerminalInput("ls\ufeff"), "ls");
|
||||
});
|
||||
|
||||
test("sanitizeTerminalInput strips soft hyphen (U+00AD)", () => {
|
||||
assert.equal(sanitizeTerminalInput("ls\u00ad"), "ls");
|
||||
});
|
||||
|
||||
test("sanitizeTerminalInput preserves ZWNJ (U+200C) and ZWJ (U+200D)", () => {
|
||||
// ZWNJ is meaningful in Persian orthography; ZWJ joins emoji sequences.
|
||||
// PTYs / remote programs can process these raw bytes, so they must not be
|
||||
// stripped — otherwise filenames, args, or passwords containing them break.
|
||||
assert.equal(sanitizeTerminalInput("a\u200cb"), "a\u200cb");
|
||||
assert.equal(sanitizeTerminalInput("a\u200db"), "a\u200db");
|
||||
// Emoji with ZWJ (👨💻 = man + ZWJ + laptop) is preserved
|
||||
assert.equal(sanitizeTerminalInput("👨\u200d💻"), "👨\u200d💻");
|
||||
});
|
||||
|
||||
test("sanitizeTerminalInput strips directional marks (U+200E, U+200F)", () => {
|
||||
assert.equal(sanitizeTerminalInput("\u200els\u200f"), "ls");
|
||||
});
|
||||
|
||||
test("sanitizeTerminalInput strips word joiner and invisible operators (U+2060-2064)", () => {
|
||||
assert.equal(sanitizeTerminalInput("ls\u2060"), "ls");
|
||||
assert.equal(sanitizeTerminalInput("ls\u2061"), "ls");
|
||||
assert.equal(sanitizeTerminalInput("ls\u2062"), "ls");
|
||||
assert.equal(sanitizeTerminalInput("ls\u2063"), "ls");
|
||||
assert.equal(sanitizeTerminalInput("ls\u2064"), "ls");
|
||||
});
|
||||
|
||||
test("sanitizeTerminalInput returns empty string for zero-width-only input", () => {
|
||||
assert.equal(sanitizeTerminalInput("\u200b"), "");
|
||||
assert.equal(sanitizeTerminalInput("\u200b\ufeff\u2060"), "");
|
||||
});
|
||||
|
||||
test("sanitizeTerminalInput does not strip ZWNJ/ZWJ-only input", () => {
|
||||
assert.equal(sanitizeTerminalInput("\u200c"), "\u200c");
|
||||
assert.equal(sanitizeTerminalInput("\u200d"), "\u200d");
|
||||
assert.equal(sanitizeTerminalInput("\u200c\u200d"), "\u200c\u200d");
|
||||
});
|
||||
|
||||
test("sanitizeTerminalInput preserves regular ASCII and control characters", () => {
|
||||
assert.equal(sanitizeTerminalInput("ls\r"), "ls\r");
|
||||
assert.equal(sanitizeTerminalInput("\r"), "\r");
|
||||
assert.equal(sanitizeTerminalInput("\n"), "\n");
|
||||
assert.equal(sanitizeTerminalInput("\u0003"), "\u0003"); // Ctrl+C
|
||||
assert.equal(sanitizeTerminalInput("\u007f"), "\u007f"); // DEL
|
||||
});
|
||||
|
||||
test("sanitizeTerminalInput preserves CJK and emoji characters", () => {
|
||||
assert.equal(sanitizeTerminalInput("你好"), "你好");
|
||||
assert.equal(sanitizeTerminalInput("😀"), "😀");
|
||||
// Full-width punctuation (common CJK IME output) is preserved
|
||||
assert.equal(sanitizeTerminalInput(",。!?"), ",。!?");
|
||||
});
|
||||
|
||||
test("sanitizeTerminalInput preserves Kitty escape sequences", () => {
|
||||
const kittySeq = "\u001b[0;;97:97u";
|
||||
assert.equal(sanitizeTerminalInput(kittySeq), kittySeq);
|
||||
});
|
||||
|
||||
test("sanitizeTerminalInput handles empty and falsy input", () => {
|
||||
assert.equal(sanitizeTerminalInput(""), "");
|
||||
});
|
||||
|
||||
test("sanitizeTerminalInput strips multiple interspersed zero-width characters", () => {
|
||||
assert.equal(sanitizeTerminalInput("\u200bl\u200bs\u200b \u200b-l\u200ba\u200b"), "ls -la");
|
||||
});
|
||||
|
||||
test("sanitizeTerminalInput is stable across repeated calls", () => {
|
||||
const input = "ls\u200b\r";
|
||||
const first = sanitizeTerminalInput(input);
|
||||
const second = sanitizeTerminalInput(first);
|
||||
assert.equal(first, second);
|
||||
assert.equal(first, "ls\r");
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Strip zero-width / invisible Unicode formatting characters from terminal
|
||||
* input before it reaches the PTY.
|
||||
*
|
||||
* CJK IMEs (notably Microsoft Pinyin / Sogou on Windows) occasionally emit
|
||||
* zero-width characters when switching composition modes. xterm.js sends
|
||||
* these to the PTY via `onData`, and with the `15-graphemes` Unicode
|
||||
* version they render at width 0 — so the command line looks normal but
|
||||
* contains hidden characters that cause the executed command to fail (#3138).
|
||||
*
|
||||
* ZWNJ (U+200C) and ZWJ (U+200D) are intentionally preserved: they carry
|
||||
* meaning in Persian orthography and emoji sequences, and PTYs / remote
|
||||
* programs can process those raw bytes even though they occupy no display
|
||||
* width. Stripping them would corrupt command arguments, filenames, and
|
||||
* passwords that legitimately contain them.
|
||||
*/
|
||||
|
||||
// U+00AD SOFT HYPHEN
|
||||
// U+200B ZERO WIDTH SPACE
|
||||
// U+200E LEFT-TO-RIGHT MARK
|
||||
// U+200F RIGHT-TO-LEFT MARK
|
||||
// U+2060 WORD JOINER
|
||||
// U+2061 FUNCTION APPLICATION
|
||||
// U+2062 INVISIBLE TIMES
|
||||
// U+2063 INVISIBLE SEPARATOR
|
||||
// U+2064 INVISIBLE PLUS
|
||||
// U+FEFF ZERO WIDTH NO-BREAK SPACE / BOM
|
||||
//
|
||||
// U+200C (ZWNJ) and U+200D (ZWJ) are intentionally excluded.
|
||||
const ZERO_WIDTH_INPUT_RE = /[\u00ad\u200b\u200e-\u200f\u2060-\u2064\ufeff]/g;
|
||||
|
||||
/**
|
||||
* Remove zero-width / invisible formatting characters from terminal input.
|
||||
* Returns an empty string when the input consisted solely of such characters.
|
||||
*/
|
||||
export function sanitizeTerminalInput(data: string): string {
|
||||
if (!data) return data;
|
||||
return data.replace(ZERO_WIDTH_INPUT_RE, "");
|
||||
}
|
||||
@@ -38,7 +38,7 @@ test("password-prompt input is classified before prompt state reset and cannot b
|
||||
);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/writeToSession\(id, outData, \{ sensitive \}\)/u,
|
||||
/for \(const chunk of getTextInputWireChunks\(outData, options\?\.perCharacterWrites === true\)\) \{\s*ctx\.terminalBackend\.writeToSession\(id, chunk, \{ sensitive \}\);\s*\}/u,
|
||||
);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEFAULT_OUTPUT_HISTORY_MAX_LINES,
|
||||
createTerminalOutputHistoryPreview,
|
||||
nextOutputHistoryPreviewTop,
|
||||
stripTerminalDisplayToPlainText,
|
||||
wrapOutputHistoryLineToRows,
|
||||
} from "./terminalOutputHistory.ts";
|
||||
|
||||
test("display chunks reduce to plain transcript text", () => {
|
||||
assert.deepEqual(
|
||||
stripTerminalDisplayToPlainText("\x1b[32mhello\x1b[0m world\r\n"),
|
||||
{ text: "hello world\r\n", pending: "" },
|
||||
);
|
||||
assert.equal(
|
||||
stripTerminalDisplayToPlainText("\x1b]0;title\x07tail").text,
|
||||
"tail",
|
||||
);
|
||||
assert.equal(
|
||||
stripTerminalDisplayToPlainText("\x1b]0;title\x1b\\tail").text,
|
||||
"tail",
|
||||
);
|
||||
assert.equal(stripTerminalDisplayToPlainText("a\x07b\x00c").text, "abc");
|
||||
});
|
||||
|
||||
test("escape sequences split across chunks are not leaked into the transcript", () => {
|
||||
const first = stripTerminalDisplayToPlainText("ok\x1b[3");
|
||||
assert.equal(first.text, "ok");
|
||||
assert.equal(first.pending, "\x1b[3");
|
||||
|
||||
const second = stripTerminalDisplayToPlainText("1mred", first.pending);
|
||||
assert.equal(second.text, "red");
|
||||
assert.equal(second.pending, "");
|
||||
});
|
||||
|
||||
test("escape sequences with intermediate bytes are consumed through their final byte", () => {
|
||||
// ESC ( B designates G0 (ncurses / terminal reset emit it constantly).
|
||||
assert.equal(stripTerminalDisplayToPlainText("\x1b(Bplain").text, "plain");
|
||||
// ESC # 8 is DECALN.
|
||||
assert.equal(stripTerminalDisplayToPlainText("\x1b#8plain").text, "plain");
|
||||
// Two-byte escapes without intermediates keep working.
|
||||
assert.equal(stripTerminalDisplayToPlainText("\x1bMplain").text, "plain");
|
||||
|
||||
const first = stripTerminalDisplayToPlainText("ok\x1b(");
|
||||
assert.equal(first.text, "ok");
|
||||
assert.equal(first.pending, "\x1b(");
|
||||
assert.equal(stripTerminalDisplayToPlainText("Btail", first.pending).text, "tail");
|
||||
});
|
||||
|
||||
test("a cursor-addressed frame without newlines stays inside the character budget", () => {
|
||||
const history = createTerminalOutputHistoryPreview({ maxChars: 64 });
|
||||
for (let frame = 0; frame < 200; frame += 1) {
|
||||
history.append(`\x1b[Hframe ${frame} ${"x".repeat(80)}`);
|
||||
}
|
||||
|
||||
const transcript = [...history.getLines()].join("");
|
||||
assert.ok(transcript.length <= 2 * 64, `unbounded open line: ${transcript.length}`);
|
||||
// The newest frame still lands in the retained tail.
|
||||
assert.ok(transcript.includes("frame 199"), transcript.slice(-200));
|
||||
});
|
||||
|
||||
test("8-bit control strings are consumed through their terminator", () => {
|
||||
// C1 OSC (0x9d) with C1 ST (0x9c) must not leak its payload.
|
||||
assert.equal(stripTerminalDisplayToPlainText("\x9d0;SECRET\x9ctail").text, "tail");
|
||||
// DCS (0x90), SOS (0x98), PM (0x9e) and APC (0x9f) use the same terminator.
|
||||
assert.equal(stripTerminalDisplayToPlainText("\x90payload\x9ctail").text, "tail");
|
||||
assert.equal(stripTerminalDisplayToPlainText("\x98payload\x9ctail").text, "tail");
|
||||
assert.equal(stripTerminalDisplayToPlainText("\x9epayload\x9ctail").text, "tail");
|
||||
assert.equal(stripTerminalDisplayToPlainText("\x9fpayload\x9ctail").text, "tail");
|
||||
|
||||
const first = stripTerminalDisplayToPlainText("ok\x9d0;title");
|
||||
assert.equal(first.text, "ok");
|
||||
assert.equal(first.pending, "\x9d0;title");
|
||||
assert.equal(stripTerminalDisplayToPlainText("\x9ctail", first.pending).text, "tail");
|
||||
});
|
||||
|
||||
test("tabs expand to terminal tab stops so preview rows wrap as they render", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append("a\tb\n");
|
||||
history.append("abc\td\n");
|
||||
assert.deepEqual([...history.getLines()], ["a b", "abc d"]);
|
||||
});
|
||||
|
||||
test("tab stops advance by cell columns, not characters", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append("中\tb\n");
|
||||
assert.deepEqual([...history.getLines()], ["中 b"]);
|
||||
});
|
||||
|
||||
test("an oversized control string split across chunks leaks no payload", () => {
|
||||
const payload = "y".repeat(5000);
|
||||
const first = stripTerminalDisplayToPlainText(`ok\x1b]52;c;${payload}`);
|
||||
assert.equal(first.text, "ok");
|
||||
assert.equal(first.pending.length, 4096);
|
||||
assert.equal(first.pending.startsWith("\x1b]"), true);
|
||||
|
||||
const second = stripTerminalDisplayToPlainText("\x07tail", first.pending);
|
||||
assert.equal(second.text, "tail");
|
||||
});
|
||||
|
||||
test("a span longer than the remaining budget continues into the next line", () => {
|
||||
const history = createTerminalOutputHistoryPreview({ maxChars: 16 });
|
||||
history.append("aaaaaaaaaaaaaaaaTTTTTTTT\n");
|
||||
assert.ok([...history.getLines()].join("").includes("TTTTTTTT"));
|
||||
});
|
||||
|
||||
test("erase-in-line after a carriage return drops the stale suffix", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append("downloading 100%\rdownloading 5%\x1b[K\n");
|
||||
assert.deepEqual([...history.getLines()], ["downloading 5%"]);
|
||||
|
||||
history.clear();
|
||||
history.append("keep\r\x1b[2Knew\n");
|
||||
assert.deepEqual([...history.getLines()], ["new"]);
|
||||
});
|
||||
|
||||
test("clear resets the tab stop column with the rest of the line state", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append("abc");
|
||||
history.clear();
|
||||
history.append("\tb\n");
|
||||
assert.deepEqual([...history.getLines()], [" b"]);
|
||||
});
|
||||
|
||||
test("bare carriage returns overwrite the line they restart", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append("downloading 10%\r");
|
||||
history.append("downloading 55%\r");
|
||||
history.append("downloading 100%\r\n");
|
||||
history.append("done\n");
|
||||
assert.deepEqual([...history.getLines()], ["downloading 100%", "done"]);
|
||||
});
|
||||
|
||||
test("history keeps a bounded tail of lines", () => {
|
||||
const history = createTerminalOutputHistoryPreview({ maxLines: 3 });
|
||||
for (let index = 0; index < 6; index += 1) history.append(`line ${index}\n`);
|
||||
assert.deepEqual(
|
||||
[...history.getLines()],
|
||||
["line 3", "line 4", "line 5"],
|
||||
);
|
||||
});
|
||||
|
||||
test("preview rows wrap long lines and flag the continuation rows", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append("ok\n");
|
||||
history.append("abcdefgh\n");
|
||||
|
||||
const window = history.getPreviewRows({ cols: 4, rows: 3, top: 0 });
|
||||
assert.equal(window.totalRows, 3);
|
||||
assert.deepEqual(
|
||||
window.rows.map((row) => row.text),
|
||||
["ok", "abcd", "efgh"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
window.rows.map((row) => row.isWrapped),
|
||||
[false, false, true],
|
||||
);
|
||||
});
|
||||
|
||||
test("preview windows clamp to the retained rows and pad short output", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append("one\ntwo\n");
|
||||
|
||||
assert.deepEqual(
|
||||
history.getPreviewRows({ cols: 10, rows: 2, top: 99 }).rows.map((row) => row.text),
|
||||
["one", "two"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
history.getPreviewRows({ cols: 10, rows: 4, top: 0 }).rows.map((row) => row.text),
|
||||
["one", "two", "", ""],
|
||||
);
|
||||
});
|
||||
|
||||
test("preview rows keep wide characters intact at a column boundary", () => {
|
||||
// Four columns hold two CJK cells per row; the glyphs are never split.
|
||||
assert.deepEqual(wrapOutputHistoryLineToRows("中文中文中文", 4), ["中文", "中文", "中文"]);
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append("中文中文中文\n");
|
||||
assert.deepEqual(
|
||||
history.getPreviewRows({ cols: 4, rows: 3, top: 0 }).rows.map((row) => row.text),
|
||||
["中文", "中文", "中文"],
|
||||
);
|
||||
});
|
||||
|
||||
test("wheel steps walk the preview from the newest row upwards", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
for (let index = 0; index < 6; index += 1) history.append(`row ${index}\n`);
|
||||
|
||||
const totalRows = history.getPreviewRows({ cols: 20, rows: 2, top: 0 }).totalRows;
|
||||
assert.equal(totalRows, 6);
|
||||
|
||||
const bottom = nextOutputHistoryPreviewTop({
|
||||
currentTop: null,
|
||||
lines: 0,
|
||||
rows: 2,
|
||||
totalRows,
|
||||
});
|
||||
assert.equal(bottom, 4);
|
||||
assert.deepEqual(
|
||||
history.getPreviewRows({ cols: 20, rows: 2, top: bottom }).rows.map((row) => row.text),
|
||||
["row 4", "row 5"],
|
||||
);
|
||||
|
||||
const up = nextOutputHistoryPreviewTop({ currentTop: bottom, lines: -3, rows: 2, totalRows });
|
||||
assert.equal(up, 1);
|
||||
const top = nextOutputHistoryPreviewTop({ currentTop: up, lines: -30, rows: 2, totalRows });
|
||||
assert.equal(top, 0);
|
||||
});
|
||||
|
||||
test("clear drops retained transcript and pending escapes", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append(`keep\n`);
|
||||
history.clear();
|
||||
history.append("\x1b[3");
|
||||
assert.deepEqual([...history.getLines()], []);
|
||||
assert.equal(
|
||||
history.getPreviewRows({ cols: 10, rows: 2, top: 0 }).totalRows,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("default retention bounds the preview history", () => {
|
||||
assert.equal(DEFAULT_OUTPUT_HISTORY_MAX_LINES > 0, true);
|
||||
|
||||
const history = createTerminalOutputHistoryPreview({ maxLines: 2, maxChars: 6 });
|
||||
history.append("aaaaaaaa\n");
|
||||
history.append("bbbbbbbb\n");
|
||||
history.append("cccccccc\n");
|
||||
// Retained lines plus the open line stay within twice the character budget,
|
||||
// and the newest text survives.
|
||||
const transcript = [...history.getLines()].join("");
|
||||
assert.ok(transcript.length <= 2 * 6, transcript);
|
||||
assert.ok(transcript.includes("cc"), transcript);
|
||||
});
|
||||
@@ -0,0 +1,428 @@
|
||||
import {
|
||||
sliceStringByCellColumns,
|
||||
stringCellWidth,
|
||||
} from "../../terminal/autocomplete/terminalStringCellWidth";
|
||||
import type { HistoryPreviewRow } from "./terminalHistoryScrollOverride";
|
||||
|
||||
/**
|
||||
* Plain-text transcript of what a session printed, used as the alternate-screen
|
||||
* history preview data source (#2516).
|
||||
*
|
||||
* Inside screen/vim/codex the app owns the alternate buffer, so xterm's normal
|
||||
* buffer has no scrollback and the preview has nothing to show. The output
|
||||
* stream is the only usable source, so every display chunk fed to the log
|
||||
* capture is reduced to plain lines here: escape sequences are dropped, a bare
|
||||
* `\r` overwrites the line it restarts (progress bars/spinners stay one line),
|
||||
* and the retained tail is bounded.
|
||||
*
|
||||
* Feed it the chunk *after* the programmatic command rewriter (masked commands
|
||||
* never reach the preview) but *before* the replay-safe sanitizer, which drops
|
||||
* alternate-screen output on purpose.
|
||||
*/
|
||||
|
||||
const ESC = "\x1b";
|
||||
const BEL = "\x07";
|
||||
const C1_CSI = "\x9b";
|
||||
const C1_ST = "\x9c";
|
||||
|
||||
// Cap on a partially received escape sequence, mirroring the replay sanitizer.
|
||||
const MAX_PENDING_ESCAPE_CHARS = 4096;
|
||||
|
||||
export const DEFAULT_OUTPUT_HISTORY_MAX_LINES = 2000;
|
||||
export const DEFAULT_OUTPUT_HISTORY_MAX_CHARS = 200_000;
|
||||
/** xterm's default tab stop interval, used when expanding tabs for the preview. */
|
||||
const TERMINAL_TAB_STOP_COLUMNS = 8;
|
||||
|
||||
// 8-bit (C1) control string introducers: DCS, SOS, OSC, PM, APC. Their
|
||||
// payloads must be consumed with them, not left as transcript text.
|
||||
const isC1ControlStringIntroducer = (ch: string): boolean =>
|
||||
ch === "\x90" || ch === "\x98" || ch === "\x9d" || ch === "\x9e" || ch === "\x9f";
|
||||
|
||||
const isEscapeIntroducer = (ch: string): boolean =>
|
||||
ch === ESC || ch === C1_CSI || isC1ControlStringIntroducer(ch);
|
||||
|
||||
const findEscapeIntroducer = (input: string, from: number): number => {
|
||||
for (let i = from; i < input.length; i += 1) {
|
||||
if (isEscapeIntroducer(input[i])) return i;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const consumeCsiBody = (input: string, from: number): number | null => {
|
||||
for (let i = from; i < input.length; i += 1) {
|
||||
// Parameter/intermediate bytes are 0x20-0x3f; the final byte is 0x40-0x7e.
|
||||
if (input[i] >= "@" && input[i] <= "~") return i + 1;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const consumeControlString = (input: string, from: number): number | null => {
|
||||
for (let i = from; i < input.length; i += 1) {
|
||||
const ch = input[i];
|
||||
if (ch === BEL) return i + 1;
|
||||
if (ch === ESC && input[i + 1] === "\\") return i + 2;
|
||||
if (ch === C1_ST) return i + 1;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const isControlStringIntroducer = (ch: string): boolean =>
|
||||
ch === "]" || ch === "P" || ch === "^" || ch === "_" || ch === "X";
|
||||
|
||||
/** Length of the introducer bytes of the escape sequence starting at `start`. */
|
||||
const escapeIntroducerLength = (input: string, start: number): number => {
|
||||
if (input[start] !== ESC) return 1;
|
||||
const second = input[start + 1];
|
||||
return second === "[" || isControlStringIntroducer(second) ? 2 : 1;
|
||||
};
|
||||
|
||||
/** Marks an erase-in-line the transcript must apply (kept in stripper output). */
|
||||
const ERASE_TO_END_OF_LINE = "\x1f";
|
||||
|
||||
/**
|
||||
* The erase-in-line sequences progress lines use (`\r` + text + `\x1b[K`) must
|
||||
* be applied to the transcript, or the stale suffix of a shorter redraw shows
|
||||
* up as text. Returns the marker for the variants the transcript can apply.
|
||||
*/
|
||||
const eraseInLineMarkerFor = (input: string, start: number, end: number): string | null => {
|
||||
if (input[end - 1] !== "K") return null;
|
||||
const introducerLength = escapeIntroducerLength(input, start);
|
||||
const mode = input.slice(start + introducerLength, end - 1).split(";")[0];
|
||||
// Erase from start to cursor (1) has no transcript equivalent; skip it.
|
||||
if (mode === "1") return null;
|
||||
return ERASE_TO_END_OF_LINE;
|
||||
};
|
||||
|
||||
// ECMA-48 nF sequences: zero or more intermediates (0x20-0x2f) then one final
|
||||
// byte (0x30-0x7e), e.g. `ESC ( B` (charset) or `ESC # 8` (DECALN).
|
||||
const isEscapeIntermediateByte = (ch: string): boolean => ch >= " " && ch <= "/";
|
||||
const isEscapeFinalByte = (ch: string): boolean => ch >= "0" && ch <= "~";
|
||||
|
||||
/** Index past the escape sequence at `start`, or null while it is incomplete. */
|
||||
const consumeEscapeSequence = (input: string, start: number): number | null => {
|
||||
const introducer = input[start];
|
||||
if (introducer === C1_CSI) return consumeCsiBody(input, start + 1);
|
||||
if (isC1ControlStringIntroducer(introducer)) return consumeControlString(input, start + 1);
|
||||
const second = input[start + 1];
|
||||
if (second === undefined) return null;
|
||||
if (second === "[") return consumeCsiBody(input, start + 2);
|
||||
if (isControlStringIntroducer(second)) return consumeControlString(input, start + 2);
|
||||
let end = start + 1;
|
||||
while (end < input.length && isEscapeIntermediateByte(input[end])) end += 1;
|
||||
if (end >= input.length) return null;
|
||||
// No final byte: leave the trailing byte to the transcript filters, which
|
||||
// drop control characters, instead of eating a meaningful one.
|
||||
if (!isEscapeFinalByte(input[end])) return end;
|
||||
return end + 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keep the line-editing controls the transcript needs and printable text; drop
|
||||
* every other control character (BEL, SO/SI, NUL, stray C1 bytes, ...).
|
||||
*/
|
||||
const keepTranscriptChars = (span: string): string => {
|
||||
// Fast path: most spans are plain text with nothing to filter.
|
||||
let needsFiltering = false;
|
||||
for (let i = 0; i < span.length; i += 1) {
|
||||
const code = span.charCodeAt(i);
|
||||
if (code < 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) {
|
||||
needsFiltering = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!needsFiltering) return span;
|
||||
|
||||
let kept = "";
|
||||
for (let i = 0; i < span.length; i += 1) {
|
||||
const code = span.charCodeAt(i);
|
||||
if (code >= 0x20 && code !== 0x7f && (code < 0x80 || code > 0x9f)) {
|
||||
kept += span[i];
|
||||
} else if (code === 8 || code === 9 || code === 10 || code === 13 || code === 31) {
|
||||
kept += span[i];
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reduce a display chunk to plain text: escape sequences are removed while
|
||||
* `\n` / `\r` / `\b` / `\t` and the erase-in-line marker survive so the history
|
||||
* can track line edits.
|
||||
*/
|
||||
export const stripTerminalDisplayToPlainText = (
|
||||
chunk: string,
|
||||
pending: string = "",
|
||||
): { text: string; pending: string } => {
|
||||
const input = pending + chunk;
|
||||
let output = "";
|
||||
let i = 0;
|
||||
while (i < input.length) {
|
||||
if (isEscapeIntroducer(input[i])) {
|
||||
const end = consumeEscapeSequence(input, i);
|
||||
if (end === null) {
|
||||
const rest = input.slice(i);
|
||||
if (rest.length <= MAX_PENDING_ESCAPE_CHARS) {
|
||||
return { text: output, pending: rest };
|
||||
}
|
||||
// Cap the retained tail but keep its introducer: without it the payload
|
||||
// of an oversized control string would be re-parsed as transcript text
|
||||
// on the next chunk.
|
||||
const introducerLength = escapeIntroducerLength(input, i);
|
||||
return {
|
||||
text: output,
|
||||
pending: rest.slice(0, introducerLength)
|
||||
+ rest.slice(rest.length - (MAX_PENDING_ESCAPE_CHARS - introducerLength)),
|
||||
};
|
||||
}
|
||||
const eraseInLine = eraseInLineMarkerFor(input, i, end);
|
||||
if (eraseInLine) output += eraseInLine;
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
const next = findEscapeIntroducer(input, i + 1);
|
||||
const end = next === -1 ? input.length : next;
|
||||
const kept = keepTranscriptChars(input.slice(i, end));
|
||||
if (kept) output += kept;
|
||||
i = end;
|
||||
}
|
||||
return { text: output, pending: "" };
|
||||
};
|
||||
|
||||
const isAsciiOnly = (text: string): boolean => {
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
if (text.charCodeAt(i) > 0x7f) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Cell columns a transcript span occupies (wide glyphs count twice). */
|
||||
const pieceCellWidth = (text: string): number =>
|
||||
isAsciiOnly(text) ? text.length : stringCellWidth(text);
|
||||
|
||||
/**
|
||||
* Wrap one transcript line into viewport-sized rows. Continuation rows carry
|
||||
* the same `isWrapped` flag xterm uses so soft-wrapped selection joins keep
|
||||
* working (see joinHistoryPreviewSelectionText).
|
||||
*/
|
||||
export const wrapOutputHistoryLineToRows = (text: string, cols: number): string[] => {
|
||||
if (cols < 1) return [text];
|
||||
const ascii = isAsciiOnly(text);
|
||||
const width = ascii ? text.length : stringCellWidth(text);
|
||||
if (width <= cols) return [text];
|
||||
|
||||
const rows: string[] = [];
|
||||
let startCell = 0;
|
||||
while (startCell < width) {
|
||||
// ASCII lines skip the grapheme segmenter; a transcript can hold long lines.
|
||||
const row = ascii
|
||||
? text.slice(startCell, startCell + cols)
|
||||
: sliceStringByCellColumns(text, startCell, startCell + cols);
|
||||
if (!row) break;
|
||||
rows.push(row);
|
||||
startCell += ascii ? row.length : stringCellWidth(row);
|
||||
}
|
||||
return rows.length > 0 ? rows : [text];
|
||||
};
|
||||
|
||||
const buildPreviewRows = (lines: readonly string[], cols: number): HistoryPreviewRow[] => {
|
||||
const rows: HistoryPreviewRow[] = [];
|
||||
for (const line of lines) {
|
||||
const wrapped = wrapOutputHistoryLineToRows(line, cols);
|
||||
for (let row = 0; row < wrapped.length; row += 1) {
|
||||
rows.push({ isWrapped: row > 0, text: wrapped[row] });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
export const clampOutputHistoryPreviewTop = (
|
||||
top: number,
|
||||
totalRows: number,
|
||||
rows: number,
|
||||
): number => {
|
||||
const maxTop = Math.max(0, totalRows - Math.max(1, rows));
|
||||
return Math.max(0, Math.min(maxTop, top));
|
||||
};
|
||||
|
||||
export const nextOutputHistoryPreviewTop = ({
|
||||
currentTop,
|
||||
lines,
|
||||
rows,
|
||||
totalRows,
|
||||
}: {
|
||||
currentTop: number | null;
|
||||
lines: number;
|
||||
rows: number;
|
||||
totalRows: number;
|
||||
}): number => clampOutputHistoryPreviewTop(
|
||||
clampOutputHistoryPreviewTop(
|
||||
currentTop ?? Math.max(0, totalRows - Math.max(1, rows)),
|
||||
totalRows,
|
||||
rows,
|
||||
) + lines,
|
||||
totalRows,
|
||||
rows,
|
||||
);
|
||||
|
||||
export interface OutputHistoryPreviewWindow {
|
||||
rows: HistoryPreviewRow[];
|
||||
totalRows: number;
|
||||
}
|
||||
|
||||
export interface TerminalOutputHistoryPreview {
|
||||
append(chunk: string): void;
|
||||
clear(): void;
|
||||
/** Transcript lines, oldest first (the open last line included). */
|
||||
getLines(): readonly string[];
|
||||
/** Viewport rows the retained transcript wraps to at `cols` columns. */
|
||||
getPreviewRowCount(cols: number): number;
|
||||
getPreviewRows(params: { cols: number; rows: number; top: number }): OutputHistoryPreviewWindow;
|
||||
}
|
||||
|
||||
export const createTerminalOutputHistoryPreview = (options?: {
|
||||
maxLines?: number;
|
||||
maxChars?: number;
|
||||
}): TerminalOutputHistoryPreview => {
|
||||
const maxLines = Math.max(1, options?.maxLines ?? DEFAULT_OUTPUT_HISTORY_MAX_LINES);
|
||||
const maxChars = Math.max(1, options?.maxChars ?? DEFAULT_OUTPUT_HISTORY_MAX_CHARS);
|
||||
let lines: string[] = [];
|
||||
let current = "";
|
||||
let cursor = 0;
|
||||
let cursorCell = 0;
|
||||
let totalChars = 0;
|
||||
let pendingEscape = "";
|
||||
let cacheDirty = true;
|
||||
let cacheCols = -1;
|
||||
let cacheRows: HistoryPreviewRow[] = [];
|
||||
|
||||
const commitCurrentLine = () => {
|
||||
lines.push(current);
|
||||
totalChars += current.length;
|
||||
current = "";
|
||||
cursor = 0;
|
||||
cursorCell = 0;
|
||||
// Never trim away the last retained line.
|
||||
while (lines.length > maxLines || (totalChars > maxChars && lines.length > 1)) {
|
||||
const dropped = lines.shift();
|
||||
if (dropped === undefined) break;
|
||||
totalChars -= dropped.length;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Write one span at the open line's cursor. A cursor-addressed redraw
|
||||
* (cursor-home CSI stripped, no LF) would keep appending frames to the open
|
||||
* line forever, so the budget is enforced here: spans are written in bounded
|
||||
* pieces, committing a full line at the cap, so nothing is dropped and the
|
||||
* copy every write makes stays bounded.
|
||||
*/
|
||||
const writeSpan = (span: string) => {
|
||||
let offset = 0;
|
||||
while (offset < span.length) {
|
||||
if (current.length >= maxChars) commitCurrentLine();
|
||||
const piece = span.slice(offset, offset + (maxChars - current.length));
|
||||
if (!piece) return;
|
||||
current = cursor >= current.length
|
||||
? current + piece
|
||||
: current.slice(0, cursor) + piece + current.slice(cursor + piece.length);
|
||||
cursor = cursor >= current.length ? current.length : cursor + piece.length;
|
||||
cursorCell += pieceCellWidth(piece);
|
||||
offset += piece.length;
|
||||
}
|
||||
};
|
||||
|
||||
const writeText = (text: string) => {
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const ch = text[i];
|
||||
if (ch === "\n") {
|
||||
commitCurrentLine();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\r") {
|
||||
cursor = 0;
|
||||
cursorCell = 0;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\b") {
|
||||
cursor = Math.max(0, cursor - 1);
|
||||
cursorCell = Math.max(0, cursorCell - 1);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === ERASE_TO_END_OF_LINE) {
|
||||
current = current.slice(0, cursor);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\t") {
|
||||
// Expand to the next 8-column tab stop so row widths match how the
|
||||
// preview overlay renders the text; a literal tab renders at the tab
|
||||
// stop and would be clipped instead of wrapped onto a continuation row.
|
||||
const padding = TERMINAL_TAB_STOP_COLUMNS - (cursorCell % TERMINAL_TAB_STOP_COLUMNS);
|
||||
writeSpan(" ".repeat(padding));
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let end = i;
|
||||
while (
|
||||
end < text.length
|
||||
&& text[end] !== "\n"
|
||||
&& text[end] !== "\r"
|
||||
&& text[end] !== "\b"
|
||||
&& text[end] !== "\t"
|
||||
&& text[end] !== ERASE_TO_END_OF_LINE
|
||||
) {
|
||||
end += 1;
|
||||
}
|
||||
writeSpan(text.slice(i, end));
|
||||
i = end;
|
||||
}
|
||||
};
|
||||
|
||||
const getLines = (): readonly string[] => (current ? [...lines, current] : lines);
|
||||
|
||||
const refreshPreviewCache = (cols: number) => {
|
||||
if (!cacheDirty && cacheCols === cols) return;
|
||||
cacheRows = buildPreviewRows(getLines(), cols);
|
||||
cacheCols = cols;
|
||||
cacheDirty = false;
|
||||
};
|
||||
|
||||
return {
|
||||
append(chunk: string): void {
|
||||
if (!chunk) return;
|
||||
const { text, pending } = stripTerminalDisplayToPlainText(chunk, pendingEscape);
|
||||
pendingEscape = pending;
|
||||
if (!text) return;
|
||||
writeText(text);
|
||||
cacheDirty = true;
|
||||
},
|
||||
clear(): void {
|
||||
lines = [];
|
||||
current = "";
|
||||
cursor = 0;
|
||||
cursorCell = 0;
|
||||
totalChars = 0;
|
||||
pendingEscape = "";
|
||||
cacheDirty = true;
|
||||
},
|
||||
getLines,
|
||||
getPreviewRowCount(cols: number): number {
|
||||
refreshPreviewCache(cols);
|
||||
return cacheRows.length;
|
||||
},
|
||||
getPreviewRows({ cols, rows: visibleRows, top }): OutputHistoryPreviewWindow {
|
||||
const columnCount = Math.max(1, cols);
|
||||
const rowCount = Math.max(1, visibleRows);
|
||||
refreshPreviewCache(columnCount);
|
||||
const totalRows = cacheRows.length;
|
||||
const clampedTop = clampOutputHistoryPreviewTop(top, totalRows, rowCount);
|
||||
const window = cacheRows.slice(clampedTop, clampedTop + rowCount);
|
||||
while (window.length < rowCount) window.push({ isWrapped: false, text: "" });
|
||||
return { rows: window, totalRows };
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
MAX_RAW_PASTE_PER_CHARACTER_LENGTH,
|
||||
getTextInputWireChunks,
|
||||
shouldSplitImeTextInputForWire,
|
||||
shouldSplitRawPasteInputForWire,
|
||||
splitTextIntoCodePointWrites,
|
||||
} from "./terminalPerCharacterInput";
|
||||
|
||||
test("splitTextIntoCodePointWrites keeps surrogate pairs on one write", () => {
|
||||
assert.deepEqual(splitTextIntoCodePointWrites("a中👍b"), ["a", "中", "👍", "b"]);
|
||||
assert.deepEqual(splitTextIntoCodePointWrites("👍"), ["👍"]);
|
||||
assert.deepEqual(splitTextIntoCodePointWrites(""), []);
|
||||
});
|
||||
|
||||
test("IME commits with more than one character split per character", () => {
|
||||
assert.equal(shouldSplitImeTextInputForWire("中国"), true);
|
||||
assert.equal(shouldSplitImeTextInputForWire("abc"), true);
|
||||
assert.equal(shouldSplitImeTextInputForWire("中a👍"), true);
|
||||
});
|
||||
|
||||
test("single-character IME commits keep the single write", () => {
|
||||
assert.equal(shouldSplitImeTextInputForWire("中"), false);
|
||||
assert.equal(shouldSplitImeTextInputForWire("👍"), false);
|
||||
assert.equal(shouldSplitImeTextInputForWire(","), false);
|
||||
assert.equal(shouldSplitImeTextInputForWire(""), false);
|
||||
});
|
||||
|
||||
test("IME commits carrying escape sequences never split", () => {
|
||||
assert.equal(shouldSplitImeTextInputForWire("\x1b[200~中\x1b[201~"), false);
|
||||
assert.equal(shouldSplitImeTextInputForWire("\x1b[0;;200u"), false);
|
||||
assert.equal(shouldSplitImeTextInputForWire("\x1ba"), false);
|
||||
});
|
||||
|
||||
test("short raw pastes split per character", () => {
|
||||
assert.equal(shouldSplitRawPasteInputForWire("10.1.2.3"), true);
|
||||
assert.equal(shouldSplitRawPasteInputForWire("中文"), true);
|
||||
assert.equal(shouldSplitRawPasteInputForWire("ab"), true);
|
||||
assert.equal(
|
||||
shouldSplitRawPasteInputForWire("a".repeat(MAX_RAW_PASTE_PER_CHARACTER_LENGTH)),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("single-character and long raw pastes keep the single write", () => {
|
||||
assert.equal(shouldSplitRawPasteInputForWire("a"), false);
|
||||
assert.equal(
|
||||
shouldSplitRawPasteInputForWire("a".repeat(MAX_RAW_PASTE_PER_CHARACTER_LENGTH + 1)),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldSplitRawPasteInputForWire(""), false);
|
||||
});
|
||||
|
||||
test("raw pastes containing escape sequences never split", () => {
|
||||
assert.equal(shouldSplitRawPasteInputForWire("\x1b[200~ab\x1b[201~"), false);
|
||||
assert.equal(shouldSplitRawPasteInputForWire("\x1b[A"), false);
|
||||
assert.equal(shouldSplitRawPasteInputForWire("a\x1b"), false);
|
||||
});
|
||||
|
||||
test("wire chunking honors the per-character request only for plain text", () => {
|
||||
assert.deepEqual(getTextInputWireChunks("ab", false), ["ab"]);
|
||||
assert.deepEqual(getTextInputWireChunks("ab", true), ["a", "b"]);
|
||||
assert.deepEqual(getTextInputWireChunks("a中👍", true), ["a", "中", "👍"]);
|
||||
assert.deepEqual(getTextInputWireChunks("\x1b[200~ab\x1b[201~", true), [
|
||||
"\x1b[200~ab\x1b[201~",
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Strict bastion prompts (e.g. QAX/奇安信) treat one SSH channel write as a
|
||||
* single keystroke and silently drop every multi-character chunk (#3077). IME
|
||||
* commits and short raw pastes therefore have to leave the renderer as
|
||||
* per-character writes, which is what typing produces and what Xshell does.
|
||||
*/
|
||||
|
||||
/** Raw pastes longer than this keep the single-write behavior. */
|
||||
export const MAX_RAW_PASTE_PER_CHARACTER_LENGTH = 32;
|
||||
|
||||
const ESC = "\x1b";
|
||||
|
||||
/** True while the payload only carries plain text and no escape sequence. */
|
||||
export const isPlainTerminalInputText = (data: string): boolean => !data.includes(ESC);
|
||||
|
||||
/** One chunk per Unicode code point so surrogate pairs stay intact. */
|
||||
export const splitTextIntoCodePointWrites = (data: string): string[] => Array.from(data);
|
||||
|
||||
/** IME commits: any plain text with more than one character splits per glyph. */
|
||||
export const shouldSplitImeTextInputForWire = (text: string): boolean =>
|
||||
Array.from(text).length > 1 && isPlainTerminalInputText(text);
|
||||
|
||||
/**
|
||||
* Raw (non-bracketed) paste: short plain text goes out as keystrokes, longer
|
||||
* pastes keep the single write so bulk pastes do not degrade.
|
||||
*/
|
||||
export const shouldSplitRawPasteInputForWire = (data: string): boolean => {
|
||||
if (data.length <= 1 || !isPlainTerminalInputText(data)) return false;
|
||||
let codePoints = 0;
|
||||
for (let index = 0; index < data.length; ) {
|
||||
index += (data.codePointAt(index) ?? 0) > 0xffff ? 2 : 1;
|
||||
codePoints += 1;
|
||||
if (codePoints > MAX_RAW_PASTE_PER_CHARACTER_LENGTH) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Chunks to write for one input payload; escape sequences are never split. */
|
||||
export const getTextInputWireChunks = (
|
||||
data: string,
|
||||
perCharacterWrites: boolean,
|
||||
): string[] =>
|
||||
perCharacterWrites && isPlainTerminalInputText(data)
|
||||
? splitTextIntoCodePointWrites(data)
|
||||
: [data];
|
||||
@@ -171,7 +171,7 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
|
||||
}
|
||||
|
||||
export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState, vaultInitialized } = ctx;
|
||||
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState, vaultInitialized } = ctx;
|
||||
const effectiveTerminalProtocol = resolveEffectiveTerminalProtocol(host);
|
||||
const hibernateHiddenTabs = resolveTerminalHibernateEnabledForProtocol(
|
||||
terminalSettings,
|
||||
@@ -432,6 +432,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
isBootActiveRef.current = true;
|
||||
terminalDataCapturedRef.current = false;
|
||||
connectionLogBufferRef.current.reset();
|
||||
terminalOutputHistory?.clear();
|
||||
terminalLogSanitizerRef.current = createReplaySafeTerminalLogSanitizer();
|
||||
setError(null);
|
||||
hasConnectedRef.current = false;
|
||||
@@ -519,6 +520,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
serialLineBufferRef,
|
||||
telnetLocalEchoRef,
|
||||
onTerminalLogData: captureTerminalLogData,
|
||||
terminalOutputHistory,
|
||||
onCwdChange: (cwd: string) => {
|
||||
onPluginRuntimeCwdChange(cwd, { source: 'osc7' });
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const fs = require("node:fs");
|
||||
const Module = require("node:module");
|
||||
@@ -15,6 +16,12 @@ const {
|
||||
waitForTransportDial,
|
||||
} = require("./sshConnectionPool.cjs");
|
||||
|
||||
const TEST_PRIVATE_KEY = crypto.generateKeyPairSync("ec", {
|
||||
namedCurve: "prime256v1",
|
||||
privateKeyEncoding: { type: "sec1", format: "pem" },
|
||||
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||
}).privateKey;
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
});
|
||||
@@ -579,6 +586,13 @@ function loadBridgeWithAuthRetryMocks(t, options = {}) {
|
||||
return { bridge, MockSSHClient };
|
||||
}
|
||||
|
||||
function createParsedPrivateKey() {
|
||||
return {
|
||||
isPrivateKey: () => true,
|
||||
getPrivatePEM: () => TEST_PRIVATE_KEY,
|
||||
};
|
||||
}
|
||||
|
||||
test("terminal SSH supports consecutive keyboard-interactive factors (#2150)", async (t) => {
|
||||
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
|
||||
connectEvents: ["repeated-keyboard-interactive"],
|
||||
@@ -1028,7 +1042,7 @@ test("terminal SSH keeps keyboard-interactive eligible after password rejection"
|
||||
test("terminal SSH prefers keyboard-interactive after publickey partial success", async (t) => {
|
||||
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
|
||||
connectEvents: ["publickey-then-password-and-keyboard-interactive"],
|
||||
parseKeyResult: {},
|
||||
parseKeyResult: createParsedPrivateKey(),
|
||||
});
|
||||
const ipcMain = makeIpcMain();
|
||||
bridge.init({ sessions: new Map(), electronModule: {} });
|
||||
@@ -1056,7 +1070,7 @@ test("terminal SSH prefers keyboard-interactive after publickey partial success"
|
||||
hostname: "corp-edr.example.com",
|
||||
username: "alice",
|
||||
authMethod: "key",
|
||||
privateKey: "INLINE_PRIVATE_KEY",
|
||||
privateKey: TEST_PRIVATE_KEY,
|
||||
password: "login-password",
|
||||
useSshAgent: false,
|
||||
port: 22,
|
||||
@@ -1080,7 +1094,7 @@ test("terminal SSH prefers keyboard-interactive after publickey partial success"
|
||||
test("terminal SSH certificate auth prefers keyboard-interactive after agent partial success after partial success", async (t) => {
|
||||
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
|
||||
connectEvents: ["agent-then-password-and-keyboard-interactive"],
|
||||
parseKeyResult: {},
|
||||
parseKeyResult: createParsedPrivateKey(),
|
||||
});
|
||||
const ipcMain = makeIpcMain();
|
||||
bridge.init({ sessions: new Map(), electronModule: {} });
|
||||
@@ -1109,7 +1123,7 @@ test("terminal SSH certificate auth prefers keyboard-interactive after agent par
|
||||
username: "alice",
|
||||
authMethod: "certificate",
|
||||
certificate: "ssh-rsa-cert-v01@openssh.com AAAA test-cert",
|
||||
privateKey: "INLINE_PRIVATE_KEY",
|
||||
privateKey: TEST_PRIVATE_KEY,
|
||||
password: "login-password",
|
||||
useSshAgent: false,
|
||||
port: 22,
|
||||
@@ -1131,7 +1145,7 @@ test("terminal SSH certificate auth prefers keyboard-interactive after agent par
|
||||
test("terminal SSH certificate requiresMfa prefers keyboard-interactive after a rejected certificate", async (t) => {
|
||||
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
|
||||
connectEvents: ["agent-then-mfa-keyboard-interactive-before-password"],
|
||||
parseKeyResult: {},
|
||||
parseKeyResult: createParsedPrivateKey(),
|
||||
});
|
||||
const ipcMain = makeIpcMain();
|
||||
bridge.init({ sessions: new Map(), electronModule: {} });
|
||||
@@ -1147,7 +1161,7 @@ test("terminal SSH certificate requiresMfa prefers keyboard-interactive after a
|
||||
authMethod: "certificate",
|
||||
requiresMfa: true,
|
||||
certificate: "ssh-rsa-cert-v01@openssh.com AAAA test-cert",
|
||||
privateKey: "INLINE_PRIVATE_KEY",
|
||||
privateKey: TEST_PRIVATE_KEY,
|
||||
password: "login-password",
|
||||
useSshAgent: false,
|
||||
port: 22,
|
||||
@@ -1172,7 +1186,7 @@ test("terminal SSH certificate auth retries keyboard-interactive when password r
|
||||
"agent-password-removes-keyboard-interactive",
|
||||
"agent-then-keyboard-interactive-after-skip-password",
|
||||
],
|
||||
parseKeyResult: {},
|
||||
parseKeyResult: createParsedPrivateKey(),
|
||||
});
|
||||
const ipcMain = makeIpcMain();
|
||||
bridge.init({ sessions: new Map(), electronModule: {} });
|
||||
@@ -1201,7 +1215,7 @@ test("terminal SSH certificate auth retries keyboard-interactive when password r
|
||||
username: "alice",
|
||||
authMethod: "certificate",
|
||||
certificate: "ssh-rsa-cert-v01@openssh.com AAAA test-cert",
|
||||
privateKey: "INLINE_PRIVATE_KEY",
|
||||
privateKey: TEST_PRIVATE_KEY,
|
||||
password: "login-password",
|
||||
useSshAgent: false,
|
||||
port: 22,
|
||||
|
||||
+3
-3
@@ -1,14 +1,14 @@
|
||||
{
|
||||
version = "1.1.81";
|
||||
version = "1.1.82";
|
||||
|
||||
sources = {
|
||||
x86_64-linux = {
|
||||
appImageArch = "x86_64";
|
||||
hash = "sha256-WkHgVhYfmqM24Zk37tAAa3uqUPtvh7jfN2nV3SQ+ong=";
|
||||
hash = "sha256-cTmVXxZFSfsWgjY/7ZZ7aYCyvXrlbqe6DFki+Wcfpqg=";
|
||||
};
|
||||
aarch64-linux = {
|
||||
appImageArch = "arm64";
|
||||
hash = "sha256-TMRFLvD0hW7rLtdg4ktMoV7hLv+4vHkucCTnvv61axQ=";
|
||||
hash = "sha256-k6abS+JCVkf6Sj4+gAGsI8k5hiYJ3gZJHifsuIcI7/8=";
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user