Fix(mobile) terminal replay, reset artifacts, and preview detection (#1383)
* fix terminal rendering and preview detection * Fix bot comments * fix: protect terminal preview URL probe --------- Co-authored-by: Konstantin Zolin <zolin_ka@vk.com> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Konstantin Zolin
Bohdan Triapitsyn
parent
ca33c6ae57
commit
1d36995c47
@@ -17,6 +17,7 @@ import { Icon } from "@/components/icon/Icon";
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { primeTerminalInputTransport } from '@/lib/terminalApi';
|
||||
import { extractTerminalPreviewUrl, isTerminalPreviewUrlAvailable } from '@/lib/terminalPreview';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { PROJECT_ACTION_ICON_MAP, type ProjectActionIconKey } from '@/lib/projectActions';
|
||||
|
||||
@@ -113,6 +114,8 @@ export const TerminalView: React.FC = () => {
|
||||
const setTabLifecycle = useTerminalStore((s) => s.setTabLifecycle);
|
||||
const setConnecting = useTerminalStore((s) => s.setConnecting);
|
||||
const appendToBuffer = useTerminalStore((s) => s.appendToBuffer);
|
||||
const setTabPreviewUrl = useTerminalStore((s) => s.setTabPreviewUrl);
|
||||
const clearBuffer = useTerminalStore((s) => s.clearBuffer);
|
||||
|
||||
const openContextPreview = useUIStore((state) => state.openContextPreview);
|
||||
|
||||
@@ -162,6 +165,7 @@ export const TerminalView: React.FC = () => {
|
||||
const [isReconnectPending, setIsReconnectPending] = React.useState(false);
|
||||
const [activeModifier, setActiveModifier] = React.useState<Modifier | null>(null);
|
||||
const [isRestarting, setIsRestarting] = React.useState(false);
|
||||
const [viewportSizeVersion, setViewportSizeVersion] = React.useState(0);
|
||||
|
||||
const streamCleanupRef = React.useRef<(() => void) | null>(null);
|
||||
const activeTerminalIdRef = React.useRef<string | null>(null);
|
||||
@@ -174,6 +178,15 @@ export const TerminalView: React.FC = () => {
|
||||
const nudgeOnConnectTerminalIdRef = React.useRef<string | null>(null);
|
||||
const rehydratedTerminalIdsRef = React.useRef<Set<string>>(new Set());
|
||||
const rehydratedSnapshotTakenRef = React.useRef(false);
|
||||
const previewScanTailRef = React.useRef('');
|
||||
const pendingPreviewProbeUrlsRef = React.useRef<Set<string>>(new Set());
|
||||
const previewProbeGenerationRef = React.useRef(0);
|
||||
|
||||
const resetTerminalPreviewScan = React.useCallback(() => {
|
||||
previewScanTailRef.current = '';
|
||||
pendingPreviewProbeUrlsRef.current.clear();
|
||||
previewProbeGenerationRef.current += 1;
|
||||
}, []);
|
||||
|
||||
const focusTerminalWhenWindowActive = React.useCallback(() => {
|
||||
if (useTouchTerminalInput) {
|
||||
@@ -245,7 +258,8 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
React.useEffect(() => {
|
||||
activeTabIdRef.current = activeTabId;
|
||||
}, [activeTabId]);
|
||||
resetTerminalPreviewScan();
|
||||
}, [activeTabId, resetTerminalPreviewScan]);
|
||||
|
||||
React.useEffect(() => {
|
||||
directoryRef.current = effectiveDirectory;
|
||||
@@ -278,6 +292,47 @@ export const TerminalView: React.FC = () => {
|
||||
[disconnectStream]
|
||||
);
|
||||
|
||||
const scanTerminalPreviewOutput = React.useCallback(
|
||||
(directory: string, tabId: string, data: string) => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const combined = `${previewScanTailRef.current}${data}`.replace(/\r\n|\r/g, '\n');
|
||||
const lines = combined.split('\n');
|
||||
const completeText = combined.endsWith('\n')
|
||||
? lines.join('\n')
|
||||
: lines.slice(0, -1).join('\n');
|
||||
previewScanTailRef.current = combined.endsWith('\n') ? '' : (lines[lines.length - 1] ?? '').slice(-1024);
|
||||
|
||||
if (!completeText) {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = extractTerminalPreviewUrl(completeText);
|
||||
if (!candidate || pendingPreviewProbeUrlsRef.current.has(candidate)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const probeGeneration = previewProbeGenerationRef.current;
|
||||
pendingPreviewProbeUrlsRef.current.add(candidate);
|
||||
void isTerminalPreviewUrlAvailable(candidate).then((available) => {
|
||||
pendingPreviewProbeUrlsRef.current.delete(candidate);
|
||||
if (!available || previewProbeGenerationRef.current !== probeGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTab = useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((tab) => tab.id === tabId);
|
||||
if (!currentTab || currentTab.previewUrlLocked || currentTab.previewUrl === candidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTabPreviewUrl(directory, tabId, candidate, { locked: false, autoOpened: false });
|
||||
});
|
||||
},
|
||||
[setTabPreviewUrl]
|
||||
);
|
||||
|
||||
const startStream = React.useCallback(
|
||||
(
|
||||
directory: string,
|
||||
@@ -330,6 +385,7 @@ export const TerminalView: React.FC = () => {
|
||||
case 'data': {
|
||||
if (event.data) {
|
||||
appendToBuffer(directory, tabId, event.data);
|
||||
scanTerminalPreviewOutput(directory, tabId, event.data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -383,6 +439,7 @@ export const TerminalView: React.FC = () => {
|
||||
);
|
||||
setIsFatalError(true);
|
||||
setConnecting(directory, tabId, false);
|
||||
clearBuffer(directory, tabId);
|
||||
setTabLifecycle(directory, tabId, 'exited');
|
||||
setTabSessionId(directory, tabId, null);
|
||||
disconnectStream();
|
||||
@@ -398,8 +455,10 @@ export const TerminalView: React.FC = () => {
|
||||
},
|
||||
[
|
||||
appendToBuffer,
|
||||
clearBuffer,
|
||||
disconnectStream,
|
||||
focusTerminalWhenWindowActive,
|
||||
scanTerminalPreviewOutput,
|
||||
setConnecting,
|
||||
setTabLifecycle,
|
||||
setTabSessionId,
|
||||
@@ -469,12 +528,16 @@ export const TerminalView: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const size = lastViewportSizeRef.current;
|
||||
if (!size && isTerminalVisibleRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
setConnecting(directory, tabId, true);
|
||||
try {
|
||||
const size = lastViewportSizeRef.current;
|
||||
const session = await terminal.createSession({
|
||||
cwd: directory,
|
||||
cols: size?.cols,
|
||||
@@ -543,6 +606,7 @@ export const TerminalView: React.FC = () => {
|
||||
terminalLifecycle,
|
||||
activeTabId,
|
||||
hasOpenedTerminalViewport,
|
||||
viewportSizeVersion,
|
||||
enableTabs,
|
||||
terminalHydrated,
|
||||
ensureDirectory,
|
||||
@@ -590,6 +654,8 @@ export const TerminalView: React.FC = () => {
|
||||
setIsReconnectPending(false);
|
||||
|
||||
disconnectStream();
|
||||
clearBuffer(effectiveDirectory, tabId);
|
||||
resetTerminalPreviewScan();
|
||||
|
||||
try {
|
||||
await closeTab(effectiveDirectory, tabId);
|
||||
@@ -602,7 +668,7 @@ export const TerminalView: React.FC = () => {
|
||||
} finally {
|
||||
setIsRestarting(false);
|
||||
}
|
||||
}, [activeTabId, closeTab, disconnectStream, effectiveDirectory, enableTabs, isRestarting, t]);
|
||||
}, [activeTabId, clearBuffer, closeTab, disconnectStream, effectiveDirectory, enableTabs, isRestarting, resetTerminalPreviewScan, t]);
|
||||
|
||||
const handleHardRestart = React.useCallback(async () => {
|
||||
// Keep semantics: “close tab -> new clean tab”.
|
||||
@@ -692,7 +758,11 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
const handleViewportResize = React.useCallback(
|
||||
(cols: number, rows: number) => {
|
||||
lastViewportSizeRef.current = { cols, rows };
|
||||
const previous = lastViewportSizeRef.current;
|
||||
if (!previous || previous.cols !== cols || previous.rows !== rows) {
|
||||
lastViewportSizeRef.current = { cols, rows };
|
||||
setViewportSizeVersion((version) => version + 1);
|
||||
}
|
||||
if (!isTerminalVisibleRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -1112,6 +1182,7 @@ export const TerminalView: React.FC = () => {
|
||||
<div className="h-full w-full box-border pl-4 pr-1.5 pt-3 pb-4">
|
||||
{shouldRenderViewport ? (
|
||||
<TerminalViewport
|
||||
key={terminalViewportKey}
|
||||
ref={(controller) => {
|
||||
terminalControllerRef.current = controller;
|
||||
}}
|
||||
|
||||
@@ -48,7 +48,10 @@ type TerminalControlMessage = {
|
||||
t: string;
|
||||
s?: string;
|
||||
c?: string;
|
||||
d?: string;
|
||||
f?: boolean;
|
||||
i?: number;
|
||||
r?: number;
|
||||
v?: number;
|
||||
exitCode?: number;
|
||||
signal?: number | null;
|
||||
@@ -152,6 +155,7 @@ class TerminalTransportManager {
|
||||
private closed = false;
|
||||
private subscriptions = new Map<symbol, StreamSubscription>();
|
||||
private activeSubscriptionToken: symbol | null = null;
|
||||
private replayCursorBySession = new Map<string, number>();
|
||||
|
||||
configure(socketUrl: string): void {
|
||||
if (!socketUrl) {
|
||||
@@ -233,7 +237,7 @@ class TerminalTransportManager {
|
||||
try {
|
||||
if (this.boundSessionId !== sessionId) {
|
||||
this.requestedSessionId = sessionId;
|
||||
socket.send(encodeControlFrame({ t: 'b', s: sessionId, v: 2 }));
|
||||
socket.send(encodeControlFrame({ t: 'b', s: sessionId, r: this.replayCursorBySession.get(sessionId) ?? 0, v: 2 }));
|
||||
}
|
||||
socket.send(data);
|
||||
return true;
|
||||
@@ -265,6 +269,7 @@ class TerminalTransportManager {
|
||||
this.socketUrl = '';
|
||||
this.subscriptions.clear();
|
||||
this.activeSubscriptionToken = null;
|
||||
this.replayCursorBySession.clear();
|
||||
}
|
||||
|
||||
prime(): void {
|
||||
@@ -438,7 +443,7 @@ class TerminalTransportManager {
|
||||
this.requestedSessionId = activeSubscription.sessionId;
|
||||
|
||||
try {
|
||||
this.socket.send(encodeControlFrame({ t: 'b', s: activeSubscription.sessionId, v: 2 }));
|
||||
this.socket.send(encodeControlFrame({ t: 'b', s: activeSubscription.sessionId, r: this.replayCursorBySession.get(activeSubscription.sessionId) ?? 0, v: 2 }));
|
||||
} catch {
|
||||
this.handleSocketFailure(new Error('Terminal websocket bind failed'));
|
||||
}
|
||||
@@ -569,6 +574,21 @@ class TerminalTransportManager {
|
||||
return;
|
||||
case 'po':
|
||||
return;
|
||||
case 'd': {
|
||||
const sessionId = payload.s ?? this.boundSessionId ?? this.requestedSessionId;
|
||||
if (!activeSubscription || !sessionId || sessionId !== activeSubscription.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof payload.i === 'number' && Number.isFinite(payload.i)) {
|
||||
this.replayCursorBySession.set(sessionId, Math.max(this.replayCursorBySession.get(sessionId) ?? 0, Math.trunc(payload.i)));
|
||||
}
|
||||
|
||||
if (typeof payload.d === 'string' && payload.d.length > 0) {
|
||||
activeSubscription.onEvent({ type: 'data', data: payload.d });
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'bok': {
|
||||
this.boundSessionId = payload.s ?? this.requestedSessionId;
|
||||
if (!activeSubscription) {
|
||||
@@ -598,6 +618,7 @@ class TerminalTransportManager {
|
||||
this.clearConnectionTimeout(activeSubscription);
|
||||
this.boundSessionId = null;
|
||||
this.requestedSessionId = null;
|
||||
this.replayCursorBySession.delete(activeSubscription.sessionId);
|
||||
activeSubscription.onEvent({
|
||||
type: 'exit',
|
||||
exitCode: payload.exitCode,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
const ANSI_ESCAPE_PREFIX = String.fromCharCode(27);
|
||||
const ANSI_ESCAPE_PATTERN = new RegExp(`${ANSI_ESCAPE_PREFIX}\\[[0-9;?]*[ -/]*[@-~]`, 'g');
|
||||
const LOOPBACK_URL_PATTERN = /(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[(?:::1|::)\])(?::\d{2,5})?(?:\/[^\s<>'"`]*)?)/gi;
|
||||
const PREVIEW_OUTPUT_PATTERN = /(?:➜\s*(?:Local|Network):)|\b(?:local|network|loopback|serving|listening|available|ready|started|running|server|vite|webpack|next\.js|astro|sveltekit|nuxt)\b/i;
|
||||
const PYTHON_HTTP_SERVER_PATTERN = /Serving HTTP on .*? port (\d{2,5})/i;
|
||||
const TRAILING_PUNCT = new Set(['.', ',', ';', ':', '!', '?']);
|
||||
|
||||
const trimUrlTrailingPunctuation = (url: string): string => {
|
||||
let result = url;
|
||||
while (result.length > 0) {
|
||||
const last = result[result.length - 1];
|
||||
if (last === ')' || last === ']' || last === '}' || last === '>') {
|
||||
const opener = last === ')' ? '(' : last === ']' ? '[' : last === '}' ? '{' : '<';
|
||||
const head = result.slice(0, -1);
|
||||
const opens = (head.match(new RegExp(`\\${opener}`, 'g')) || []).length;
|
||||
const closes = (head.match(new RegExp(`\\${last}`, 'g')) || []).length;
|
||||
if (opens > closes) break;
|
||||
result = head;
|
||||
continue;
|
||||
}
|
||||
if (TRAILING_PUNCT.has(last)) {
|
||||
result = result.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const normalizeLoopbackUrl = (url: string): string => {
|
||||
let normalized = trimUrlTrailingPunctuation(url);
|
||||
normalized = normalized.replace('0.0.0.0', '127.0.0.1');
|
||||
normalized = normalized.replace('[::1]', '127.0.0.1');
|
||||
normalized = normalized.replace('[::]', '127.0.0.1');
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const extractTerminalPreviewUrl = (text: string): string | null => {
|
||||
if (!text) return null;
|
||||
|
||||
const cleaned = text.replace(ANSI_ESCAPE_PATTERN, '');
|
||||
const pythonMatch = cleaned.match(PYTHON_HTTP_SERVER_PATTERN);
|
||||
if (pythonMatch?.[1]) {
|
||||
const port = Number.parseInt(pythonMatch[1], 10);
|
||||
if (Number.isFinite(port) && port > 0 && port <= 65535) {
|
||||
return `http://127.0.0.1:${port}/`;
|
||||
}
|
||||
}
|
||||
|
||||
const lines = cleaned.split('\n');
|
||||
for (const line of lines) {
|
||||
if (!PREVIEW_OUTPUT_PATTERN.test(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const matches = Array.from(line.matchAll(LOOPBACK_URL_PATTERN));
|
||||
if (matches.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const withPort = matches.find((match) => {
|
||||
try {
|
||||
return Boolean(new URL(normalizeLoopbackUrl(match[1])).port);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return normalizeLoopbackUrl((withPort ?? matches[0])[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isTerminalPreviewUrlAvailable = async (url: string, timeoutMs = 1500): Promise<boolean> => {
|
||||
if (!url) return false;
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (host !== 'localhost' && host !== '127.0.0.1' && host !== '0.0.0.0' && host !== '::1' && host !== '::') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch('/api/system/probe-url', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: parsed.toString() }),
|
||||
cache: 'no-store',
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await response.json().catch(() => null) as { ok?: unknown } | null;
|
||||
return result?.ok === true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
@@ -121,67 +121,6 @@ const createEmptyTab = (id: string, label: string): TerminalTab => ({
|
||||
previewUrlLocked: false,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g;
|
||||
// Many dev servers print loopback as 0.0.0.0, localhost, or IPv6 ([::]/[::1]).
|
||||
const URL_PATTERN = /(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[(?:::1|::)\])(?::\d{2,5})?(?:\/[\w\-./~%!$&'()*+,;=:@?#[\]]*)?)/i;
|
||||
|
||||
// Dev server logs frequently wrap URLs in punctuation, e.g.
|
||||
// "Local: http://localhost:5173/ (press h to show help)"
|
||||
// "Serving on (http://127.0.0.1:50028/)."
|
||||
// The URL_PATTERN above intentionally allows sub-delim characters like `()`
|
||||
// in the path (RFC 3986), which means greedy capture can swallow trailing
|
||||
// closing brackets that were really part of the surrounding sentence.
|
||||
// Peel off any trailing closer that has no matching opener inside the URL,
|
||||
// plus common trailing sentence punctuation.
|
||||
const TRAILING_PUNCT = new Set(['.', ',', ';', ':', '!', '?']);
|
||||
const trimUrlTrailingPunctuation = (url: string): string => {
|
||||
let result = url;
|
||||
while (result.length > 0) {
|
||||
const last = result[result.length - 1];
|
||||
if (last === ')' || last === ']' || last === '}' || last === '>') {
|
||||
const opener = last === ')' ? '(' : last === ']' ? '[' : last === '}' ? '{' : '<';
|
||||
// Count matched pairs in the rest of the URL; if there's no unmatched
|
||||
// opener, the closer is from surrounding text — strip it.
|
||||
const head = result.slice(0, -1);
|
||||
const opens = (head.match(new RegExp(`\\${opener}`, 'g')) || []).length;
|
||||
const closes = (head.match(new RegExp(`\\${last}`, 'g')) || []).length;
|
||||
if (opens > closes) break;
|
||||
result = head;
|
||||
continue;
|
||||
}
|
||||
if (TRAILING_PUNCT.has(last)) {
|
||||
result = result.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const extractPreviewUrl = (chunk: string): string | null => {
|
||||
if (!chunk) return null;
|
||||
const cleaned = chunk.replace(ANSI_ESCAPE_PATTERN, '');
|
||||
const match = cleaned.match(URL_PATTERN);
|
||||
if (!match?.[1]) return null;
|
||||
let url = trimUrlTrailingPunctuation(match[1]);
|
||||
// Normalize common loopback hostnames to a stable value so the iframe can load.
|
||||
url = url.replace('0.0.0.0', '127.0.0.1');
|
||||
url = url.replace('[::1]', '127.0.0.1');
|
||||
url = url.replace('[::]', '127.0.0.1');
|
||||
return url;
|
||||
};
|
||||
|
||||
const extractPythonHttpServerUrl = (chunk: string): string | null => {
|
||||
if (!chunk) return null;
|
||||
const cleaned = chunk.replace(ANSI_ESCAPE_PATTERN, '');
|
||||
const match = cleaned.match(/Serving HTTP on .*? port (\d{2,5})/i);
|
||||
if (!match?.[1]) return null;
|
||||
const port = Number.parseInt(match[1], 10);
|
||||
if (!Number.isFinite(port) || port <= 0 || port > 65535) return null;
|
||||
return `http://127.0.0.1:${port}/`;
|
||||
};
|
||||
|
||||
const createEmptyDirectoryState = (firstTab: TerminalTab): DirectoryTerminalState => ({
|
||||
tabs: [firstTab],
|
||||
activeTabId: firstTab.id,
|
||||
@@ -527,17 +466,11 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
bufferLength -= removed.data.length;
|
||||
}
|
||||
|
||||
const maybePreviewUrl = tab.previewUrlLocked ? null : extractPreviewUrl(chunk) ?? extractPythonHttpServerUrl(chunk);
|
||||
const shouldUpdatePreview = Boolean(maybePreviewUrl && maybePreviewUrl !== tab.previewUrl);
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = {
|
||||
...tab,
|
||||
bufferChunks,
|
||||
bufferLength,
|
||||
...(shouldUpdatePreview
|
||||
? { previewUrl: maybePreviewUrl, previewAutoOpened: false }
|
||||
: null),
|
||||
};
|
||||
newSessions.set(key, { ...existing, tabs: nextTabs });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user