mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-03 21:17:50 +00:00
fix: make shell and standalone shell feature based components
- In addition, use one copy handler throughout the app.
This commit is contained in:
63
src/components/shell/constants/constants.ts
Normal file
63
src/components/shell/constants/constants.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { ITerminalOptions } from '@xterm/xterm';
|
||||
|
||||
export const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device';
|
||||
export const SHELL_RESTART_DELAY_MS = 200;
|
||||
export const TERMINAL_INIT_DELAY_MS = 100;
|
||||
export const TERMINAL_RESIZE_DELAY_MS = 50;
|
||||
|
||||
export const TERMINAL_OPTIONS: ITerminalOptions = {
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
allowProposedApi: true,
|
||||
allowTransparency: false,
|
||||
convertEol: true,
|
||||
scrollback: 10000,
|
||||
tabStopWidth: 4,
|
||||
windowsMode: false,
|
||||
macOptionIsMeta: true,
|
||||
macOptionClickForcesSelection: true,
|
||||
// Keep the runtime theme keys used by the previous JSX implementation.
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#ffffff',
|
||||
cursorAccent: '#1e1e1e',
|
||||
selection: '#264f78',
|
||||
selectionForeground: '#ffffff',
|
||||
black: '#000000',
|
||||
red: '#cd3131',
|
||||
green: '#0dbc79',
|
||||
yellow: '#e5e510',
|
||||
blue: '#2472c8',
|
||||
magenta: '#bc3fbc',
|
||||
cyan: '#11a8cd',
|
||||
white: '#e5e5e5',
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#f14c4c',
|
||||
brightGreen: '#23d18b',
|
||||
brightYellow: '#f5f543',
|
||||
brightBlue: '#3b8eea',
|
||||
brightMagenta: '#d670d6',
|
||||
brightCyan: '#29b8db',
|
||||
brightWhite: '#ffffff',
|
||||
extendedAnsi: [
|
||||
'#000000',
|
||||
'#800000',
|
||||
'#008000',
|
||||
'#808000',
|
||||
'#000080',
|
||||
'#800080',
|
||||
'#008080',
|
||||
'#c0c0c0',
|
||||
'#808080',
|
||||
'#ff0000',
|
||||
'#00ff00',
|
||||
'#ffff00',
|
||||
'#0000ff',
|
||||
'#ff00ff',
|
||||
'#00ffff',
|
||||
'#ffffff',
|
||||
],
|
||||
} as any,
|
||||
};
|
||||
215
src/components/shell/hooks/useShellConnection.ts
Normal file
215
src/components/shell/hooks/useShellConnection.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import type { FitAddon } from '@xterm/addon-fit';
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import type { Project, ProjectSession } from '../../../types/app';
|
||||
import { TERMINAL_INIT_DELAY_MS } from '../constants/constants';
|
||||
import { getShellWebSocketUrl, parseShellMessage, sendSocketMessage } from '../utils/socket';
|
||||
|
||||
const ANSI_ESCAPE_REGEX = /\x1b\[[0-9;]*m/g;
|
||||
const PROCESS_EXIT_REGEX = /Process exited with code (\d+)/;
|
||||
|
||||
type UseShellConnectionOptions = {
|
||||
wsRef: MutableRefObject<WebSocket | null>;
|
||||
terminalRef: MutableRefObject<Terminal | null>;
|
||||
fitAddonRef: MutableRefObject<FitAddon | null>;
|
||||
selectedProjectRef: MutableRefObject<Project | null | undefined>;
|
||||
selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>;
|
||||
initialCommandRef: MutableRefObject<string | null | undefined>;
|
||||
isPlainShellRef: MutableRefObject<boolean>;
|
||||
onProcessCompleteRef: MutableRefObject<((exitCode: number) => void) | null | undefined>;
|
||||
isInitialized: boolean;
|
||||
autoConnect: boolean;
|
||||
closeSocket: () => void;
|
||||
clearTerminalScreen: () => void;
|
||||
setAuthUrl: (nextAuthUrl: string) => void;
|
||||
};
|
||||
|
||||
type UseShellConnectionResult = {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
closeSocket: () => void;
|
||||
connectToShell: () => void;
|
||||
disconnectFromShell: () => void;
|
||||
};
|
||||
|
||||
export function useShellConnection({
|
||||
wsRef,
|
||||
terminalRef,
|
||||
fitAddonRef,
|
||||
selectedProjectRef,
|
||||
selectedSessionRef,
|
||||
initialCommandRef,
|
||||
isPlainShellRef,
|
||||
onProcessCompleteRef,
|
||||
isInitialized,
|
||||
autoConnect,
|
||||
closeSocket,
|
||||
clearTerminalScreen,
|
||||
setAuthUrl,
|
||||
}: UseShellConnectionOptions): UseShellConnectionResult {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
|
||||
const handleProcessCompletion = useCallback(
|
||||
(output: string) => {
|
||||
if (!isPlainShellRef.current || !onProcessCompleteRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanOutput = output.replace(ANSI_ESCAPE_REGEX, '');
|
||||
if (cleanOutput.includes('Process exited with code 0')) {
|
||||
onProcessCompleteRef.current(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const match = cleanOutput.match(PROCESS_EXIT_REGEX);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exitCode = Number.parseInt(match[1], 10);
|
||||
if (!Number.isNaN(exitCode) && exitCode !== 0) {
|
||||
onProcessCompleteRef.current(exitCode);
|
||||
}
|
||||
},
|
||||
[isPlainShellRef, onProcessCompleteRef],
|
||||
);
|
||||
|
||||
const handleSocketMessage = useCallback(
|
||||
(rawPayload: string) => {
|
||||
const message = parseShellMessage(rawPayload);
|
||||
if (!message) {
|
||||
console.error('[Shell] Error handling WebSocket message:', rawPayload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'output') {
|
||||
const output = typeof message.data === 'string' ? message.data : '';
|
||||
handleProcessCompletion(output);
|
||||
terminalRef.current?.write(output);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'auth_url' || message.type === 'url_open') {
|
||||
const nextAuthUrl = typeof message.url === 'string' ? message.url : '';
|
||||
if (nextAuthUrl) {
|
||||
setAuthUrl(nextAuthUrl);
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleProcessCompletion, setAuthUrl, terminalRef],
|
||||
);
|
||||
|
||||
const connectWebSocket = useCallback(() => {
|
||||
if (isConnecting || isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const wsUrl = getShellWebSocketUrl();
|
||||
if (!wsUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = new WebSocket(wsUrl);
|
||||
wsRef.current = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
setAuthUrl('');
|
||||
|
||||
window.setTimeout(() => {
|
||||
const currentTerminal = terminalRef.current;
|
||||
const currentFitAddon = fitAddonRef.current;
|
||||
const currentProject = selectedProjectRef.current;
|
||||
if (!currentTerminal || !currentFitAddon || !currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentFitAddon.fit();
|
||||
|
||||
sendSocketMessage(wsRef.current, {
|
||||
type: 'init',
|
||||
projectPath: currentProject.fullPath || currentProject.path || '',
|
||||
sessionId: isPlainShellRef.current ? null : selectedSessionRef.current?.id || null,
|
||||
hasSession: isPlainShellRef.current ? false : Boolean(selectedSessionRef.current),
|
||||
provider: isPlainShellRef.current
|
||||
? 'plain-shell'
|
||||
: selectedSessionRef.current?.__provider || 'claude',
|
||||
cols: currentTerminal.cols,
|
||||
rows: currentTerminal.rows,
|
||||
initialCommand: initialCommandRef.current,
|
||||
isPlainShell: isPlainShellRef.current,
|
||||
});
|
||||
}, TERMINAL_INIT_DELAY_MS);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const rawPayload = typeof event.data === 'string' ? event.data : String(event.data ?? '');
|
||||
handleSocketMessage(rawPayload);
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
clearTerminalScreen();
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
};
|
||||
} catch {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [
|
||||
clearTerminalScreen,
|
||||
fitAddonRef,
|
||||
handleSocketMessage,
|
||||
initialCommandRef,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
isPlainShellRef,
|
||||
selectedProjectRef,
|
||||
selectedSessionRef,
|
||||
setAuthUrl,
|
||||
terminalRef,
|
||||
wsRef,
|
||||
]);
|
||||
|
||||
const connectToShell = useCallback(() => {
|
||||
if (!isInitialized || isConnected || isConnecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
connectWebSocket();
|
||||
}, [connectWebSocket, isConnected, isConnecting, isInitialized]);
|
||||
|
||||
const disconnectFromShell = useCallback(() => {
|
||||
closeSocket();
|
||||
clearTerminalScreen();
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
setAuthUrl('');
|
||||
}, [clearTerminalScreen, closeSocket, setAuthUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoConnect || !isInitialized || isConnecting || isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
connectToShell();
|
||||
}, [autoConnect, connectToShell, isConnected, isConnecting, isInitialized]);
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
closeSocket,
|
||||
connectToShell,
|
||||
disconnectFromShell,
|
||||
};
|
||||
}
|
||||
166
src/components/shell/hooks/useShellRuntime.ts
Normal file
166
src/components/shell/hooks/useShellRuntime.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { FitAddon } from '@xterm/addon-fit';
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import { useShellConnection } from './useShellConnection';
|
||||
import { useShellTerminal } from './useShellTerminal';
|
||||
import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types';
|
||||
import { copyTextToClipboard } from '../../../utils/clipboard';
|
||||
|
||||
export function useShellRuntime({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
initialCommand,
|
||||
isPlainShell,
|
||||
minimal,
|
||||
autoConnect,
|
||||
isRestarting,
|
||||
onProcessComplete,
|
||||
}: UseShellRuntimeOptions): UseShellRuntimeResult {
|
||||
const terminalContainerRef = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<Terminal | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
const [authUrl, setAuthUrl] = useState('');
|
||||
const [authUrlVersion, setAuthUrlVersion] = useState(0);
|
||||
|
||||
const selectedProjectRef = useRef(selectedProject);
|
||||
const selectedSessionRef = useRef(selectedSession);
|
||||
const initialCommandRef = useRef(initialCommand);
|
||||
const isPlainShellRef = useRef(isPlainShell);
|
||||
const onProcessCompleteRef = useRef(onProcessComplete);
|
||||
const authUrlRef = useRef('');
|
||||
const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null);
|
||||
|
||||
// Keep mutable values in refs so websocket handlers always read current data.
|
||||
useEffect(() => {
|
||||
selectedProjectRef.current = selectedProject;
|
||||
selectedSessionRef.current = selectedSession;
|
||||
initialCommandRef.current = initialCommand;
|
||||
isPlainShellRef.current = isPlainShell;
|
||||
onProcessCompleteRef.current = onProcessComplete;
|
||||
}, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]);
|
||||
|
||||
const setCurrentAuthUrl = useCallback((nextAuthUrl: string) => {
|
||||
authUrlRef.current = nextAuthUrl;
|
||||
setAuthUrl(nextAuthUrl);
|
||||
setAuthUrlVersion((previous) => previous + 1);
|
||||
}, []);
|
||||
|
||||
const closeSocket = useCallback(() => {
|
||||
const activeSocket = wsRef.current;
|
||||
if (!activeSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
activeSocket.readyState === WebSocket.OPEN ||
|
||||
activeSocket.readyState === WebSocket.CONNECTING
|
||||
) {
|
||||
activeSocket.close();
|
||||
}
|
||||
|
||||
wsRef.current = null;
|
||||
}, []);
|
||||
|
||||
const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const popup = window.open(url, '_blank', 'noopener,noreferrer');
|
||||
if (popup) {
|
||||
try {
|
||||
popup.opener = null;
|
||||
} catch {
|
||||
// Ignore cross-origin restrictions when trying to null opener.
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return copyTextToClipboard(url);
|
||||
}, []);
|
||||
|
||||
const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({
|
||||
terminalContainerRef,
|
||||
terminalRef,
|
||||
fitAddonRef,
|
||||
wsRef,
|
||||
selectedProject,
|
||||
minimal,
|
||||
isRestarting,
|
||||
initialCommandRef,
|
||||
isPlainShellRef,
|
||||
authUrlRef,
|
||||
copyAuthUrlToClipboard,
|
||||
closeSocket,
|
||||
});
|
||||
|
||||
const { isConnected, isConnecting, connectToShell, disconnectFromShell } = useShellConnection({
|
||||
wsRef,
|
||||
terminalRef,
|
||||
fitAddonRef,
|
||||
selectedProjectRef,
|
||||
selectedSessionRef,
|
||||
initialCommandRef,
|
||||
isPlainShellRef,
|
||||
onProcessCompleteRef,
|
||||
isInitialized,
|
||||
autoConnect,
|
||||
closeSocket,
|
||||
clearTerminalScreen,
|
||||
setAuthUrl: setCurrentAuthUrl,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRestarting) {
|
||||
return;
|
||||
}
|
||||
|
||||
disconnectFromShell();
|
||||
disposeTerminal();
|
||||
}, [disconnectFromShell, disposeTerminal, isRestarting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
disconnectFromShell();
|
||||
disposeTerminal();
|
||||
}, [disconnectFromShell, disposeTerminal, selectedProject]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentSessionId = selectedSession?.id ?? null;
|
||||
if (
|
||||
lastSessionIdRef.current !== null &&
|
||||
lastSessionIdRef.current !== currentSessionId &&
|
||||
isInitialized
|
||||
) {
|
||||
disconnectFromShell();
|
||||
}
|
||||
|
||||
lastSessionIdRef.current = currentSessionId;
|
||||
}, [disconnectFromShell, isInitialized, selectedSession?.id]);
|
||||
|
||||
return {
|
||||
terminalContainerRef,
|
||||
isConnected,
|
||||
isInitialized,
|
||||
isConnecting,
|
||||
authUrl,
|
||||
authUrlVersion,
|
||||
connectToShell,
|
||||
disconnectFromShell,
|
||||
openAuthUrlInBrowser,
|
||||
copyAuthUrlToClipboard,
|
||||
};
|
||||
}
|
||||
233
src/components/shell/hooks/useShellTerminal.ts
Normal file
233
src/components/shell/hooks/useShellTerminal.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { MutableRefObject, RefObject } from 'react';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { WebglAddon } from '@xterm/addon-webgl';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import type { Project } from '../../../types/app';
|
||||
import {
|
||||
CODEX_DEVICE_AUTH_URL,
|
||||
TERMINAL_INIT_DELAY_MS,
|
||||
TERMINAL_OPTIONS,
|
||||
TERMINAL_RESIZE_DELAY_MS,
|
||||
} from '../constants/constants';
|
||||
import { isCodexLoginCommand } from '../utils/auth';
|
||||
import { sendSocketMessage } from '../utils/socket';
|
||||
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
|
||||
|
||||
type UseShellTerminalOptions = {
|
||||
terminalContainerRef: RefObject<HTMLDivElement>;
|
||||
terminalRef: MutableRefObject<Terminal | null>;
|
||||
fitAddonRef: MutableRefObject<FitAddon | null>;
|
||||
wsRef: MutableRefObject<WebSocket | null>;
|
||||
selectedProject: Project | null | undefined;
|
||||
minimal: boolean;
|
||||
isRestarting: boolean;
|
||||
initialCommandRef: MutableRefObject<string | null | undefined>;
|
||||
isPlainShellRef: MutableRefObject<boolean>;
|
||||
authUrlRef: MutableRefObject<string>;
|
||||
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
|
||||
closeSocket: () => void;
|
||||
};
|
||||
|
||||
type UseShellTerminalResult = {
|
||||
isInitialized: boolean;
|
||||
clearTerminalScreen: () => void;
|
||||
disposeTerminal: () => void;
|
||||
};
|
||||
|
||||
export function useShellTerminal({
|
||||
terminalContainerRef,
|
||||
terminalRef,
|
||||
fitAddonRef,
|
||||
wsRef,
|
||||
selectedProject,
|
||||
minimal,
|
||||
isRestarting,
|
||||
initialCommandRef,
|
||||
isPlainShellRef,
|
||||
authUrlRef,
|
||||
copyAuthUrlToClipboard,
|
||||
closeSocket,
|
||||
}: UseShellTerminalOptions): UseShellTerminalResult {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || '';
|
||||
const hasSelectedProject = Boolean(selectedProject);
|
||||
|
||||
useEffect(() => {
|
||||
ensureXtermFocusStyles();
|
||||
}, []);
|
||||
|
||||
const clearTerminalScreen = useCallback(() => {
|
||||
if (!terminalRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
terminalRef.current.clear();
|
||||
terminalRef.current.write('\x1b[2J\x1b[H');
|
||||
}, [terminalRef]);
|
||||
|
||||
const disposeTerminal = useCallback(() => {
|
||||
if (terminalRef.current) {
|
||||
terminalRef.current.dispose();
|
||||
terminalRef.current = null;
|
||||
}
|
||||
|
||||
fitAddonRef.current = null;
|
||||
setIsInitialized(false);
|
||||
}, [fitAddonRef, terminalRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminalContainerRef.current || !hasSelectedProject || isRestarting || terminalRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTerminal = new Terminal(TERMINAL_OPTIONS);
|
||||
terminalRef.current = nextTerminal;
|
||||
|
||||
const nextFitAddon = new FitAddon();
|
||||
fitAddonRef.current = nextFitAddon;
|
||||
nextTerminal.loadAddon(nextFitAddon);
|
||||
|
||||
// Avoid wrapped partial links in compact login flows.
|
||||
if (!minimal) {
|
||||
nextTerminal.loadAddon(new WebLinksAddon());
|
||||
}
|
||||
|
||||
try {
|
||||
nextTerminal.loadAddon(new WebglAddon());
|
||||
} catch {
|
||||
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
|
||||
}
|
||||
|
||||
nextTerminal.open(terminalContainerRef.current);
|
||||
|
||||
nextTerminal.attachCustomKeyEventHandler((event) => {
|
||||
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
|
||||
? CODEX_DEVICE_AUTH_URL
|
||||
: authUrlRef.current;
|
||||
|
||||
if (
|
||||
event.type === 'keydown' &&
|
||||
minimal &&
|
||||
isPlainShellRef.current &&
|
||||
activeAuthUrl &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
event.key?.toLowerCase() === 'c'
|
||||
) {
|
||||
void copyAuthUrlToClipboard(activeAuthUrl);
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'keydown' &&
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
event.key?.toLowerCase() === 'c' &&
|
||||
nextTerminal.hasSelection()
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
document.execCommand('copy');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'keydown' &&
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
event.key?.toLowerCase() === 'v'
|
||||
) {
|
||||
// Block native paste so data is only injected after clipboard-read resolves.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.readText) {
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
.then((text) => {
|
||||
sendSocketMessage(wsRef.current, {
|
||||
type: 'input',
|
||||
data: text,
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
window.setTimeout(() => {
|
||||
const currentFitAddon = fitAddonRef.current;
|
||||
const currentTerminal = terminalRef.current;
|
||||
if (!currentFitAddon || !currentTerminal) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentFitAddon.fit();
|
||||
sendSocketMessage(wsRef.current, {
|
||||
type: 'resize',
|
||||
cols: currentTerminal.cols,
|
||||
rows: currentTerminal.rows,
|
||||
});
|
||||
}, TERMINAL_INIT_DELAY_MS);
|
||||
|
||||
setIsInitialized(true);
|
||||
|
||||
const dataSubscription = nextTerminal.onData((data) => {
|
||||
sendSocketMessage(wsRef.current, {
|
||||
type: 'input',
|
||||
data,
|
||||
});
|
||||
});
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
window.setTimeout(() => {
|
||||
const currentFitAddon = fitAddonRef.current;
|
||||
const currentTerminal = terminalRef.current;
|
||||
if (!currentFitAddon || !currentTerminal) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentFitAddon.fit();
|
||||
sendSocketMessage(wsRef.current, {
|
||||
type: 'resize',
|
||||
cols: currentTerminal.cols,
|
||||
rows: currentTerminal.rows,
|
||||
});
|
||||
}, TERMINAL_RESIZE_DELAY_MS);
|
||||
});
|
||||
|
||||
resizeObserver.observe(terminalContainerRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
dataSubscription.dispose();
|
||||
closeSocket();
|
||||
disposeTerminal();
|
||||
};
|
||||
}, [
|
||||
authUrlRef,
|
||||
closeSocket,
|
||||
copyAuthUrlToClipboard,
|
||||
disposeTerminal,
|
||||
fitAddonRef,
|
||||
initialCommandRef,
|
||||
isPlainShellRef,
|
||||
isRestarting,
|
||||
minimal,
|
||||
hasSelectedProject,
|
||||
selectedProjectKey,
|
||||
terminalContainerRef,
|
||||
terminalRef,
|
||||
wsRef,
|
||||
]);
|
||||
|
||||
return {
|
||||
isInitialized,
|
||||
clearTerminalScreen,
|
||||
disposeTerminal,
|
||||
};
|
||||
}
|
||||
73
src/components/shell/types/types.ts
Normal file
73
src/components/shell/types/types.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { MutableRefObject, RefObject } from 'react';
|
||||
import type { FitAddon } from '@xterm/addon-fit';
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import type { Project, ProjectSession } from '../../../types/app';
|
||||
|
||||
export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
|
||||
|
||||
export type ShellInitMessage = {
|
||||
type: 'init';
|
||||
projectPath: string;
|
||||
sessionId: string | null;
|
||||
hasSession: boolean;
|
||||
provider: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
initialCommand: string | null | undefined;
|
||||
isPlainShell: boolean;
|
||||
};
|
||||
|
||||
export type ShellResizeMessage = {
|
||||
type: 'resize';
|
||||
cols: number;
|
||||
rows: number;
|
||||
};
|
||||
|
||||
export type ShellInputMessage = {
|
||||
type: 'input';
|
||||
data: string;
|
||||
};
|
||||
|
||||
export type ShellOutgoingMessage = ShellInitMessage | ShellResizeMessage | ShellInputMessage;
|
||||
|
||||
export type ShellIncomingMessage =
|
||||
| { type: 'output'; data: string }
|
||||
| { type: 'auth_url'; url?: string }
|
||||
| { type: 'url_open'; url?: string }
|
||||
| { type: string; [key: string]: unknown };
|
||||
|
||||
export type UseShellRuntimeOptions = {
|
||||
selectedProject: Project | null | undefined;
|
||||
selectedSession: ProjectSession | null | undefined;
|
||||
initialCommand: string | null | undefined;
|
||||
isPlainShell: boolean;
|
||||
minimal: boolean;
|
||||
autoConnect: boolean;
|
||||
isRestarting: boolean;
|
||||
onProcessComplete?: ((exitCode: number) => void) | null;
|
||||
};
|
||||
|
||||
export type ShellSharedRefs = {
|
||||
wsRef: MutableRefObject<WebSocket | null>;
|
||||
terminalRef: MutableRefObject<Terminal | null>;
|
||||
fitAddonRef: MutableRefObject<FitAddon | null>;
|
||||
authUrlRef: MutableRefObject<string>;
|
||||
selectedProjectRef: MutableRefObject<Project | null | undefined>;
|
||||
selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>;
|
||||
initialCommandRef: MutableRefObject<string | null | undefined>;
|
||||
isPlainShellRef: MutableRefObject<boolean>;
|
||||
onProcessCompleteRef: MutableRefObject<((exitCode: number) => void) | null | undefined>;
|
||||
};
|
||||
|
||||
export type UseShellRuntimeResult = {
|
||||
terminalContainerRef: RefObject<HTMLDivElement>;
|
||||
isConnected: boolean;
|
||||
isInitialized: boolean;
|
||||
isConnecting: boolean;
|
||||
authUrl: string;
|
||||
authUrlVersion: number;
|
||||
connectToShell: () => void;
|
||||
disconnectFromShell: () => void;
|
||||
openAuthUrlInBrowser: (url?: string) => boolean;
|
||||
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
|
||||
};
|
||||
24
src/components/shell/utils/auth.ts
Normal file
24
src/components/shell/utils/auth.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ProjectSession } from '../../../types/app';
|
||||
import { CODEX_DEVICE_AUTH_URL } from '../constants/constants';
|
||||
|
||||
export function isCodexLoginCommand(command: string | null | undefined): boolean {
|
||||
return typeof command === 'string' && /\bcodex\s+login\b/i.test(command);
|
||||
}
|
||||
|
||||
export function resolveAuthUrlForDisplay(command: string | null | undefined, authUrl: string): string {
|
||||
if (isCodexLoginCommand(command)) {
|
||||
return CODEX_DEVICE_AUTH_URL;
|
||||
}
|
||||
|
||||
return authUrl;
|
||||
}
|
||||
|
||||
export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null {
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return session.__provider === 'cursor'
|
||||
? session.name || 'Untitled Session'
|
||||
: session.summary || 'New Session';
|
||||
}
|
||||
32
src/components/shell/utils/socket.ts
Normal file
32
src/components/shell/utils/socket.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { IS_PLATFORM } from '../../../constants/config';
|
||||
import type { ShellIncomingMessage, ShellOutgoingMessage } from '../types/types';
|
||||
|
||||
export function getShellWebSocketUrl(): string | null {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
|
||||
if (IS_PLATFORM) {
|
||||
return `${protocol}//${window.location.host}/shell`;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('auth-token');
|
||||
if (!token) {
|
||||
console.error('No authentication token found for Shell WebSocket connection');
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${protocol}//${window.location.host}/shell?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
export function parseShellMessage(payload: string): ShellIncomingMessage | null {
|
||||
try {
|
||||
return JSON.parse(payload) as ShellIncomingMessage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function sendSocketMessage(ws: WebSocket | null, message: ShellOutgoingMessage): void {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
29
src/components/shell/utils/terminalStyles.ts
Normal file
29
src/components/shell/utils/terminalStyles.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
const XTERM_STYLE_ELEMENT_ID = 'shell-xterm-focus-style';
|
||||
|
||||
const XTERM_FOCUS_STYLES = `
|
||||
.xterm .xterm-screen {
|
||||
outline: none !important;
|
||||
}
|
||||
.xterm:focus .xterm-screen {
|
||||
outline: none !important;
|
||||
}
|
||||
.xterm-screen:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export function ensureXtermFocusStyles(): void {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.getElementById(XTERM_STYLE_ELEMENT_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const styleSheet = document.createElement('style');
|
||||
styleSheet.id = XTERM_STYLE_ELEMENT_ID;
|
||||
styleSheet.type = 'text/css';
|
||||
styleSheet.innerText = XTERM_FOCUS_STYLES;
|
||||
document.head.appendChild(styleSheet);
|
||||
}
|
||||
162
src/components/shell/view/Shell.tsx
Normal file
162
src/components/shell/view/Shell.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import type { Project, ProjectSession } from '../../../types/app';
|
||||
import { SHELL_RESTART_DELAY_MS } from '../constants/constants';
|
||||
import { useShellRuntime } from '../hooks/useShellRuntime';
|
||||
import { getSessionDisplayName } from '../utils/auth';
|
||||
import ShellConnectionOverlay from './subcomponents/ShellConnectionOverlay';
|
||||
import ShellEmptyState from './subcomponents/ShellEmptyState';
|
||||
import ShellHeader from './subcomponents/ShellHeader';
|
||||
import ShellMinimalView from './subcomponents/ShellMinimalView';
|
||||
|
||||
type ShellProps = {
|
||||
selectedProject?: Project | null;
|
||||
selectedSession?: ProjectSession | null;
|
||||
initialCommand?: string | null;
|
||||
isPlainShell?: boolean;
|
||||
onProcessComplete?: ((exitCode: number) => void) | null;
|
||||
minimal?: boolean;
|
||||
autoConnect?: boolean;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
export default function Shell({
|
||||
selectedProject = null,
|
||||
selectedSession = null,
|
||||
initialCommand = null,
|
||||
isPlainShell = false,
|
||||
onProcessComplete = null,
|
||||
minimal = false,
|
||||
autoConnect = false,
|
||||
isActive,
|
||||
}: ShellProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
|
||||
// Keep the public API stable for existing callers that still pass `isActive`.
|
||||
void isActive;
|
||||
|
||||
const {
|
||||
terminalContainerRef,
|
||||
isConnected,
|
||||
isInitialized,
|
||||
isConnecting,
|
||||
authUrl,
|
||||
authUrlVersion,
|
||||
connectToShell,
|
||||
disconnectFromShell,
|
||||
openAuthUrlInBrowser,
|
||||
copyAuthUrlToClipboard,
|
||||
} = useShellRuntime({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
initialCommand,
|
||||
isPlainShell,
|
||||
minimal,
|
||||
autoConnect,
|
||||
isRestarting,
|
||||
onProcessComplete,
|
||||
});
|
||||
|
||||
const sessionDisplayName = useMemo(() => getSessionDisplayName(selectedSession), [selectedSession]);
|
||||
const sessionDisplayNameShort = useMemo(
|
||||
() => (sessionDisplayName ? sessionDisplayName.slice(0, 30) : null),
|
||||
[sessionDisplayName],
|
||||
);
|
||||
const sessionDisplayNameLong = useMemo(
|
||||
() => (sessionDisplayName ? sessionDisplayName.slice(0, 50) : null),
|
||||
[sessionDisplayName],
|
||||
);
|
||||
|
||||
const handleRestartShell = useCallback(() => {
|
||||
setIsRestarting(true);
|
||||
window.setTimeout(() => {
|
||||
setIsRestarting(false);
|
||||
}, SHELL_RESTART_DELAY_MS);
|
||||
}, []);
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<ShellEmptyState
|
||||
title={t('shell.selectProject.title')}
|
||||
description={t('shell.selectProject.description')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (minimal) {
|
||||
return (
|
||||
<ShellMinimalView
|
||||
terminalContainerRef={terminalContainerRef}
|
||||
authUrl={authUrl}
|
||||
authUrlVersion={authUrlVersion}
|
||||
initialCommand={initialCommand}
|
||||
isConnected={isConnected}
|
||||
openAuthUrlInBrowser={openAuthUrlInBrowser}
|
||||
copyAuthUrlToClipboard={copyAuthUrlToClipboard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const readyDescription = isPlainShell
|
||||
? t('shell.runCommand', {
|
||||
command: initialCommand || t('shell.defaultCommand'),
|
||||
projectName: selectedProject.displayName,
|
||||
})
|
||||
: selectedSession
|
||||
? t('shell.resumeSession', { displayName: sessionDisplayNameLong })
|
||||
: t('shell.startSession');
|
||||
|
||||
const connectingDescription = isPlainShell
|
||||
? t('shell.runCommand', {
|
||||
command: initialCommand || t('shell.defaultCommand'),
|
||||
projectName: selectedProject.displayName,
|
||||
})
|
||||
: t('shell.startCli', { projectName: selectedProject.displayName });
|
||||
|
||||
const overlayMode = !isInitialized ? 'loading' : isConnecting ? 'connecting' : !isConnected ? 'connect' : null;
|
||||
const overlayDescription = overlayMode === 'connecting' ? connectingDescription : readyDescription;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-gray-900 w-full">
|
||||
<ShellHeader
|
||||
isConnected={isConnected}
|
||||
isInitialized={isInitialized}
|
||||
isRestarting={isRestarting}
|
||||
hasSession={Boolean(selectedSession)}
|
||||
sessionDisplayNameShort={sessionDisplayNameShort}
|
||||
onDisconnect={disconnectFromShell}
|
||||
onRestart={handleRestartShell}
|
||||
statusNewSessionText={t('shell.status.newSession')}
|
||||
statusInitializingText={t('shell.status.initializing')}
|
||||
statusRestartingText={t('shell.status.restarting')}
|
||||
disconnectLabel={t('shell.actions.disconnect')}
|
||||
disconnectTitle={t('shell.actions.disconnectTitle')}
|
||||
restartLabel={t('shell.actions.restart')}
|
||||
restartTitle={t('shell.actions.restartTitle')}
|
||||
disableRestart={isRestarting || isConnected}
|
||||
/>
|
||||
|
||||
<div className="flex-1 p-2 overflow-hidden relative">
|
||||
<div
|
||||
ref={terminalContainerRef}
|
||||
className="h-full w-full focus:outline-none"
|
||||
style={{ outline: 'none' }}
|
||||
/>
|
||||
|
||||
{overlayMode && (
|
||||
<ShellConnectionOverlay
|
||||
mode={overlayMode}
|
||||
description={overlayDescription}
|
||||
loadingLabel={t('shell.loading')}
|
||||
connectLabel={t('shell.actions.connect')}
|
||||
connectTitle={t('shell.actions.connectTitle')}
|
||||
connectingLabel={t('shell.connecting')}
|
||||
onConnect={connectToShell}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
type ShellConnectionOverlayProps = {
|
||||
mode: 'loading' | 'connect' | 'connecting';
|
||||
description: string;
|
||||
loadingLabel: string;
|
||||
connectLabel: string;
|
||||
connectTitle: string;
|
||||
connectingLabel: string;
|
||||
onConnect: () => void;
|
||||
};
|
||||
|
||||
export default function ShellConnectionOverlay({
|
||||
mode,
|
||||
description,
|
||||
loadingLabel,
|
||||
connectLabel,
|
||||
connectTitle,
|
||||
connectingLabel,
|
||||
onConnect,
|
||||
}: ShellConnectionOverlayProps) {
|
||||
if (mode === 'loading') {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
|
||||
<div className="text-white">{loadingLabel}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === 'connect') {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
|
||||
<div className="text-center max-w-sm w-full">
|
||||
<button
|
||||
onClick={onConnect}
|
||||
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center space-x-2 text-base font-medium w-full sm:w-auto"
|
||||
title={connectTitle}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span>{connectLabel}</span>
|
||||
</button>
|
||||
<p className="text-gray-400 text-sm mt-3 px-2">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
|
||||
<div className="text-center max-w-sm w-full">
|
||||
<div className="flex items-center justify-center space-x-3 text-yellow-400">
|
||||
<div className="w-6 h-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
|
||||
<span className="text-base font-medium">{connectingLabel}</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mt-3 px-2">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/components/shell/view/subcomponents/ShellEmptyState.tsx
Normal file
25
src/components/shell/view/subcomponents/ShellEmptyState.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
type ShellEmptyStateProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export default function ShellEmptyState({ title, description }: ShellEmptyStateProps) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">{title}</h3>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
src/components/shell/view/subcomponents/ShellHeader.tsx
Normal file
87
src/components/shell/view/subcomponents/ShellHeader.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
type ShellHeaderProps = {
|
||||
isConnected: boolean;
|
||||
isInitialized: boolean;
|
||||
isRestarting: boolean;
|
||||
hasSession: boolean;
|
||||
sessionDisplayNameShort: string | null;
|
||||
onDisconnect: () => void;
|
||||
onRestart: () => void;
|
||||
statusNewSessionText: string;
|
||||
statusInitializingText: string;
|
||||
statusRestartingText: string;
|
||||
disconnectLabel: string;
|
||||
disconnectTitle: string;
|
||||
restartLabel: string;
|
||||
restartTitle: string;
|
||||
disableRestart: boolean;
|
||||
};
|
||||
|
||||
export default function ShellHeader({
|
||||
isConnected,
|
||||
isInitialized,
|
||||
isRestarting,
|
||||
hasSession,
|
||||
sessionDisplayNameShort,
|
||||
onDisconnect,
|
||||
onRestart,
|
||||
statusNewSessionText,
|
||||
statusInitializingText,
|
||||
statusRestartingText,
|
||||
disconnectLabel,
|
||||
disconnectTitle,
|
||||
restartLabel,
|
||||
restartTitle,
|
||||
disableRestart,
|
||||
}: ShellHeaderProps) {
|
||||
return (
|
||||
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
|
||||
{hasSession && sessionDisplayNameShort && (
|
||||
<span className="text-xs text-blue-300">({sessionDisplayNameShort}...)</span>
|
||||
)}
|
||||
|
||||
{!hasSession && <span className="text-xs text-gray-400">{statusNewSessionText}</span>}
|
||||
|
||||
{!isInitialized && <span className="text-xs text-yellow-400">{statusInitializingText}</span>}
|
||||
|
||||
{isRestarting && <span className="text-xs text-blue-400">{statusRestartingText}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{isConnected && (
|
||||
<button
|
||||
onClick={onDisconnect}
|
||||
className="px-3 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 flex items-center space-x-1"
|
||||
title={disconnectTitle}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span>{disconnectLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onRestart}
|
||||
disabled={disableRestart}
|
||||
className="text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
|
||||
title={restartTitle}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<span>{restartLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
src/components/shell/view/subcomponents/ShellMinimalView.tsx
Normal file
113
src/components/shell/view/subcomponents/ShellMinimalView.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { RefObject } from 'react';
|
||||
import type { AuthCopyStatus } from '../../types/types';
|
||||
import { resolveAuthUrlForDisplay } from '../../utils/auth';
|
||||
|
||||
type ShellMinimalViewProps = {
|
||||
terminalContainerRef: RefObject<HTMLDivElement>;
|
||||
authUrl: string;
|
||||
authUrlVersion: number;
|
||||
initialCommand: string | null | undefined;
|
||||
isConnected: boolean;
|
||||
openAuthUrlInBrowser: (url: string) => boolean;
|
||||
copyAuthUrlToClipboard: (url: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export default function ShellMinimalView({
|
||||
terminalContainerRef,
|
||||
authUrl,
|
||||
authUrlVersion,
|
||||
initialCommand,
|
||||
isConnected,
|
||||
openAuthUrlInBrowser,
|
||||
copyAuthUrlToClipboard,
|
||||
}: ShellMinimalViewProps) {
|
||||
const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState<AuthCopyStatus>('idle');
|
||||
const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false);
|
||||
|
||||
const displayAuthUrl = useMemo(
|
||||
() => resolveAuthUrlForDisplay(initialCommand, authUrl),
|
||||
[authUrl, initialCommand],
|
||||
);
|
||||
|
||||
// Keep auth panel UI state local to minimal mode and reset it when connection/url changes.
|
||||
useEffect(() => {
|
||||
setAuthUrlCopyStatus('idle');
|
||||
setIsAuthPanelHidden(false);
|
||||
}, [authUrlVersion, displayAuthUrl, isConnected]);
|
||||
|
||||
const hasAuthUrl = Boolean(displayAuthUrl);
|
||||
const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden;
|
||||
const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-gray-900 relative">
|
||||
<div
|
||||
ref={terminalContainerRef}
|
||||
className="h-full w-full focus:outline-none"
|
||||
style={{ outline: 'none' }}
|
||||
/>
|
||||
|
||||
{showMobileAuthPanel && (
|
||||
<div className="absolute inset-x-0 bottom-14 z-20 border-t border-gray-700/80 bg-gray-900/95 p-3 backdrop-blur-sm md:hidden">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-gray-300">Open or copy the login URL:</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAuthPanelHidden(true)}
|
||||
className="rounded bg-gray-700 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-gray-100 hover:bg-gray-600"
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={displayAuthUrl}
|
||||
readOnly
|
||||
onClick={(event) => event.currentTarget.select()}
|
||||
className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
aria-label="Authentication URL"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
openAuthUrlInBrowser(displayAuthUrl);
|
||||
}}
|
||||
className="flex-1 rounded bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Open URL
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const copied = await copyAuthUrlToClipboard(displayAuthUrl);
|
||||
setAuthUrlCopyStatus(copied ? 'copied' : 'failed');
|
||||
}}
|
||||
className="flex-1 rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-600"
|
||||
>
|
||||
{authUrlCopyStatus === 'copied' ? 'Copied' : 'Copy URL'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showMobileAuthPanelToggle && (
|
||||
<div className="absolute bottom-14 right-3 z-20 md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAuthPanelHidden(false)}
|
||||
className="rounded bg-gray-800/95 px-3 py-2 text-xs font-medium text-gray-100 shadow-lg backdrop-blur-sm hover:bg-gray-700"
|
||||
>
|
||||
Show login URL
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user