fix(terminal): stabilize Android mobile keyboard input (#325)
* fix(terminal): stabilize android mobile keyboard input * fix(terminal): add disableStdin option to ghostty config * refactor(terminal): remove duplicated mobile input handlers
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web';
|
import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web';
|
||||||
|
|
||||||
import type { TerminalTheme } from '@/lib/terminalTheme';
|
import type { TerminalTheme } from '@/lib/terminalTheme';
|
||||||
@@ -84,35 +85,83 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
const viewportDiscoveryTimeoutRef = React.useRef<number | null>(null);
|
const viewportDiscoveryTimeoutRef = React.useRef<number | null>(null);
|
||||||
const viewportDiscoveryAttemptsRef = React.useRef(0);
|
const viewportDiscoveryAttemptsRef = React.useRef(0);
|
||||||
const hiddenInputRef = React.useRef<HTMLTextAreaElement | null>(null);
|
const hiddenInputRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const textInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
|
const isComposingRef = React.useRef(false);
|
||||||
|
const ignoreNextInputRef = React.useRef(false);
|
||||||
|
const lastBeforeInputRef = React.useRef<{ type: string; at: number } | null>(null);
|
||||||
|
const lastInputEventAtRef = React.useRef<number | null>(null);
|
||||||
|
const refocusTimeoutRef = React.useRef<number | null>(null);
|
||||||
|
const keydownProbeTimeoutRef = React.useRef<number | null>(null);
|
||||||
|
const lastObservedValueRef = React.useRef('');
|
||||||
const [, forceRender] = React.useReducer((x) => x + 1, 0);
|
const [, forceRender] = React.useReducer((x) => x + 1, 0);
|
||||||
const [terminalReadyVersion, bumpTerminalReady] = React.useReducer((x) => x + 1, 0);
|
const [terminalReadyVersion, bumpTerminalReady] = React.useReducer((x) => x + 1, 0);
|
||||||
|
|
||||||
inputHandlerRef.current = onInput;
|
inputHandlerRef.current = onInput;
|
||||||
resizeHandlerRef.current = onResize;
|
resizeHandlerRef.current = onResize;
|
||||||
|
|
||||||
|
const disableTerminalTextareas = React.useCallback(() => {
|
||||||
|
if (!enableTouchScroll) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const container = containerRef.current;
|
||||||
|
const hiddenInput = hiddenInputRef.current;
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const textareas = Array.from(container.querySelectorAll('textarea')) as HTMLTextAreaElement[];
|
||||||
|
textareas.forEach((textarea) => {
|
||||||
|
if (textarea === hiddenInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (textarea.getAttribute('data-terminal-disabled-input') === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
textarea.setAttribute('data-terminal-disabled-input', 'true');
|
||||||
|
textarea.setAttribute('aria-hidden', 'true');
|
||||||
|
textarea.tabIndex = -1;
|
||||||
|
textarea.disabled = true;
|
||||||
|
textarea.style.position = 'absolute';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
textarea.style.width = '0px';
|
||||||
|
textarea.style.height = '0px';
|
||||||
|
textarea.style.pointerEvents = 'none';
|
||||||
|
textarea.style.zIndex = '-1';
|
||||||
|
});
|
||||||
|
}, [enableTouchScroll]);
|
||||||
|
|
||||||
|
const isAndroid = typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent);
|
||||||
|
const useTextInput = enableTouchScroll && isAndroid;
|
||||||
|
|
||||||
const focusHiddenInput = React.useCallback((clientX?: number, clientY?: number) => {
|
const focusHiddenInput = React.useCallback((clientX?: number, clientY?: number) => {
|
||||||
const input = hiddenInputRef.current;
|
const input = (useTextInput ? textInputRef.current : hiddenInputRef.current) as HTMLElement | null;
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!input || !container) {
|
if (!input || !container) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position the input near the user's tap/cursor so the global keyboard
|
|
||||||
// avoidance logic can decide whether anything is actually obscured.
|
|
||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
|
const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : rect.width;
|
||||||
|
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : rect.height;
|
||||||
const fallbackX = rect.left + rect.width / 2;
|
const fallbackX = rect.left + rect.width / 2;
|
||||||
const fallbackY = rect.top + rect.height - 12;
|
const fallbackY = rect.top + rect.height - 12;
|
||||||
const x = typeof clientX === 'number' ? clientX : fallbackX;
|
const x = typeof clientX === 'number' ? clientX : fallbackX;
|
||||||
const y = typeof clientY === 'number' ? clientY : fallbackY;
|
const y = typeof clientY === 'number' ? clientY : fallbackY;
|
||||||
|
|
||||||
const padding = 8;
|
const padding = 8;
|
||||||
const left = Math.max(padding, Math.min(rect.width - padding, x - rect.left));
|
const left = Math.max(padding, Math.min(viewportWidth - padding, x));
|
||||||
const top = Math.max(padding, Math.min(rect.height - padding, y - rect.top));
|
const top = Math.max(padding, Math.min(viewportHeight - padding, y));
|
||||||
|
|
||||||
input.style.left = `${left}px`;
|
input.style.left = `${left}px`;
|
||||||
input.style.top = `${top}px`;
|
input.style.top = `${top}px`;
|
||||||
input.style.bottom = '';
|
input.style.bottom = '';
|
||||||
|
|
||||||
|
if (input instanceof HTMLTextAreaElement || input instanceof HTMLInputElement) {
|
||||||
|
input.disabled = false;
|
||||||
|
input.readOnly = false;
|
||||||
|
input.removeAttribute('disabled');
|
||||||
|
input.removeAttribute('readonly');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
input.focus({ preventScroll: true });
|
input.focus({ preventScroll: true });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -120,8 +169,94 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
input.focus();
|
input.focus();
|
||||||
} catch { /* ignored */ }
|
} catch { /* ignored */ }
|
||||||
}
|
}
|
||||||
|
}, [useTextInput]);
|
||||||
|
|
||||||
|
const readEditableValue = React.useCallback((target: HTMLElement) => {
|
||||||
|
if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) {
|
||||||
|
return target.value;
|
||||||
|
}
|
||||||
|
return target.textContent ?? '';
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const clearEditableValue = React.useCallback((target: HTMLElement) => {
|
||||||
|
if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) {
|
||||||
|
target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.textContent = '';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scheduleKeyProbe = React.useCallback((target: HTMLElement) => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (useTextInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keydownProbeTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(keydownProbeTimeoutRef.current);
|
||||||
|
keydownProbeTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let attempt = 0;
|
||||||
|
const maxAttempts = 3;
|
||||||
|
|
||||||
|
const runProbe = () => {
|
||||||
|
keydownProbeTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
keydownProbeTimeoutRef.current = null;
|
||||||
|
const value = readEditableValue(target);
|
||||||
|
if (!value) {
|
||||||
|
attempt += 1;
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
runProbe();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previous = lastObservedValueRef.current;
|
||||||
|
lastObservedValueRef.current = value;
|
||||||
|
const delta = value.startsWith(previous) ? value.slice(previous.length) : value;
|
||||||
|
if (delta) {
|
||||||
|
inputHandlerRef.current(delta.replace(/\r\n|\r|\n/g, '\r'));
|
||||||
|
}
|
||||||
|
clearEditableValue(target);
|
||||||
|
lastObservedValueRef.current = '';
|
||||||
|
}, attempt === 0 ? 0 : 24);
|
||||||
|
};
|
||||||
|
|
||||||
|
runProbe();
|
||||||
|
}, [clearEditableValue, readEditableValue, useTextInput]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!enableTouchScroll || !container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleContainerFocusIn = (event: FocusEvent) => {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target.getAttribute('data-terminal-hidden-input') === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!container.contains(target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
target.blur();
|
||||||
|
} catch { /* ignored */ }
|
||||||
|
focusHiddenInput();
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener('focusin', handleContainerFocusIn, true);
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener('focusin', handleContainerFocusIn, true);
|
||||||
|
};
|
||||||
|
}, [enableTouchScroll, focusHiddenInput]);
|
||||||
|
|
||||||
const copySelectionToClipboard = React.useCallback(async () => {
|
const copySelectionToClipboard = React.useCallback(async () => {
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -134,6 +269,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) {
|
if (!container) {
|
||||||
return;
|
return;
|
||||||
@@ -147,13 +283,14 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (navigator.clipboard?.writeText) {
|
||||||
if (navigator.clipboard?.writeText) {
|
const copied = await navigator.clipboard
|
||||||
await navigator.clipboard.writeText(text);
|
.writeText(text)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
if (copied) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// fall through to execCommand
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -167,7 +304,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
document.body.removeChild(textarea);
|
document.body.removeChild(textarea);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
return;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -657,6 +794,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
let disposed = false;
|
let disposed = false;
|
||||||
let localTerminal: GhosttyTerminal | null = null;
|
let localTerminal: GhosttyTerminal | null = null;
|
||||||
let localResizeObserver: ResizeObserver | null = null;
|
let localResizeObserver: ResizeObserver | null = null;
|
||||||
|
let localTextareaObserver: MutationObserver | null = null;
|
||||||
let localDisposables: Array<{ dispose: () => void }> = [];
|
let localDisposables: Array<{ dispose: () => void }> = [];
|
||||||
|
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@@ -664,7 +802,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.tabIndex = 0;
|
container.tabIndex = enableTouchScroll ? -1 : 0;
|
||||||
|
|
||||||
const initialize = async () => {
|
const initialize = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -673,7 +811,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty);
|
const options = getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, Boolean(enableTouchScroll));
|
||||||
|
|
||||||
const terminal = new GhosttyTerminal(options);
|
const terminal = new GhosttyTerminal(options);
|
||||||
|
|
||||||
@@ -687,6 +825,15 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
terminal.open(container);
|
terminal.open(container);
|
||||||
bumpTerminalReady();
|
bumpTerminalReady();
|
||||||
|
|
||||||
|
disableTerminalTextareas();
|
||||||
|
|
||||||
|
if (enableTouchScroll && typeof MutationObserver !== 'undefined') {
|
||||||
|
localTextareaObserver = new MutationObserver(() => {
|
||||||
|
disableTerminalTextareas();
|
||||||
|
});
|
||||||
|
localTextareaObserver.observe(container, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
const viewport = findScrollableViewport(container);
|
const viewport = findScrollableViewport(container);
|
||||||
if (viewport) {
|
if (viewport) {
|
||||||
viewport.classList.add('overlay-scrollbar-target', 'overlay-scrollbar-container');
|
viewport.classList.add('overlay-scrollbar-target', 'overlay-scrollbar-container');
|
||||||
@@ -698,7 +845,9 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
|
|
||||||
fitTerminal();
|
fitTerminal();
|
||||||
setupTouchScroll();
|
setupTouchScroll();
|
||||||
terminal.focus();
|
if (!enableTouchScroll) {
|
||||||
|
terminal.focus();
|
||||||
|
}
|
||||||
|
|
||||||
localDisposables = [
|
localDisposables = [
|
||||||
terminal.onData((data: string) => {
|
terminal.onData((data: string) => {
|
||||||
@@ -730,6 +879,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
|
|
||||||
localDisposables.forEach((disposable) => disposable.dispose());
|
localDisposables.forEach((disposable) => disposable.dispose());
|
||||||
localResizeObserver?.disconnect();
|
localResizeObserver?.disconnect();
|
||||||
|
localTextareaObserver?.disconnect();
|
||||||
|
|
||||||
localTerminal?.dispose();
|
localTerminal?.dispose();
|
||||||
terminalRef.current = null;
|
terminalRef.current = null;
|
||||||
@@ -738,7 +888,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
lastReportedSizeRef.current = null;
|
lastReportedSizeRef.current = null;
|
||||||
resetWriteState();
|
resetWriteState();
|
||||||
};
|
};
|
||||||
}, [fitTerminal, fontFamily, fontSize, setupTouchScroll, theme, resetWriteState]);
|
}, [disableTerminalTextareas, enableTouchScroll, fitTerminal, fontFamily, fontSize, setupTouchScroll, theme, resetWriteState]);
|
||||||
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -750,8 +900,10 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
resetWriteState();
|
resetWriteState();
|
||||||
lastReportedSizeRef.current = null;
|
lastReportedSizeRef.current = null;
|
||||||
fitTerminal();
|
fitTerminal();
|
||||||
terminal.focus();
|
if (!enableTouchScroll) {
|
||||||
}, [sessionKey, terminalReadyVersion, fitTerminal, resetWriteState]);
|
terminal.focus();
|
||||||
|
}
|
||||||
|
}, [enableTouchScroll, sessionKey, terminalReadyVersion, fitTerminal, resetWriteState]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setupTouchScroll();
|
setupTouchScroll();
|
||||||
@@ -819,6 +971,266 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
[enableTouchScroll, focusHiddenInput, fitTerminal, resetWriteState]
|
[enableTouchScroll, focusHiddenInput, fitTerminal, resetWriteState]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleHiddenInputBlur = React.useCallback(
|
||||||
|
(event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
if (!enableTouchScroll) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const related = event.relatedTarget as HTMLElement | null;
|
||||||
|
const relatedTag = related?.tagName;
|
||||||
|
const isInput = relatedTag === 'INPUT' || relatedTag === 'TEXTAREA' || related?.isContentEditable;
|
||||||
|
const isHiddenInput = related?.getAttribute('data-terminal-hidden-input') === 'true';
|
||||||
|
if (isInput && !isHiddenInput) {
|
||||||
|
if (refocusTimeoutRef.current !== null && typeof window !== 'undefined') {
|
||||||
|
window.clearTimeout(refocusTimeoutRef.current);
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
refocusTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
refocusTimeoutRef.current = null;
|
||||||
|
focusHiddenInput();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[enableTouchScroll, focusHiddenInput]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHiddenBeforeInput = React.useCallback(
|
||||||
|
(event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
const nativeEvent = event.nativeEvent as InputEvent | undefined;
|
||||||
|
const inputType = nativeEvent?.inputType ?? '';
|
||||||
|
const data = typeof nativeEvent?.data === 'string' ? nativeEvent.data : '';
|
||||||
|
|
||||||
|
lastInputEventAtRef.current = typeof performance !== 'undefined'
|
||||||
|
? performance.now()
|
||||||
|
: Date.now();
|
||||||
|
|
||||||
|
if (inputType === 'insertCompositionText') {
|
||||||
|
isComposingRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inputType && data) {
|
||||||
|
if (isComposingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
inputHandlerRef.current(data);
|
||||||
|
lastBeforeInputRef.current = {
|
||||||
|
type: 'insertText',
|
||||||
|
at: typeof performance !== 'undefined' ? performance.now() : Date.now(),
|
||||||
|
};
|
||||||
|
ignoreNextInputRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputType === 'insertText' && data) {
|
||||||
|
if (isComposingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
inputHandlerRef.current(data);
|
||||||
|
lastBeforeInputRef.current = {
|
||||||
|
type: inputType,
|
||||||
|
at: typeof performance !== 'undefined' ? performance.now() : Date.now(),
|
||||||
|
};
|
||||||
|
ignoreNextInputRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputType === 'insertLineBreak') {
|
||||||
|
if (isComposingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
inputHandlerRef.current('\r');
|
||||||
|
lastBeforeInputRef.current = {
|
||||||
|
type: inputType,
|
||||||
|
at: typeof performance !== 'undefined' ? performance.now() : Date.now(),
|
||||||
|
};
|
||||||
|
ignoreNextInputRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputType === 'deleteContentBackward') {
|
||||||
|
if (isComposingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
inputHandlerRef.current('\x7f');
|
||||||
|
lastBeforeInputRef.current = {
|
||||||
|
type: inputType,
|
||||||
|
at: typeof performance !== 'undefined' ? performance.now() : Date.now(),
|
||||||
|
};
|
||||||
|
ignoreNextInputRef.current = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHiddenInput = React.useCallback(
|
||||||
|
(event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
const target = event.currentTarget as HTMLElement;
|
||||||
|
lastInputEventAtRef.current = typeof performance !== 'undefined'
|
||||||
|
? performance.now()
|
||||||
|
: Date.now();
|
||||||
|
if (isComposingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ignoreNextInputRef.current) {
|
||||||
|
const lastBeforeInput = lastBeforeInputRef.current;
|
||||||
|
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||||
|
if (lastBeforeInput && now - lastBeforeInput.at < 50) {
|
||||||
|
ignoreNextInputRef.current = false;
|
||||||
|
clearEditableValue(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ignoreNextInputRef.current = false;
|
||||||
|
}
|
||||||
|
const raw = readEditableValue(target);
|
||||||
|
if (!raw) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastObservedValueRef.current = raw;
|
||||||
|
|
||||||
|
const value = raw.replace(/\r\n|\r|\n/g, '\r');
|
||||||
|
inputHandlerRef.current(value);
|
||||||
|
clearEditableValue(target);
|
||||||
|
lastObservedValueRef.current = '';
|
||||||
|
},
|
||||||
|
[clearEditableValue, readEditableValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHiddenKeyDown = React.useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const target = event.currentTarget as HTMLElement;
|
||||||
|
const nativeEvent = event.nativeEvent as KeyboardEvent | undefined;
|
||||||
|
if (nativeEvent?.isComposing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lastBeforeInput = lastBeforeInputRef.current;
|
||||||
|
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||||
|
const recent = Boolean(lastBeforeInput && now - lastBeforeInput.at < 50);
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
if (recent && lastBeforeInput?.type === 'insertLineBreak') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
inputHandlerRef.current('\r');
|
||||||
|
clearEditableValue(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Backspace') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (recent && lastBeforeInput?.type === 'deleteContentBackward') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!readEditableValue(target)) {
|
||||||
|
inputHandlerRef.current('\x7f');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||||
|
const lastInputAt = lastInputEventAtRef.current;
|
||||||
|
const sawInputRecently = Boolean(lastInputAt && now - lastInputAt < 50);
|
||||||
|
if (!sawInputRecently) {
|
||||||
|
event.preventDefault();
|
||||||
|
inputHandlerRef.current(event.key);
|
||||||
|
ignoreNextInputRef.current = true;
|
||||||
|
lastBeforeInputRef.current = {
|
||||||
|
type: 'keydown-text',
|
||||||
|
at: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleKeyProbe(target);
|
||||||
|
},
|
||||||
|
[clearEditableValue, readEditableValue, scheduleKeyProbe]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHiddenKeyUp = React.useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const target = event.currentTarget as HTMLElement;
|
||||||
|
const nativeEvent = event.nativeEvent as KeyboardEvent | undefined;
|
||||||
|
if (nativeEvent?.isComposing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduleKeyProbe(target);
|
||||||
|
},
|
||||||
|
[scheduleKeyProbe]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHiddenCompositionEnd = React.useCallback(
|
||||||
|
(event: React.CompositionEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
const target = event.currentTarget as HTMLElement;
|
||||||
|
isComposingRef.current = false;
|
||||||
|
const data = event.data || readEditableValue(target);
|
||||||
|
lastInputEventAtRef.current = typeof performance !== 'undefined'
|
||||||
|
? performance.now()
|
||||||
|
: Date.now();
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = data.replace(/\r\n|\r|\n/g, '\r');
|
||||||
|
inputHandlerRef.current(value);
|
||||||
|
clearEditableValue(target);
|
||||||
|
lastBeforeInputRef.current = {
|
||||||
|
type: 'compositionend',
|
||||||
|
at: typeof performance !== 'undefined' ? performance.now() : Date.now(),
|
||||||
|
};
|
||||||
|
ignoreNextInputRef.current = true;
|
||||||
|
},
|
||||||
|
[clearEditableValue, readEditableValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHiddenPaste = React.useCallback(
|
||||||
|
(event: React.ClipboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const text = event.clipboardData?.getData('text') ?? '';
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const terminal = terminalRef.current;
|
||||||
|
const payload = terminal?.hasBracketedPaste?.()
|
||||||
|
? `\x1b[200~${text}\x1b[201~`
|
||||||
|
: text;
|
||||||
|
inputHandlerRef.current(payload);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hiddenInputStyle: React.CSSProperties = {
|
||||||
|
position: 'fixed',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
opacity: 0,
|
||||||
|
zIndex: -1,
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'transparent',
|
||||||
|
caretColor: 'transparent',
|
||||||
|
resize: 'none',
|
||||||
|
overflow: 'hidden',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
border: '0',
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
outline: 'none',
|
||||||
|
outlineOffset: 0,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 400,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
WebkitUserSelect: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
@@ -838,121 +1250,76 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
void copySelectionToClipboard();
|
if (!enableTouchScroll) {
|
||||||
|
void copySelectionToClipboard();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onTouchEnd={() => {
|
onTouchEnd={() => {
|
||||||
void copySelectionToClipboard();
|
if (!enableTouchScroll) {
|
||||||
|
void copySelectionToClipboard();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{enableTouchScroll ? (
|
{enableTouchScroll && typeof document !== 'undefined'
|
||||||
<textarea
|
? createPortal(
|
||||||
ref={hiddenInputRef}
|
<>
|
||||||
inputMode="text"
|
<input
|
||||||
autoCapitalize="off"
|
ref={textInputRef}
|
||||||
autoComplete="off"
|
type="text"
|
||||||
autoCorrect="off"
|
inputMode="text"
|
||||||
spellCheck={false}
|
autoCapitalize="off"
|
||||||
tabIndex={-1}
|
autoComplete="off"
|
||||||
enterKeyHint="send"
|
autoCorrect="off"
|
||||||
style={{
|
spellCheck={false}
|
||||||
position: 'absolute',
|
tabIndex={0}
|
||||||
left: 0,
|
enterKeyHint="send"
|
||||||
top: 0,
|
data-terminal-hidden-input="true"
|
||||||
// Android IME needs visible, larger dimensions to properly attach
|
placeholder="Terminal input"
|
||||||
width: 30,
|
style={{
|
||||||
height: 30,
|
...hiddenInputStyle,
|
||||||
// Must be slightly visible for Android IME - clip to hide visually
|
display: useTextInput ? 'block' : 'none',
|
||||||
opacity: 0.011,
|
}}
|
||||||
zIndex: 10,
|
onBlur={handleHiddenInputBlur}
|
||||||
background: 'transparent',
|
onBeforeInput={handleHiddenBeforeInput}
|
||||||
color: 'transparent',
|
onCompositionStart={() => {
|
||||||
caretColor: 'transparent',
|
isComposingRef.current = true;
|
||||||
resize: 'none',
|
}}
|
||||||
overflow: 'hidden',
|
onInput={handleHiddenInput}
|
||||||
whiteSpace: 'nowrap',
|
onKeyDown={handleHiddenKeyDown}
|
||||||
border: 'none',
|
onKeyUp={handleHiddenKeyUp}
|
||||||
padding: 0,
|
onCompositionEnd={handleHiddenCompositionEnd}
|
||||||
margin: 0,
|
onPaste={handleHiddenPaste}
|
||||||
outline: 'none',
|
/>
|
||||||
// Prevent iOS zoom on focus - must be 16px+
|
<textarea
|
||||||
fontSize: 16,
|
ref={hiddenInputRef}
|
||||||
// Ensure pointer events work
|
inputMode="text"
|
||||||
pointerEvents: 'auto',
|
autoCapitalize="off"
|
||||||
// Android needs these for IME to work properly
|
autoComplete="off"
|
||||||
WebkitUserSelect: 'text',
|
autoCorrect="off"
|
||||||
userSelect: 'text',
|
spellCheck={false}
|
||||||
// Transform off-screen but keep focusable
|
tabIndex={0}
|
||||||
transform: 'translateX(-9999px)',
|
enterKeyHint="send"
|
||||||
transformOrigin: 'left top',
|
data-terminal-hidden-input="true"
|
||||||
}}
|
placeholder="Terminal input"
|
||||||
onFocus={(event) => {
|
style={{
|
||||||
// Move back on screen when focused (for Android IME)
|
...hiddenInputStyle,
|
||||||
event.currentTarget.style.transform = 'translateX(0)';
|
display: useTextInput ? 'none' : 'block',
|
||||||
}}
|
}}
|
||||||
onBlur={(event) => {
|
onBlur={handleHiddenInputBlur}
|
||||||
// Move off screen when blurred
|
onBeforeInput={handleHiddenBeforeInput}
|
||||||
event.currentTarget.style.transform = 'translateX(-9999px)';
|
onCompositionStart={() => {
|
||||||
}}
|
isComposingRef.current = true;
|
||||||
onBeforeInput={(event) => {
|
}}
|
||||||
const nativeEvent = event.nativeEvent as unknown as InputEvent | undefined;
|
onInput={handleHiddenInput}
|
||||||
const inputType = nativeEvent?.inputType ?? '';
|
onKeyDown={handleHiddenKeyDown}
|
||||||
const data = typeof nativeEvent?.data === 'string' ? nativeEvent.data : '';
|
onKeyUp={handleHiddenKeyUp}
|
||||||
|
onCompositionEnd={handleHiddenCompositionEnd}
|
||||||
// Handle insertText (iOS) and insertCompositionText (Android Gboard)
|
onPaste={handleHiddenPaste}
|
||||||
if ((inputType === 'insertText' || inputType === 'insertCompositionText') && data) {
|
/>
|
||||||
event.preventDefault();
|
</>,
|
||||||
inputHandlerRef.current(data);
|
document.body
|
||||||
return;
|
)
|
||||||
}
|
: null}
|
||||||
|
|
||||||
if (inputType === 'insertLineBreak') {
|
|
||||||
event.preventDefault();
|
|
||||||
inputHandlerRef.current('\r');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inputType === 'deleteContentBackward') {
|
|
||||||
event.preventDefault();
|
|
||||||
inputHandlerRef.current('\x7f');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onInput={(event) => {
|
|
||||||
// Fallback: capture any text that makes it through to the input value
|
|
||||||
const raw = String(event.currentTarget.value || '');
|
|
||||||
if (!raw) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Some mobile keyboards insert `\n` for Enter; PTY expects CR.
|
|
||||||
const value = raw.replace(/\r\n|\r|\n/g, '\r');
|
|
||||||
inputHandlerRef.current(value);
|
|
||||||
event.currentTarget.value = '';
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
// Handle Enter key explicitly for Android
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
inputHandlerRef.current('\r');
|
|
||||||
event.currentTarget.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key === 'Backspace') {
|
|
||||||
// If there's nothing in the input buffer, emulate DEL.
|
|
||||||
if (!event.currentTarget.value) {
|
|
||||||
inputHandlerRef.current('\x7f');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onCompositionEnd={(event) => {
|
|
||||||
// Android IME sends final text via composition events
|
|
||||||
const data = event.data;
|
|
||||||
if (data) {
|
|
||||||
inputHandlerRef.current(data);
|
|
||||||
event.currentTarget.value = '';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{viewportRef.current && !enableTouchScroll ? (
|
{viewportRef.current && !enableTouchScroll ? (
|
||||||
<OverlayScrollbar
|
<OverlayScrollbar
|
||||||
containerRef={viewportRef}
|
containerRef={viewportRef}
|
||||||
|
|||||||
@@ -237,11 +237,6 @@ export const TerminalView: React.FC = () => {
|
|||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'connected': {
|
case 'connected': {
|
||||||
if (event.runtime || event.ptyBackend) {
|
|
||||||
console.log(
|
|
||||||
`[Terminal] connected runtime=${event.runtime ?? 'unknown'} pty=${event.ptyBackend ?? 'unknown'}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setConnecting(directory, tabId, false);
|
setConnecting(directory, tabId, false);
|
||||||
setConnectionError(null);
|
setConnectionError(null);
|
||||||
setIsFatalError(false);
|
setIsFatalError(false);
|
||||||
|
|||||||
@@ -98,7 +98,8 @@ export function getGhosttyTerminalOptions(
|
|||||||
fontFamily: string,
|
fontFamily: string,
|
||||||
fontSize: number,
|
fontSize: number,
|
||||||
theme: TerminalTheme,
|
theme: TerminalTheme,
|
||||||
ghostty: Ghostty
|
ghostty: Ghostty,
|
||||||
|
disableStdin = false
|
||||||
) {
|
) {
|
||||||
const powerlineFallbacks =
|
const powerlineFallbacks =
|
||||||
'"JetBrainsMonoNL Nerd Font", "FiraCode Nerd Font", "Cascadia Code PL", "Fira Code", "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", "Courier New", monospace';
|
'"JetBrainsMonoNL Nerd Font", "FiraCode Nerd Font", "Cascadia Code PL", "Fira Code", "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", "Courier New", monospace';
|
||||||
@@ -136,5 +137,6 @@ export function getGhosttyTerminalOptions(
|
|||||||
},
|
},
|
||||||
scrollback: 50_000,
|
scrollback: 50_000,
|
||||||
ghostty,
|
ghostty,
|
||||||
|
disableStdin,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user